Skip to content

fix(plugin-security): run the seed-ownership claim whenever a seed settles, on every boot - #21503

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-21486-warm-boot-seed-claim
Oct 3, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-21486-warm-boot-seed-claim

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21486
Clause-②: no

What changes

The seed-ownership claim now runs whenever a seed settles (app:seeded), on every boot. Before, it ran only on the boot that promotes the first platform admin.

  • security-plugin.ts: the app:seeded handler (claimSeedOwnershipOnSettle) resolves its claim target itself when no bootstrap pass of this boot has named one. It does this by asking findExistingPlatformAdmin. When the bootstrap has named a target (this.claimTargetAdminUserId, now an instance field), that target is used as before. The subscription moved from start() to init(), so an in-budget seed fired from an earlier-registered plugin's start() is heard too. The handler reads the engine lazily when it runs.
  • bootstrap-platform-admin.ts: the already_have_admin holder scan (leg A + leg B, ordered, bounded, truncated reported) moved verbatim into one module function, findPlatformAdminGrantHolder. The bootstrap's guard calls it. The new findExistingPlatformAdmin(ql, bootstrapPermissionSets) calls the same function, after the same set-id read the bootstrap's seed loop makes. It answers undefined exactly where the bootstrap names no target: a walled posture, no admin_full_access among the seeded sets or no stored row for it, or nobody holding the unscoped grant yet. ⛔ There is no second selection rule and no second claim path.
  • claim-seed-ownership.ts: the two log lines that promised a re-run now say what actually happens.
    • Claim report: handed N seeded record(s) to platform admin USER_ID …. The prefix consumers match on is kept; it used to say first admin.
    • PROVISIONAL line: "The claim runs again as each of those sources settles (app:seeded) and hands those rows to the same platform admin".
    • Failure and page-cap lines: "until the claim next runs — the next seed settle (app:seeded, on this boot or a later one) or the next platform-admin promotion".
    • The code comment that said os meta resync and the bootstrap replay re-claim is corrected: both short-circuit on already_have_admin and never reach the claim.
  • Docs: content/docs/data-modeling/seed-data.mdx called the handoff "one-time". It now says the handoff runs again whenever a seed settles, on every later boot too, and never touches an owned row. No other sentence in content/docs/** (outside releases/) or skills/** was made false.
  • Changeset: @objectstack/plugin-security patch, Clause-②: no.

Measured on the base (cba429717), before any change

The rig is a real ObjectQL + SqlDriver (better-sqlite3) over one SQLite file, booted twice, with a fresh SecurityPlugin each time.

  • First boot: seed 3 rows, then sign-up and promotion. The claim hands all 3 rows over.
  • Between boots: one seeded row is deleted (the replay re-inserts it), a null owner is planted on another row, and one row is owned by somebody else.
warm-boot order handlers that heard app:seeded ownerless after app:seeded after kernel:ready
plugin start() → seed settles → kernel:ready (the objectstack dev order) 1 2 2
seed settles before this plugin's start() (app registered first, the @objectstack/verify order) 0 2 2
kernel:ready → seed settles (over budget) 1 0 —

The same reading on a real ObjectKernel (registration order SecurityPlugin→seeder and seeder→SecurityPlugin, with a probe seeder that settles and triggers app:seeded un-awaited, as AppPlugin does): warm-boot ownerless ["c2"] in both orders.

Log lines on the base warm boot: no handed … seeded record(s) line at all, and platform bootstrap complete {"reason":"already_have_admin","adminUserId":"usr_admin_human"}.

The same probes on the fix: 0 ownerless after app:seeded in all three orders and both kernel orders. The row owned by somebody else stays theirs, and one line is logged: handed 2 seeded record(s) to platform admin usr_admin_human (1 of 1 eligible object(s) had unowned rows) — final: …. The probes were throwaway files and are not committed.

The one rule that picks "the existing platform admin"

It is the bootstrap's already_have_admin guard, now findPlatformAdminGrantHolder. It returns the first unscoped human admin_full_access grant row, with leg A ordered by grant-row id ascending. usr_system never counts. With several admins it answers the holder of the grant row whose id sorts first. That is pinned with a fixture where every other plausible rule answers differently: the grant ups_1 goes to usr_zed, and usr_amy is the older user whose user id also sorts first. Both the claim and the bootstrap name usr_zed.

Pins: claim-seed-ownership-warm-boot.test.ts, real engine over one file, booted twice

  1. Warm boot, seed settles before kernel:ready (plugin started first) → 0 ownerless after app:seeded. One claim report { claimed: 2, adminUserId }, then already_have_admin and nothing more claimed.
  2. Warm boot, seed settles before this plugin's start() (app registered first) → heard by 1 handler, and 0 ownerless.
  3. Warm boot, seed settles after kernel:ready (over budget), using the target the bootstrap named → 0 ownerless.
  4. Several admins → the claim and the bootstrap agree on the first-grant-id holder.
  5. Walled posture → findExistingPlatformAdmin names nobody (no claim ever ran under a wall, and none runs now). Under single the same database answers usr_zed.

Each warm-boot case first re-measures the first-boot path as its control: the in-budget settle with no admin claims nothing and logs nothing, and the promotion claims 3 (ownershipClaimed: 3). Idempotence is pinned in cases 1–3: the row c4, owned by usr_someone_else, stays usr_someone_else. claim-seed-ownership-seed-settle-rerun.test.ts is unchanged and green as the double-based control.

Ablations (direction predicted before each run; the fix was committed first; the mutation went through scripts/ablation-replace.mjs with an anchor that must hit; restore proven by blob == HEAD and an empty git diff HEAD)

The subject resolves through a relative ./security-plugin.js import, so it reads src/ and no dist/ rebuild is involved.

  • A1, the handler's self-resolution removed (this.claimTargetAdminUserId only). Predicted red: cases 1, 2 and 4; green: 3 and 5. Observed: exactly that, 3 failed and 2 passed. For example, case 1 received c2: null, c3: null. Restored blob 843b795a7603 matched HEAD.
  • A2, the handler effective only after start() (if (!this.ql) return;, which is the subscription back in start() in effect). Predicted red: case 2 only. Observed: exactly that, 1 failed and 4 passed. Restore was proven. ⚠️ The first A2 attempt was a no-op: the tool refused it because the replacement still contained the anchor, so nothing ran. It was re-anchored and run once more. That re-run is the reading above.

Verification, on the final head 1f33f8ee5 (origin/main merged after PR #21488 landed)

  • pnpm --filter @objectstack/plugin-security run test --maxWorkers=2 (the package test script) → 164 files passed, 3527 passed, 45 skipped.
  • pnpm --filter @objectstack/plugin-security run typecheck → green. That includes check:test-typecheck over tsconfig.test.json, which compiles the new test file.
  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (no paths) gives 94 commands against merge base 555504711. All 94 ran with exit 0. --ran reconciles: 94 derived, 94 run, 0 NOT-MEASURED, 0 UNRUN.
    • On the pre-merge tree, check:skill-examples, check:dual-build-cjs-loads and check:i18n refused with PREREQUISITE NOT MET (exit 3, nothing measured). After a full build they measured green on this head.
    • check:slot-lookup caught a real erasure in my first cut (let ql: any around getService). It is typed now (IObjectQLEngine | undefined), in commit 1f33f8ee5.
  • Lint, as a proven narrowing rather than the repo-wide run (CI owns pnpm lint): eslint --no-inline-config --format json over the 4 touched TS files gives 4 files, 0 errors and 0 warnings. Each file resolves a config and none is ignored (an ignored file would warn). eslint.config.mjs enables no type-aware linting (no parserOptions.project), so this diff cannot move the verdict on any untouched file.
  • NOT MEASURED locally, and left to CI by design: the Test Core shards, the Dogfood Regression Gate, Dogfood Verify CLI, Build Core, Build Docs, Temporal Conformance and the workspace Type Check lanes.
  • origin/main has moved 3 commits past the merge base since the merge (fd96a8473, 9ff74285f, 6f17d1d36). None touches these six paths. The stale-tree note dispatch-gates printed names two CI shard-timing scripts only.

Risks and costs

  • On every boot whose seed settles while a platform admin exists, the claim now walks every eligible object, as it already did on over-budget boots. Its predicate is "unowned" (owner_id NULL or usr_system), not "seeded". On such a boot it therefore also hands over a non-seed row that some system-context writer left unowned. This was already true on the over-budget path and on first-boot promotion, and the card's own repro counts planted nulls as rows that must be claimed. A row someone owns is never matched.
  • The handler can now run during an earlier plugin's start(), before this plugin's start(). It touches only the engine (resolved lazily) and the settlement service, writes as isSystem through the same predicates, and is best-effort: a failure is logged at warn and never breaks the boot.
  • Rollback: revert the PR. Behaviour returns to first-boot-only claims, and nothing persisted needs undoing.

Acceptance notes

  • Composition order differs between the CLI and the test harness. objectstack dev/serve registers SecurityPlugin before the app's AppPlugin. @objectstack/verify's bootStack registers the app first. The kernel keeps registration order among plugins with no edge between them, so an in-budget app:seeded fires before any start()-time subscriber registered later. This PR makes the claim independent of that by subscribing in init(). The other app:seeded subscribers (plugin-auth's membership backfill, platform-objects' attestation) subscribe in start(), but each has a kernel:ready backstop, so no defect was found there. Observation only. carrier: none ("承接者:无").
  • skills/objectstack-platform/references/plugin-hooks.md shows ctx.hook('app:seeded', …) without saying that a subscription made in start() can miss an in-budget seed from an earlier-registered app. No sentence there is false, so it was not edited (skills/** is Tier H). carrier: none.
  • os meta resync never runs the claim on an install that already has an admin: it short-circuits on already_have_admin. A code comment claimed it did; that comment is corrected here, and nothing else promised it. Observation only.

The serial constraint is cleared: PR #21488 merged first, and this branch merged origin/main afterwards. The overlapping region in security-plugin.ts merged cleanly, and its metadata:reloaded block sits above the unchanged heading line of the old start-time block.


Generated by Claude Code

claude added 5 commits October 2, 2026 23:38
…ttles, on every boot

The app:seeded handler now resolves its claim target itself (the existing
platform admin, by the bootstrap's own already_have_admin rule, extracted
into one shared function) when no bootstrap pass of this boot has named it,
and is subscribed in init() so an in-budget seed fired from an earlier
plugin's start() is heard. The claim's log lines now say what happens.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…n every settle order

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…ead of erasing it to any

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 3, 2026
@github-actions

github-actions Bot commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

16 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 6f17d1d364729ce0fa4f12f83a08676972d08d94.

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

What this run could not see
  • 1 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 6f17d1d364729ce0fa4f12f83a08676972d08d94 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 6f17d1d364729ce0fa4f12f83a08676972d08d94

⚠️ 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 6f17d1d364729ce0fa4f12f83a08676972d08d94 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

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

2 participants