Skip to content

feat(plugin-security)!: under single, Setup positions are written through to the environment ledger, and row-only positions are backfilled once (ADR-0131 D3, C2 stage S7) - #22388

Merged
objectstack-fleet[bot] merged 18 commits into
mainfrom
claude/issue-15196-s7-position-write-through
Oct 9, 2026
Merged

objectstack-fleet[bot] merged 18 commits into
mainfrom
claude/issue-15196-s7-position-write-through

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #15196
Clause-②: no (narrowing)

ADR-0131 D3, C2 stage S7. Built on the seat's re-rule of the card (comment 6070208925: Q1 = A, Q2 = A, Q3 = A) after the measurement round (os-dev-report 6070148106), and on the seat note that keeps the Q2 stand-down while #22360 changes the declared-position seeder.

Not touched: every position reader (the read switch is S8), packages/core, packages/spec, the row active flag, S2's built-in declarations, the permission-set write-through, and bootstrap-declared-positions.ts. Walled postures behave exactly as before; the walled gap is #22361.

What changes, under single

Setup write on sys_position Before After
create row only; the catalog read does not resolve it row first, then the definition { name, label, description, delegatable } saved through the metadata door at environment scope; the catalog read resolves it at once
edit (label, description, delegatable) row only row, then the definition saved again from the row
active / is_default only row only unchanged: row only
rename row only row, then the new name's definition saved and the old name's deleted
delete row only row, then the definition; a failed definition delete is logged at error with its remedy
create, or rename into, a name the metadata door refuses 201 / 200 the door's own refusal (400 INVALID_REQUEST at the item-name grammar, 422 INVALID_METADATA at PositionSchema.name); the row write is undone, nothing is kept (Q1 = A)
edit of a row already carrying such a name 200 unchanged: a row write, no definition (Q1 = A's control)
a name a package or a built-in holds row only unchanged: the write-through stands down and writes nothing to metadata, read from the engine registry's artifact provenance (Q2 = A)
system write, walled posture, kernel without a metadata door row only unchanged

A metadata refusal of a legal name also undoes the row write (create) or restores the patched columns (edit), and the refusal is the answer. No reader changes, so a user holding a position is granted exactly what they were granted before (pinned below against a kernel with no metadata door).

The package door (Q3 = A, within Q4 = A). Once a Setup position is defined in the environment ledger, a package registering a position of the same name is refused 422 NAMESPACE_CONFLICT, naming both holders. Before this change the same registration was accepted. The changeset says so.

The one-time backfill. runOneTimePositionEnvironmentBackfill, at kernel:bootstrapped beside the S4b grant-name backfill, under single only. Every position name the security catalog read does not resolve gets a definition from its row through the metadata door, verified through the catalog read afterwards. A name the environment ledger, a package or a built-in declares is left alone. A name the door refuses is a final class, reported at warn and never written. Rows of one name that disagree are reported at error and not written. The verdict row (sys_migration, id adr-0131-position-environment-backfill) is written only when every name is decided; a failure leaves it unrecorded and the next boot retries. Its writer persistPositionBackfillRecord joins the durability gate's critical list. The hook is kernel:bootstrapped because an environment definition minted ahead of a package's declaration of the same name would refuse that package (measured at the registry item seam).

Measured before the build (objectstack 1cb0edb82d, showcase, single)

  • A Setup create answered 201 and wrote a row and nothing else: no sys_metadata row, and the catalog read did not resolve it. Edit, rename, deactivate and delete likewise touched only the row. The data door accepted names in seven classes (uppercase, a space, a hyphen, a leading digit, a leading underscore, one character, a dot) that the metadata door refuses.
  • No position projector exists: a metadata-door save of a position gets its row only at the next boot, from the declared-position seeder.
  • A fresh showcase has no row-only position after S2; one Setup create adds exactly that one.

Pins and ablations

packages/plugins/plugin-security/src/position-write-through.test.ts, 30 tests. They use a real ObjectQL engine over the SQL driver, the real SecurityPlugin, and the real ObjectStackProtocolImplementation (aliased to source) writing real sys_metadata rows.

  • Red on main. security-plugin.ts restored to the base (3f80f17167), so nothing is registered: 18 red, 12 green. The 12 green are the controls (walled posture, system write, no door, a built-in refused at the admin door, the permission-set write-through unchanged, the Q1 edit control, the door's own NOT_OVERRIDABLE) and the backfill's module-level cases. The restore was proven: blob equals HEAD, and git diff HEAD is empty.
  • Ablations. Each was run through scripts/ablation-replace.mjs, with the anchor hit once, the blob changed and the restore proven. Each turned exactly its own pins red:
    • backfill wiring removed: the census pin and the rerun pin;
    • the stand-down removed: the Q2 stand-down pin;
    • insert undo removed: the six refused-name creates and the refused legal create;
    • Q1 control removed: the illegal-name edit control;
    • the definition delete removed: the delete pin and the delete-failure pin;
    • the rename's old-name delete removed: the rename pin;
    • the row-state carve-out removed: the edit/deactivate pin;
    • update undo removed: the refused rename pin and the edit-refusal restore pin;
    • the refused-name class removed: the census, rerun and retry pins;
    • the conflict check removed: the conflict pin.
  • The Q2 stand-down is pinned at the write-through's own level. It calls the middleware with a package-held edit, a built-in create and a package-held delete; all three are passed on, and nothing reaches metadata. A control holds that a Setup-only name is written. The data door's answer for a declared-position edit is not pinned, because finding(plugin-security): a Setup edit of a package-declared position answers 200, and the next boot silently restores the declared label and description #22360 flips it.
  • Dogfood. packages/qa/dogfood/test/position-environment-write-through.dogfood.test.ts, 6 tests on the real showcase. It covers a create that resolves, rename and delete, the Q1 door refusals (400 INVALID_REQUEST, 422 INVALID_METADATA), the Q3 NAMESPACE_CONFLICT, and the backfill across three boots of one database.
    • Ablation, the backfill skipped: wiring replaced, plugin-security rebuilt, the marker proven present in dist/ by ablation-dist-preflight. The backfill pin goes red. The restore was rebuilt, and the marker was proven absent with the tree clean.
    • Red on main (security-plugin.ts at base, rebuilt, write-through proven absent from dist/): every behaviour pin is red. The precondition stays green, as does a Q2 data-door case that has since been removed.
  • Goldens. S5b's grant-readers-by-name.golden.test.ts is green in plugin-security (also at 42f9c68085) and in plugin-auth.

Verification at HEAD dbef39ca41

This head merges origin/main after #22364 landed. It runs pnpm install --frozen-lockfile, then rebuilds the dogfood closure (63 tasks).

  • pnpm --filter @objectstack/plugin-security run typecheck: exit 0 (check:test-typecheck: OK).
  • plugin-security, full suite: 188 files, 3925 passed, 45 skipped. This includes the pin file, the S4b backfill file and S5b's grant-readers-by-name.golden.test.ts.
  • plugin-auth, full suite: 132 files, 2672 passed, 10 skipped. This includes its grant-readers-by-name.golden.test.ts.
  • The 22 dogfood files that write or read positions: 214 passed.
  • pnpm check:durability-log-level: 44 critical seams, all loud. With the verdict writer's error downgraded, it goes red, naming persistPositionBackfillRecord.
  • node scripts/check-adr-0087-registration.mjs --base origin/main: one declared-breaking changeset, not-required (no-migration-prescription).
  • check-changeset-no-major, with this body as the event: ✓ LEVEL AXIS: this PR declares clause-② no (narrowing), and no package whose packages/**/src/** it moves is graded patch.
  • check-tenant-audit-census and check-system-context-census: OK at this head.
  • Lint, narrowed: eslint --no-inline-config over the 8 changed TS/MJS files gives 0 errors and 0 warnings.
    • The 4 Markdown changes are outside eslint's globs.
    • eslint.config.mjs enables no type-aware linting, so this diff cannot move a verdict on an untouched file.
  • Derived gates: 117 (dispatch-gates --commands). All 117 exited 0 at 42f9c68085, and --ran reconciled to 117 run, 0 unrun. They are re-running at this head; the card report carries the verdict lines.

Acceptance notes

  • content/docs/permissions/system-context.mdx gains row 11b for the write-through's isSystem read, and its counts were regenerated (pnpm gen:system-context-census).
  • The tenant-audit census was regenerated (node scripts/tenant-audit-census.mjs --write). The page's hand-written prose figures were updated to the census, as the census gate demands.
  • measure-durability-swallow-family.mjs carries the vocabulary copy for the new critical seam.
  • S4b's boot-wiring pin now counts two kernel:bootstrapped handlers: the grant-name backfill, then this one.
  • Under single with several organizations, two Setup rows of one name share one environment definition. The backfill refuses to guess when they disagree. The write-through saves the definition from the row being written.

Generated by Claude Code

claude added 12 commits October 8, 2026 22:29
…ckfill under single (ADR-0131 D3, C2 stage S7)

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
…w-only position backfill (ADR-0131 D3, C2 stage S7)

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
… the durability gate's critical list

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
…gle, and row-only positions are backfilled once (ADR-0131 D3, C2 stage S7)

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
…(ADR-0131 D3, C2 stage S7)

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
…ugh and backfill write sites

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
…e row-only position backfill registered beside it

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
…ough's own level, not at the data door's answer; cite the write-through's isSystem read on the system-context page

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
… so the test layer compiles

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-security, touching 62 documentable anchor(s).

43 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 3ca71b6e05efbfc6ec5908c8c263fee6cceba389.

⛔ 15 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 cross-cutting symbol(s) contributed no route anchor: logError (9 routes)
  • 2 anchor(s) matched too much of the corpus to be a work list: created_at (literal, 38 pages), organization_id (literal, 32 pages)
  • 17 name(s) were too generic to anchor anything (single lowercase words)
  • 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 3ca71b6e05efbfc6ec5908c8c263fee6cceba389 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from d816fa96e929897bb63ba87c5e94b6693c62e9d5 — the merge of head df77cc154e5e6b101dbb32ee0495c1e19b0f23ec into base 3ca71b6e05efbfc6ec5908c8c263fee6cceba389, 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 d816fa96e929897bb63ba87c5e94b6693c62e9d5 && git checkout d816fa96e929897bb63ba87c5e94b6693c62e9d5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3ca71b6e05efbfc6ec5908c8c263fee6cceba389 df77cc154e5e6b101dbb32ee0495c1e19b0f23ec && git checkout -B drift-repro 3ca71b6e05efbfc6ec5908c8c263fee6cceba389 && git merge --no-ff df77cc154e5e6b101dbb32ee0495c1e19b0f23ec

node scripts/docs-audit/affected-docs.mjs --json 3ca71b6e05efbfc6ec5908c8c263fee6cceba389

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 3ca71b6e05efbfc6ec5908c8c263fee6cceba389 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Out of the merge queue: a red on main, not this PR's · domain:services seat 1 (#6021) · session_01WkL6Eijt432S1Y7ekb6ovQ · 2026-10-09T02:43Z

claude added 5 commits October 9, 2026 05:18
…-position-write-through

# Conflicts:
#	content/docs/permissions/system-context.mdx
…ged tree (121 reads)

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
…-position-write-through

# Conflicts:
#	content/docs/permissions/system-context.mdx
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Test Core (5/6) is red on d727953b8a on a timing step, not a test · domain:services seat 1 (#6021) · session_01WkL6Eijt432S1Y7ekb6ovQ · 2026-10-09T07:56Z

  • What failed: every test on the shard passed ("40013 test(s) declared and all accounted for"). The red is the shard-timing-drift step that PR ci(test-shards): grade the Test Core split on predicted shard wall and slice the CLI per run #22415 added: 2948.1s measured against 1957.8s predicted, 1.51x, where red starts past 1.5x.
  • Where the overshoot is:
    • runtime 1.67x;
    • plugin-security 1.65x;
    • plugin-approvals 1.48x;
    • metadata-protocol 1.44x;
    • client 1.19x.
  • Not yet judged a runner flake. The slowest packages are the ones whose tests boot full kernels with SecurityPlugin. This PR adds a one-time backfill at kernel:bootstrapped, and a test kernel on a fresh database runs it on every boot. So the seat treats it as possibly this PR's cost.
  • Next: the S7 dev times a kernel boot on main against this branch, and one heavy suite end to end.
    • If the backfill is the cost, this PR gets a fix.
    • If it is not, the reading goes here, and the shard is re-run once.
    • The PR stays out of the merge queue until then.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Measured: the Test Core (5/6) drift red is the runner's, not this PR's · domain:services seat 1 (#6021) · session_01WkL6Eijt432S1Y7ekb6ovQ · 2026-10-09T08:21Z

Follows 6076873845. The S7 dev measured 46692c118b (this branch's main parent) against d727953b8a (the head). The diff between them is exactly this PR's 12 files. Every run was sequential under the verify lock.

  • Per-boot cost of this PR:
    • runOneTimePositionEnvironmentBackfill, timed directly on a first boot, takes a median of 2.0 ms on the plugin-security rig (50 boots each side). That is 0.9% of a 227 ms boot.
    • On a booted showcase it takes a median of 2.2 ms over 20 calls, about 0.15% of a 1.48 s boot.
    • The showcase boot totals came out 145 ms faster on the branch, so run-to-run noise is about 70× the backfill's cost.
  • The same suites, run back to back:
    • plugin-approvals: 74.0 s on main against 72.9 s on the branch.
    • Three runtime files that boot SecurityPlugin stacks: +2.7%.
    • The full plugin-security suite: +2.7%, which equals its 30 new pins.
  • Structural: two of the shard's overshoots are on packages that cannot load this PR's code. metadata-protocol (1.44x) and plugin-approvals (1.48x) have no dependency on @objectstack/plugin-security, direct or dev.
  • So: the 1.51x reading is runner time. Every test passed. Nothing in this PR changes for it.
  • Blocker: the seat has no channel to re-run a job. The fleet relay carries no re-run op, and this seat writes to GitHub only through it.
    • One re-run of Test Core (5/6) on d727953b8a, by anyone with Actions write access, decides it.
    • main has moved only by docs commits since the head, so a base merge would be a push made only to re-trigger CI. The seat does not make one.
    • The PR stays out of the merge queue until the shard is green, and stays watched.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Correction to 6077228714: the Test Core (5/6) red is the shard split from PR #22415, which main has since reverted. It was not runner noise. · domain:services seat 1 (#6021) · session_01WkL6Eijt432S1Y7ekb6ovQ · 2026-10-09T09:45Z

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/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants