Skip to content

fix(plugin-security): the declared-positions seeder reads through the security catalog read (ADR-0131 C2 stage S2b) - #22210

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-15196-s2b-declared-seeder-seam
Oct 8, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-15196-s2b-declared-seeder-seam

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #15196
Clause-②: no

Stage S2b of ADR-0131 C2: the declared-positions seeder reads through S1's catalog read. bootstrapDeclaredPositions used 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 with PUT /api/v1/meta/position/:name is 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 reads createSecurityCatalogReader(...).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, so bootstrapBuiltinRoles stays their only writer.

Measured on a booted showcase, walled. A platform administrator saves zz_s2b_door at the door, then a new organization is created. Before, that organization's sys_position catalog held 7 rows: the six built-ins and zz_s2b_door, with none of the showcase's ten declared positions. After, it holds 17 rows: the six, the ten and zz_s2b_door. The zz_s2b_door row reads admin | true | false | S2b Door on 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, and packages/spec and security-plugin.ts are 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 body uses the real SchemaRegistry with a door-saved field_rep beside a declared field_rep. It writes the door-saved label and description on both sides of the change: green at base 959c209d56 with 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's positions key 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: readDeclaredPositions is the catalog read's list minus the six (isBuiltinPositionName, an exact name match), mapped to each entry's definition. The row writes are unchanged: the same columns, the same three-outcome existence read and the same refusal reporting.
  • A read that did not happen is not "nothing declared". The catalog read refuses to be built over a source missing a member it calls (TypeError). It raises AuthzStoreUnavailableError (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 failed at warn. 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).
  • Comment-only: claim-seed-ownership.ts now carries the domain:engine seat's proposed sentence on the sys_stamp_audit_* builtins (note 6050244606). The note on builtin-positions.ts describing the declared seeder's exclusion said "registry-first decision"; this stage made that false, so it now describes the catalog read.
  • Test doubles: three sibling suites seed positions from a stub registry (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 of null. What each suite asserts is unchanged.
  • vitest.config.ts declares OS_REGISTRY_LOG: 'warn'. The seeder's unit suite now constructs a SchemaRegistry, which puts this package in check: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.
  • Changeset: @objectstack/plugin-security patch, carrying the same Clause-②: no line.

Surface. The claim amendment (6051984337) named bootstrap-declared-positions.ts, its tests, the S2 boot harness and the claim-seed-ownership.ts comment. Every other file above is in plugin-security and 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 outside plugin-security is touched.

Pins

The real-boot harness (builtin-positions.boot.test.ts) boots this plugin for real: init, start, then its kernel:ready handlers in order, over ObjectQL on SQLite, with field_rep declared in the metadata service. It now runs 9 scenarios: three postures (single, single + organization, walled with 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.

  • The census and the write ledger. The census reads name, organization, managed_by, active, is_default, label and description. The ledger holds every sys_position insert 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 include field_rep in every pass.
  • The door-authored position's own rows (single, then walled) are pinned separately to what the registry-only read wrote, and the pin is green on both sides of the change.
  • The built-in shadow state. The reviewer flagged it in 6051555790 ③: org_admin and everyone saved 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 what bootstrapBuiltinRoles writes.
  • Grant equivalence. The resolver's whole envelope for the platform administrator, the organization administrator, a member and an agent equals S2's golden. It runs in all 6 scenarios with an organization (single + organization and walled, times the three states), where S2 ran it in 2. No principal holds a newly seeded position.
  • Position write refusals match S2's golden in the same 6 scenarios: a dangling name, deleting everyone, relabelling platform_admin, plus the two controls.

Unit pins (bootstrap-declared-positions.test.ts) run over the real SchemaRegistry, the six registered the way the plugin registers them:

  • a door-authored position no longer silences the declared ones;
  • the door-authored row is written as before;
  • the read order pin above;
  • the shadow pin, covering both hydration shapes: tenant-authored only, and with this plugin's envelope grafted on;
  • a source that throws, or a metadata list that lost a loader, writes nothing and rejects with SERVICE_UNAVAILABLE / 503;
  • a composition with no metadata service rejects (TypeError) instead of reading the registry alone.

Base leg (bootstrap-declared-positions.ts restored from 959c209d56 by blob, then restored to HEAD: blob equal, git diff HEAD empty). 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 missing field_rep rows 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 by git checkout HEAD, blob equal to HEAD and git diff HEAD empty, and re-checked by a separate blob comparison. The run covers five suites, 118 tests.

ablation red
A1: restore the either-or read 6: the three door-authored scenarios (census and ledger), the unit trap pin, the two failure-path pins
A2: the seeder takes built-in names (no exclusion) 15: all nine boot scenarios' census and ledger, and six unit pins, the shadow pin among them
A3: exclude by package instead of by name 7: exactly the three shadow scenarios, the unit shadow pin, and the three S2 unit pins whose stub carries no package
A4: read the metadata service alone 30, the door-authored rows pin among them (all three scenarios)
A5: metadata service first for a shared name 5: the read-order pin, the three door-authored ledgers (order), a failure-path pin
A6: grant sensitivity (built-in names taken and inserted inactive) 26, the grant golden among them in all 6 scenarios with an organization

Measured on a booted showcase, walled

The flow is a platform administrator's PUT /api/v1/meta/position/zz_s2b_door (200), then sys_organization insert org_s2b_gamma, then a read of its sys_position rows. Before is plugin-security rebuilt 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-merge 2d41056b54 and again at 5c4e58def7.

reading before after
an organization present before the save 16 rows 16 rows
the organization created after the save 7 rows: the six and zz_s2b_door 17 rows: the six, the ten declared, zz_s2b_door
the zz_s2b_door row admin, active, not default, S2b Door identical

In 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's single door-authored scenario: insert door_authored@-, written identically before and after.

Verification

All at 5c4e58def7, the head of this PR: origin/main c6fe02d7ac merged in, then pnpm install --frozen-lockfile and the closures rebuilt.

  • plugin-security: typecheck exit 0, test layer 0 errors. vitest run: 175 files, 3715 passed, 45 skipped.
  • core: typecheck exit 0, test layer at its 4 pinned debt signatures. vitest run --project local: 78 files, 2192 passed. Measured before the merge, at e593e1ab2a. The merge brought no change to packages/core.
  • Closures built: @objectstack/plugin-security... (18 packages) and @objectstack/dogfood^... (63 packages).
  • Dogfood: 27 files, the 21 that boot a walled posture plus six position and baseline files (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-multitenant skipped itself through its own organizations probe. No boot logged declared-position seeding failed. The same 27 files read the same before the merge.
  • Gates: the 65 commands dispatch-gates --commands derives for this diff, run at 5c4e58def7, each exit captured before any pipe: 65 of 65 exit 0. --ran reconciled them as 65 derived, 65 run, 0 NOT-MEASURED (a derived zero), 0 UNRUN.
    • On the first pass, at 2d41056b54, two commands did not exit 0. check:registry-log-declared went red because the seeder's suite now constructs a SchemaRegistry; the line it prescribes is in e593e1ab2a. check:dual-build-cjs-loads answered PREREQUISITE NOT MET because eight packages outside this diff's closure were unbuilt; it passed once they were built.
    • check:query-options-erasure reads 236 on the test surface, unchanged.
  • Lint (scoped): the 9 changed .ts files, eslint --no-inline-config --format json: 9 files linted, 0 errors, 0 warnings. The population is eslint.config.mjs's own: every packages/** TypeScript file, and none of the 9 was ignored. The file count is read from the JSON. The config enables no type-aware linting (no parserOptions.project), so this diff cannot change the verdict on any file it does not touch. The repo-wide pnpm lint is CI's.

Acceptance notes

  • The failure-path change above is deliberate and declared. Before, a degraded metadata list seeded a partial catalog with no word. Now the pass seeds nothing and the boot says so once at warn. The caller's level is security-plugin.ts's, which this stage does not touch.
  • The read-order boundary (a name held twice in the registry) is described above. It is unreachable for non-built-in positions today, so it is noted and not pinned.
  • S1's module doc in security-catalog.ts still carries a measured table from before S2 (position: engine registry 0). That table is already carried to S8a, and nothing here changes it.
  • No scripts/adr-anchors/ entry is added; the ADR-0131 D2 anchor is carried to S8a (seat ACCEPT 6051569578).

Generated by Claude Code

claude added 6 commits October 8, 2026 04:20
… 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>
@github-actions github-actions Bot added the size/l label Oct 8, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 8, 2026
@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️ 3 changed file(s) yielded no anchor (packages/plugins/plugin-security/src/builtin-positions.ts, packages/plugins/plugin-security/src/claim-seed-ownership.ts, packages/plugins/plugin-security/vitest.config.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 3 changed file(s) yielded no anchor (packages/plugins/plugin-security/src/builtin-positions.ts, packages/plugins/plugin-security/src/claim-seed-ownership.ts, packages/plugins/plugin-security/vitest.config.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 16 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7d7943dd0dfba6c98afa2200a08983ec85f9849a → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 738b10907475748f92ad8306c70a12ee01ec5ef3 — the merge of head 5c4e58def70ab2d23de7d770ed580f051e1a59a6 into base 7d7943dd0dfba6c98afa2200a08983ec85f9849a, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 8, 2026 07:00
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 8, 2026 07:00
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 8, 2026
Merged via the queue into main with commit 17e4425 Oct 8, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-15196-s2b-declared-seeder-seam branch October 8, 2026 07:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants