Repository navigation
fix(plugin-security): the declared-positions seeder reads through the security catalog read (ADR-0131 C2 stage S2b) - #22210
Conversation
… security catalog read (ADR-0131 C2 S2b) bootstrapDeclaredPositions read the engine registry alone whenever it held any position besides the six built-ins, else the metadata service. One door-authored position therefore made every organization created afterwards seed that position and none of the stack-declared ones. It now reads through createSecurityCatalogReader: the registry and the metadata service in the catalog's one read order, minus the six built-ins by name (bootstrapBuiltinRoles stays their only writer, and a stored definition shadowing a built-in name is skipped too). A name both sources hold is written from the registry's body, as before. Comment-only: claim-seed-ownership's note on the sys_stamp_audit_* builtins (registered in code now, so they run on the claim), and builtin-positions' description of the exclusion. Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
…real-boot harness (ADR-0131 C2 S2b) Three postures, each as it is, with a door-authored position, and with a stored definition under a built-in name: the census and the sys_position write ledger hold the built-ins, every stack-declared position and the door-authored one; the door-authored position's own rows are what the registry-only read wrote; the shadowing definitions change no row and no write; and every principal's grants equal the recorded golden in every scenario with an organization. Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
…rs (ADR-0131 C2 S2b) The declared-positions seeder now builds the security catalog read, which takes the registry's by-name read and disabled-package question beside its list, and a metadata service. The three suites that seed positions from a stub registry give it those members and a metadata service that declares nothing; what each suite asserts is unchanged. Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
…talog read (ADR-0131 C2 S2b) Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
…check:registry-log-declared) The declared-positions seeder's suite now constructs a SchemaRegistry to read the registry's own by-name precedence, which puts plugin-security in check:registry-log-declared's population. Declare OS_REGISTRY_LOG: 'warn' as the gate prescribes; the engine's shipped default is unchanged. Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 16 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 738b10907475748f92ad8306c70a12ee01ec5ef3 && git checkout 738b10907475748f92ad8306c70a12ee01ec5ef3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7d7943dd0dfba6c98afa2200a08983ec85f9849a 5c4e58def70ab2d23de7d770ed580f051e1a59a6 && git checkout -B drift-repro 7d7943dd0dfba6c98afa2200a08983ec85f9849a && git merge --no-ff 5c4e58def70ab2d23de7d770ed580f051e1a59a6
node scripts/docs-audit/affected-docs.mjs --json 7d7943dd0dfba6c98afa2200a08983ec85f9849a |
Part of #15196
Clause-②: no
Stage S2b of ADR-0131 C2: the declared-positions seeder reads through S1's catalog read.
bootstrapDeclaredPositionsused to read one source or the other. It took the engine registry alone whenever the registry held any position besides the six built-ins, and the metadata service otherwise. A position a platform administrator saves withPUT /api/v1/meta/position/:nameis hydrated into the registry. So one such save made every later seeding pass write that position and none of the stack's declared ones, including the pass each new organization gets on a walled deployment. The seeder now readscreateSecurityCatalogReader(...).list('position')(@objectstack/core, from PR #22091). That read takes the engine registry and the metadata service in the catalog's one read order, every pass. The six built-ins are still excluded, by name, sobootstrapBuiltinRolesstays their only writer.Measured on a booted showcase, walled. A platform administrator saves
zz_s2b_doorat the door, then a new organization is created. Before, that organization'ssys_positioncatalog held 7 rows: the six built-ins andzz_s2b_door, with none of the showcase's ten declared positions. After, it holds 17 rows: the six, the ten andzz_s2b_door. Thezz_s2b_doorrow readsadmin | true | false | S2b Dooron both sides.The rest of #15196 stays open. No reader moves to the catalog read here (S8), no Setup write changes (S7),
bootstrapBuiltinRoles' rows do not change, andpackages/specandsecurity-plugin.tsare untouched.The read order, measured before any row write changed
For a name both sources hold, S1's read answers the registry's body, through the registry's own by-name precedence: a stored definition in the bare slot first, else the first-registered package's. The old seeder answered the same, because a non-built-in name in the registry was itself what made the registry answer alone. The pin
a name both sources hold is written from the registry's bodyuses the realSchemaRegistrywith a door-savedfield_repbeside a declaredfield_rep. It writes the door-saved label and description on both sides of the change: green at base959c209d56with the old seeder restored by blob, and green at this head.One boundary was read and not pinned. The old read iterated
listItems, so a name the registry held twice had both entries processed, the last one's label winning. S1 answers one entry per name. For a non-built-in position name this state is unreachable today: the manifest'spositionskey reaches no registry (measured in S2), so only the six carry a composite package entry, and the six are excluded.What changed
bootstrap-declared-positions.ts:readDeclaredPositionsis the catalog read's list minus the six (isBuiltinPositionName, an exact name match), mapped to each entry'sdefinition. The row writes are unchanged: the same columns, the same three-outcome existence read and the same refusal reporting.TypeError). It raisesAuthzStoreUnavailableError(SERVICE_UNAVAILABLE, 503) for a source that threw or a metadata list that lost a loader. The seeder lets both reach its caller, which already catches and logs[security] declared-position seeding failedatwarn. Before, the seeder swallowed both and seeded whatever part it could read, silently. This is the only behaviour change outside the door-authored state, and it is in a failure path. In 26 booted dogfood files it never fired (zero such lines).claim-seed-ownership.tsnow carries thedomain:engineseat's proposed sentence on thesys_stamp_audit_*builtins (note 6050244606). The note onbuiltin-positions.tsdescribing the declared seeder's exclusion said "registry-first decision"; this stage made that false, so it now describes the catalog read.bootstrap-seed-round-trips,per-organization-catalog,seed-write-refusal). Each stub gains the two members the catalog read calls (getItem,isPackageDisabled), and each suite passes a metadata service that declares nothing instead ofnull. What each suite asserts is unchanged.vitest.config.tsdeclaresOS_REGISTRY_LOG: 'warn'. The seeder's unit suite now constructs aSchemaRegistry, which puts this package incheck:registry-log-declared's population; the gate went red without it and prescribes exactly this line. Side effect, measured: the full suite's log went from 10,908[Registry]lines to 7.@objectstack/plugin-securitypatch, carrying the sameClause-②: noline.Surface. The claim amendment (6051984337) named
bootstrap-declared-positions.ts, its tests, the S2 boot harness and theclaim-seed-ownership.tscomment. Every other file above is inplugin-securityand is either a test double of this seeder, a comment this stage made false, or the line a gate demanded. Apart from the changeset, no file outsideplugin-securityis touched.Pins
The real-boot harness (
builtin-positions.boot.test.ts) boots this plugin for real:init,start, then itskernel:readyhandlers in order, over ObjectQL on SQLite, withfield_repdeclared in the metadata service. It now runs 9 scenarios: three postures (single,single + organization,walledwith an organization at boot plus one created after), each booted as it is, with a door-authored position in the registry, and with a stored definition under a built-in name.managed_by,active,is_default, label and description. The ledger holds everysys_positioninsert and update, refused ones included. In each scenario they equal the built-ins, every stack-declared position and the door-authored one. The three postures as they are keep S2's goldens unchanged, as recorded before the declarations. The door-authored goldens now includefield_repin every pass.single, then walled) are pinned separately to what the registry-only read wrote, and the pin is green on both sides of the change.org_adminandeveryonesaved as package-less, tenant-authored definitions. Positive control: the catalog read answers the stored body for both names. Pinned: the census and ledger equal the plain posture's, so the declared seeder neither seeds nor restamps either name, and the built-in rows are exactly whatbootstrapBuiltinRoleswrites.single + organizationand walled, times the three states), where S2 ran it in 2. No principal holds a newly seeded position.everyone, relabellingplatform_admin, plus the two controls.Unit pins (
bootstrap-declared-positions.test.ts) run over the realSchemaRegistry, the six registered the way the plugin registers them:SERVICE_UNAVAILABLE/ 503;TypeError) instead of reading the registry alone.Base leg (
bootstrap-declared-positions.tsrestored from959c209d56by blob, then restored toHEAD: blob equal,git diff HEADempty). The two files ran 6 red and 40 green. The 6 red are the three door-authored census/ledger scenarios (the only difference is the missingfield_reprows and inserts), the unit trap pin and the two failure-path pins. Every grant, refusal, shadow, read-order and door-authored-row pin is green at base.Ablations. Each went through
scripts/ablation-replace.mjs(anchor 1 to 0, blob changed). Each was restored bygit checkout HEAD, blob equal toHEADandgit diff HEADempty, and re-checked by a separate blob comparison. The run covers five suites, 118 tests.Measured on a booted showcase, walled
The flow is a platform administrator's
PUT /api/v1/meta/position/zz_s2b_door(200), thensys_organizationinsertorg_s2b_gamma, then a read of itssys_positionrows. Before isplugin-securityrebuilt from the base seeder source; the dist preflight found the base marker in 2 built files, and after the run the source was restored by blob, rebuilt, and the marker was absent again. After is this branch, pre-merge2d41056b54and again at5c4e58def7.zz_s2b_doorzz_s2b_doorzz_s2b_doorrowadmin, active, not default,S2b DoorIn
single, the seeder runs once per boot, so a door-saved position reaches it at the next boot as a hydrated registry item. That is the harness'ssingledoor-authored scenario:insert door_authored@-, written identically before and after.Verification
All at
5c4e58def7, the head of this PR:origin/mainc6fe02d7acmerged in, thenpnpm install --frozen-lockfileand the closures rebuilt.plugin-security:typecheckexit 0, test layer 0 errors.vitest run: 175 files, 3715 passed, 45 skipped.core:typecheckexit 0, test layer at its 4 pinned debt signatures.vitest run --project local: 78 files, 2192 passed. Measured before the merge, ate593e1ab2a. The merge brought no change topackages/core.@objectstack/plugin-security...(18 packages) and@objectstack/dogfood^...(63 packages).showcase-declarative-rbac-seeding,me-apps-and-everyone-baseline,membership-role-vocabulary,showcase-d7-default-profile,delegation-of-duty,org-scoped-sharing-rule-listing). 26 passed, with 220 tests passed and 3 skipped.rls-multitenantskipped itself through its own organizations probe. No boot loggeddeclared-position seeding failed. The same 27 files read the same before the merge.dispatch-gates --commandsderives for this diff, run at5c4e58def7, each exit captured before any pipe: 65 of 65 exit 0.--ranreconciled them as 65 derived, 65 run, 0 NOT-MEASURED (a derived zero), 0 UNRUN.2d41056b54, two commands did not exit 0.check:registry-log-declaredwent red because the seeder's suite now constructs aSchemaRegistry; the line it prescribes is ine593e1ab2a.check:dual-build-cjs-loadsanswered PREREQUISITE NOT MET because eight packages outside this diff's closure were unbuilt; it passed once they were built.check:query-options-erasurereads 236 on the test surface, unchanged..tsfiles,eslint --no-inline-config --format json: 9 files linted, 0 errors, 0 warnings. The population iseslint.config.mjs's own: everypackages/**TypeScript file, and none of the 9 was ignored. The file count is read from the JSON. The config enables no type-aware linting (noparserOptions.project), so this diff cannot change the verdict on any file it does not touch. The repo-widepnpm lintis CI's.Acceptance notes
warn. The caller's level issecurity-plugin.ts's, which this stage does not touch.security-catalog.tsstill carries a measured table from before S2 (position: engine registry 0). That table is already carried to S8a, and nothing here changes it.scripts/adr-anchors/entry is added; the ADR-0131 D2 anchor is carried to S8a (seat ACCEPT 6051569578).Generated by Claude Code