Repository navigation
fix(store,accounts): make the scope-setting rename deployable, and refuse a catalog that cannot be served - #1303
Merged
antoinetoussaint-byte merged 7 commits intoNov 4, 2026
Conversation
…talog that cannot be served Migration 65 renamed the two RLS scope settings onto the platform Postgres library's names in one step, which is not a deployable move: the catalog and the binary that binds it ship separately, and whichever loses the race reads an unset setting — zero rows on every scoped SELECT, a WITH CHECK failure on every INSERT, and nothing raised. A rolling update puts pre-rename pods against the renamed catalog; rolling the image back leaves the whole fleet there. Migration 66 opens a compatibility window: every policy predicate and routine body reads the library's name with the host's retired one behind it, and the host binds both generations, so one image serves a catalog from either side of the rename. It is catalog-driven rather than generated, and it ships as tools/rls-scope-settings-transition.sql so a consumer whose own migrations wrote policies on the retired names reaches the same state; the migration is that file byte for byte, held in step by a test. Accounts refuses to start against a database whose policies read only the retired names: the two pools service-postgres owns bind the library's names and take no more, so no binding could make such a catalog serve and a started process would report healthy while answering nothing through them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ge a released connection on both The effect-receipts tenant scope bound the two library names with its own statement; it now uses the one request binding, so no path is left behind when the compatibility window closes. The release reset reads back all four names: inside the window a predicate falls through to the retired name when the live one is empty, so a session-level binding on either generation hands the next borrower a scope. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rocess and not for the tooling store Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…annot collide in pkg/infra Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…indow renders it The pinned text is the catalog's own rendering, so opening the window moves it: users_select now reads the library's user setting and falls through to the host's retired one. Owner, SECURITY DEFINER, the app_tenant EXECUTE grant and the absence of a PUBLIC grant are unchanged. Closing the window moves it back, which is why the text is pinned rather than asserted to mention a setting. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…iting for a backend to come back Two separate things the first CI run and the first suite run found. errcheck: the migration-66 test dropped (*migrate.Migrate).Close's two returns. Fixed in the shape #1301 used for the same call in the migration-65 test, which landed while this branch was open and is why CI reported only this one. TestRequestConnectionCarriesNoBindingAfterRelease released the poisoned connection to the shared pool and then waited five seconds for that backend's PID to reappear idle, which needs the reset to have cleaned the session AND nothing else in the package to be holding every request connection for the length of the wait. The second is not a property of this code, and a failure could not say which half it was. It now drives the pool's own AfterRelease judgement on the connection it poisoned: the verdict the pool acts on, plus the session the next borrower would be handed, carrying none of the four names and back to app_tenant. The poison is set on all four, because inside the compatibility window a predicate falls through to the retired name when the live one is empty. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
antoinetoussaint-byte
force-pushed
the
issue-1293-rls-setting-rename-transition
branch
from
November 4, 2026 05:15
b32ea23 to
f27490c
Compare
antoinetoussaint-byte
added a commit
that referenced
this pull request
Nov 4, 2026
…quired Measured on #1303: with `boundaries` added as a required status check, the merge-queue entry sat in AWAITING_CHECKS with every other required context already green. `boundaries` was the one context absent from the queue head, because this workflow fired only on `pull_request` and `push`. A required context that never reports on a queue entry strands the pull request until the check-response timeout ejects it. `go.yml` already carries `merge_group` with a comment saying exactly this. This brings the boundary gate to the same footing so the requirement can be put back on the ruleset, where the gate actually binds rather than merely reports. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
crisfiorini-oss
pushed a commit
to crisfiorini-oss/module-saas-starter
that referenced
this pull request
Nov 4, 2026
…quired (codefly-dev#1305) Measured on codefly-dev#1303: with `boundaries` added as a required status check, the merge-queue entry sat in AWAITING_CHECKS with every other required context already green. `boundaries` was the one context absent from the queue head, because this workflow fired only on `pull_request` and `push`. A required context that never reports on a queue entry strands the pull request until the check-response timeout ejects it. `go.yml` already carries `merge_group` with a comment saying exactly this. This brings the boundary gate to the same footing so the requirement can be put back on the ruleset, where the gate actually binds rather than merely reports. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Issue #1293 (reopened), post-merge adversarial review r54 of #1294 (merged ad81f71). No closing keyword: whether the issue is done is the owner's call, not this PR's.
Migration 65 (#1294) renamed the two RLS scope settings onto the platform Postgres library's names in one step. A one-step rename of a scope setting is not deployable: the catalog and the binary that binds it ship separately, and the predicate is
col::text = current_setting(<name>, true), so whichever side loses the race reads an unset setting — zero rows on every scoped SELECT, a WITH CHECK failure on every INSERT, and nothing raised anywhere. This delivers the transition that was missing, and the four smaller findings from the post-merge review of that PR.Classification: fix. Every change sits at the place that owns the behaviour — the ledger owns the predicates,
pkg/infraowns the binding and the startup refusal,module/toolsowns the gate and the recipe a consumer runs. No stopgaps, nothing labelled temporary except the compatibility window itself, whose closing release is documented and whose text is already committed and proved as66_scope_settings_transition.down.sql.Reuse. The rewrite is one artifact used three ways:
module/tools/rls-scope-settings-transition.sqlis the file a consumer runs, store migration 66 is that file byte for byte, and the down half is the later migration that closes the window. It is catalog-driven rather than generated, so it reaches what no generated file here could name — a policy whose name depends on the month (theaudit_eventspartitions) and a consumer's own policies — and it reuses the ledger's existing idioms rather than adding mechanisms:ALTER POLICYinstead of a DROP/CREATE pair, migration 64's replace-as-owner dance for a routine the baseline reassigned, andaudit_events' own partition policies as ordinarypg_policyrows. The startup refusal reuses the construction-time refusal seamNewPostgresStoreWithCapabilitiesalready applies to a misprovisioned login; no health endpoint, no second probe. What I deliberately did not reuse:scopedpostgres' own scope-setting default, which is exactly what F3 is about.Review r54
F1 — major — version skew silently empties every tenant table. Fixed.
module/services/store/migrations/66_scope_settings_transition.up.sqlrewrites every policy predicate and routine body toCOALESCE(NULLIF(current_setting('codefly.current_tenant_id', true), ''), current_setting('app.current_org_id', true)), and the same for the user pair. Fail-closed is preserved exactly: both arms empty is a false predicate, not an absent one. It is catalog-driven and idempotent, bounded bySET LOCAL lock_timeout = '15s', and usesALTER POLICY, which cannot restate a policy's kind, verb or role list.pkg/infra.bindRequestScopeSQLbinds all four names from the newLegacyTenantScopeSetting/LegacyUserScopeSettingconstants, so one image is correct against a catalog from before the rename, after it, or inside the window. Two other sites that bind or check a scope now cover both generations: the effect-receipts tenant scope reuses the one binding instead of its own statement, and the release reset (sessionReset) reads back all four names, because inside the window a session-level binding on the retired half hands the next borrower a scope.pkg/infra.requireCatalogReadsScopeSettingsrefuses to construct the store against a database whose policies read only the retired names. It is a refusal rather than a warning for a reason worth stating: the host can bind both generations on its own request pool, but the two poolsservice-postgresowns bind the library's two names and take no more, so against such a catalog every read through them is silently empty and no binding here can fix it — a started process would report healthy and answer nothing. It asks the catalog what the policies judge by, not the ledger what version it claims.TestMigration66OpensTheScopeSettingCompatibilityWindowstands a database at 65, asserts the skew exists as described (a transaction binding the retired name is admitted to nothing), applies 66, and requires that the catalog changed in exactly that one way and no other (fingerprint equality against an independently re-derived substitution over kind, verb, roles, owners and ACLs); that a transaction binding either generation sees its own organisation and no other; that the library's name wins over a stale retired value; that an unbound transaction and one with both halves bound empty see nothing; that the WITH CHECK half admits a write for its own tenant and refuses one for another (a count, because a refused UPDATE under forced RLS is not an error); that the down half restores 65's catalog exactly and replay reproduces the window; and that applying the up half twice is the same catalog.TestScopeSettingRefusalReadsTheCatalogsVersionis the decision over a catalog at each version — 64 (retired names alone) refused, 65 and 66 served, a half-applied rewrite refused and naming the policy left behind, an empty catalog refused.TestCatalogScopeSettingCheckReadsTheLiveCatalogdrives the real query against the live catalog: the frontier serves, and a policy staged in a rolled-back transaction with migration 64's own predicate shape refuses with the remedy named.Not done as literally specified: I did not boot an accounts process against a full version-64 ledger. The refusal is per policy, so version 64 is seventy-five instances of the one shape the DB test stages and the decision table covers; standing a second cluster and a second ledger to boot a process against it would prove the same rule over more rows. Say the word and I will add it to the store-side replay suite, which is the one place with a container runtime.
module/services/store/migrations/README.md § The compatibility window, and how to close it: its migration is the text of the down half, renumbered above the frontier, cut only once no deployable image binds the retired names, and it must not ship beside 66 — together they are the one-step rename again.F2 — major — consumer modules on
app.current_org_idgo silently empty; no breaking-change entry, no recipe. Fixed.module/module.package.codefly.yaml: amigrationsrow (scope-settings-transition,from: ">=0.1.0 <0.2.0", breaking) and a breaking-changes entry that states the failure mode in the words it actually appears in (every such table reads as empty, every INSERT fails its WITH CHECK, nothing raised) and names both remedies.module/tools/rls-scope-settings-transition.sql, under an artifact root that ships. It is one rewrite that reaches the same end state from either side of the rename: a predicate already in the window is left alone, one on the library's name is wrapped, and one still on the retired name is wrapped to the same text — which is also that consumer's rename.module/tools/rls_scope_settings_recipe_test.goholds the migration to the recipe byte for byte (and refuses a migration that appends a body of its own after it), and holds the recipe to the four names, the NULLIF, the lock bound andALTER POLICY.module/tools/rls-migration-gate.mjs: the refusal now names the remedy — rewrite onto the two library names, or run the recipe over a database whose policies are already installed — with two new gate tests, one for the retired-name refusal and one proving the compatibility read is admitted.F3 — minor — the scoped pools coupled to an unexported library default. Fixed.
openScopedBoundaryAspassesscopedpostgres.WithScopeSettings(TenantScopeSetting, UserScopeSetting)again. Behaviour is unchanged today — they are the library's own defaults — and that is the point: the default is unexported, its doc comment in the published library still saysapp.current_tenant_id, and nothing tied it to the constant the policies, the request binding and the startup check all read. The comment that argued the override would be "a second place to keep the names in step" is replaced by the opposite argument, which is the one the published library's own stale doc comment makes for us.F4 — minor — migration 65 takes AccessExclusiveLock on 70 relations with no
lock_timeout. Fixed forward, not in 65. A shipped migration is immutable here —module/services/store/migrations/README.mdsays so andmigration-reference-gate.mjsenforces it against the base SHA, so an edit to 65 would be refused by CI and would in any case do nothing for a database that already applied it. The bound lives where the same locks are taken again: 66 opens withSET LOCAL lock_timeout = '15s'and rewrites withALTER POLICYrather than a DROP/CREATE pair, and it calls no partition helper at all (the partitions' policies arepg_policyrows the walk already reaches, which is also why no deadline is needed aroundaudit_events_secure_partition_within). The README now carries the rule for the next bulk migration: an unbounded wait on seventy relations turns a deploy into an outage, a bounded one fails and is retried.F5 — minor —
AGENTS.mdnames a test that does not exist. Fixed.module/services/accounts/AGENTS.mdnow namesTestNoTenantRebindingOutsideInfra. TheCLAIM_INVENTORY.mdrow for the rename did not name it, so nothing else needed correcting;module/DATABASE_AUTHORITY.mdalready had it right and is updated for the widened scan.F6 — minor — the source scan no longer flags the retired names. Fixed. The rebinding rule is
(?i)\b(?:app|codefly)\s*\.\s*current_, so a statement outsidepkg/infrathat binds or reads either generation is refused — which matters more now, not less, since the retired name is readable again for the length of the window.TestTenantRebindingScanSeesEveryFormgains aset_config('app.current_org_id', …)case, andTestNoTenantRebindingOutsideInfrapasses over the tree.The release-reset test no longer waits for a backend
TestRequestConnectionCarriesNoBindingAfterReleasefailed once in the first full run of the accounts DB suite and passed in isolation and on the re-run. Its old shape released the poisoned connection to the shared pool and then waited up to five seconds for that backend's PID to reappear among the idle connections, which is two conditions at once: the reset must have cleaned the session, AND nothing else in the package may hold every request connection for the length of the wait. The second is not a property of this code — one background dispatcher is enough to fail it — and a failure could not say which half it was.Asserted without requiring that backend to come back. The test now drives the pool's own
AfterReleasejudgement (returnsToTenantRole, exposed throughexport_test.go) on the connection it poisoned, and asserts both halves there: the judgement returns true, which is the verdict the pool acts on — true puts the connection back, false destroys it — and the session that the next borrower would be handed carries none of the four names and is back toapp_tenant. No wait, no PID matching, no shared-pool timing; it runs in 0.00s where the old one could take five seconds. I widened rather than removed: the poison is now set on all four names, because inside the window a predicate falls through to the retired name when the live one is empty, so a session-level binding on either is the previous caller's scope.Both halves are proved load-bearing by mutation rather than assumed. Replacing
RESET ALLwith targeted clears of the two library names only — same statement count, so the reset still succeeds — leaves the retired poison behind: with the read-back this PR ships, the verdict assertion fires; with the pre-change two-name read-back, the verdict passes and the four-name assertion fires namingapp.current_org_id. DroppingRESET ALLoutright fires the verdict assertion too.Verified
node module/tools/migration-reference-gate.mjs origin/main --replay— the whole migration suite on real PostgreSQL 16 clusters, green in 1212s:TestMigration66OpensTheScopeSettingCompatibilityWindowpasses,TestMigration65RenamesEveryScopeSettingAndNothingElsestill passes (it stands a database at 65, where binding the retired name must still admit nothing — 66 does not weaken that assertion, it moves the version it holds at), andTestMigrationUpgradeproves 66 as an upgrade fromorigin/main's ledger (verified added versions: [66 67], 67 being the probe migration that test appends). The 66 test was re-run alone against the final bytes.python3 qualification/managed-database/ci.py— passed, 141 tests. This installs the ledger twice, as a superuser and as the least-privilegedNOSUPERUSER NOBYPASSRLS CREATEROLE NOINHERITprincipal, and requires one schema and one authority catalog from both. It is not optional for this change: 66 replaces routines the baseline reassigned to an application role, and grants and takes backCREATE ON SCHEMA publicaround each one — the class of mistake a local superuser install cannot see. It also re-ran every request-scope guard:*_enqueue_empty_contextandwebhook_sync_empty_org_deniedstill refuse with 42501, because an empty live binding falls through to an unset retired one and yields NULL, whichIS DISTINCT FROMand=both read as "no".go test -p 1 ./pkg/infra/in accounts — the whole DB suite, against the local store at the ledger's frontier: green in 152s. It is the suite that matters most here, because the binding change touches every request transaction, and it caught two things a narrower run would not have:TestOrganizationMemberEmailFunctionHasPinnedAuthoritypins theusers_selectpredicate as literal catalog text, which the window moves. The pin is now the compatibility read, built from the two constants, with the fallback arm named as part of it; owner,SECURITY DEFINER, theapp_tenantEXECUTE grant and the absence of a PUBLIC grant are unchanged and still pinned. Closing the window moves it back, which is the reason to pin the text rather than assert that it mentions a setting.TestRequestConnectionCarriesNoBindingAfterReleasefailed once in the first full run and passed in isolation and in the re-run. Rather than leave that as a known flake, it is rewritten — see below.TestEveryScopedPolicyReadsTheLibrarySettings(rewritten to require the window's shape rather than the retired names' absence, with the live-read counts held to the compatibility form),TestCatalogScopeSettingCheckReadsTheLiveCatalog, the decision table, and the rebinding scan.golangci-lint run --max-issues-per-linter=0 --max-same-issues=0withGOWORK=offinmodule/services/store/code,module/services/accounts/codeandmodule/tools: 0 issues in each, on golangci-lint 2.15.0 (CI pins 2.14.0, so local is the stricter of the two). TheGo lint (module/services/store/code)failure on the first push was one errcheck finding on this PR's owndefer runner.Close(); it is nowdefer func() { _, _ = runner.Close() }(), the shape fix(lint): check the three return values errcheck found in accounts and store tests #1301 used for the same call in the migration-65 test. That PR landed while this branch was open, which is also why CI saw only the one finding — this branch is rebased onto it.node module/tools/rls-migration-gate.mjs check(55 unit tests),migration-pairing-gate.mjs check,migration-reference-gate.mjs origin/main,naming-gate.mjs check,scripts/check-boundaries.sh,test-targets-gate.mjs check,authz-coverage-gate.mjs check,module-verify.mjs,go test ./...inmodule/tools,go test ./...inmodule/services/store/code.pg_catalog.qualification and its cast spelling through the rewrite and back.Not verified: nothing in this change was left unexercised. The one shape I did not stand up is a whole version-64 ledger under a booted accounts process, as noted under F1.
🤖 Generated with Claude Code