fix(plugin-security): run the seed-ownership claim whenever a seed settles, on every boot - #21503
Conversation
…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>
…not once Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…rm-boot-seed-claim
…ead of erasing it to any Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 16 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 12 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. 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 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
|
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: theapp:seededhandler (claimSeedOwnershipOnSettle) resolves its claim target itself when no bootstrap pass of this boot has named one. It does this by askingfindExistingPlatformAdmin. When the bootstrap has named a target (this.claimTargetAdminUserId, now an instance field), that target is used as before. The subscription moved fromstart()toinit(), so an in-budget seed fired from an earlier-registered plugin'sstart()is heard too. The handler reads the engine lazily when it runs.bootstrap-platform-admin.ts: thealready_have_adminholder scan (leg A + leg B, ordered, bounded,truncatedreported) moved verbatim into one module function,findPlatformAdminGrantHolder. The bootstrap's guard calls it. The newfindExistingPlatformAdmin(ql, bootstrapPermissionSets)calls the same function, after the same set-id read the bootstrap's seed loop makes. It answersundefinedexactly where the bootstrap names no target: a walled posture, noadmin_full_accessamong 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.handed N seeded record(s) to platform admin USER_ID …. The prefix consumers match on is kept; it used to sayfirst admin.app:seeded) and hands those rows to the same platform admin".app:seeded, on this boot or a later one) or the next platform-admin promotion".os meta resyncand the bootstrap replay re-claim is corrected: both short-circuit onalready_have_adminand never reach the claim.content/docs/data-modeling/seed-data.mdxcalled 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 incontent/docs/**(outsidereleases/) orskills/**was made false.@objectstack/plugin-securitypatch,Clause-②: no.Measured on the base (
cba429717), before any changeThe rig is a real
ObjectQL+SqlDriver(better-sqlite3) over one SQLite file, booted twice, with a freshSecurityPlugineach time.app:seededapp:seededkernel:readystart()→ seed settles →kernel:ready(theobjectstack devorder)start()(app registered first, the@objectstack/verifyorder)kernel:ready→ seed settles (over budget)The same reading on a real
ObjectKernel(registration order SecurityPlugin→seeder and seeder→SecurityPlugin, with a probe seeder that settles and triggersapp:seededun-awaited, asAppPlugindoes): warm-boot ownerless["c2"]in both orders.Log lines on the base warm boot: no
handed … seeded record(s)line at all, andplatform bootstrap complete {"reason":"already_have_admin","adminUserId":"usr_admin_human"}.The same probes on the fix: 0 ownerless after
app:seededin 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_adminguard, nowfindPlatformAdminGrantHolder. It returns the first unscoped humanadmin_full_accessgrant row, with leg A ordered by grant-rowidascending.usr_systemnever counts. With several admins it answers the holder of the grant row whoseidsorts first. That is pinned with a fixture where every other plausible rule answers differently: the grantups_1goes tousr_zed, andusr_amyis the older user whose user id also sorts first. Both the claim and the bootstrap nameusr_zed.Pins:
claim-seed-ownership-warm-boot.test.ts, real engine over one file, booted twicekernel:ready(plugin started first) → 0 ownerless afterapp:seeded. One claim report{ claimed: 2, adminUserId }, thenalready_have_adminand nothing more claimed.start()(app registered first) → heard by 1 handler, and 0 ownerless.kernel:ready(over budget), using the target the bootstrap named → 0 ownerless.findExistingPlatformAdminnames nobody (no claim ever ran under a wall, and none runs now). Undersinglethe same database answersusr_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 rowc4, owned byusr_someone_else, staysusr_someone_else.claim-seed-ownership-seed-settle-rerun.test.tsis 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.mjswith an anchor that must hit; restore proven by blob == HEAD and an emptygit diff HEAD)The subject resolves through a relative
./security-plugin.jsimport, so it readssrc/and nodist/rebuild is involved.this.claimTargetAdminUserIdonly). Predicted red: cases 1, 2 and 4; green: 3 and 5. Observed: exactly that, 3 failed and 2 passed. For example, case 1 receivedc2: null, c3: null. Restored blob843b795a7603matched HEAD.start()(if (!this.ql) return;, which is the subscription back instart()in effect). Predicted red: case 2 only. Observed: exactly that, 1 failed and 4 passed. Restore was proven.Verification, on the final head
1f33f8ee5(origin/mainmerged after PR #21488 landed)pnpm --filter @objectstack/plugin-security run test --maxWorkers=2(the packagetestscript) → 164 files passed, 3527 passed, 45 skipped.pnpm --filter @objectstack/plugin-security run typecheck→ green. That includescheck:test-typecheckovertsconfig.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 base555504711. All 94 ran with exit 0.--ranreconciles: 94 derived, 94 run, 0 NOT-MEASURED, 0 UNRUN.check:skill-examples,check:dual-build-cjs-loadsandcheck:i18nrefused with PREREQUISITE NOT MET (exit 3, nothing measured). After a full build they measured green on this head.check:slot-lookupcaught a real erasure in my first cut (let ql: anyaroundgetService). It is typed now (IObjectQLEngine | undefined), in commit1f33f8ee5.pnpm lint): eslint--no-inline-config --format jsonover 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.mjsenables no type-aware linting (noparserOptions.project), so this diff cannot move the verdict on any untouched file.origin/mainhas moved 3 commits past the merge base since the merge (fd96a8473,9ff74285f,6f17d1d36). None touches these six paths. The stale-tree notedispatch-gatesprinted names two CI shard-timing scripts only.Risks and costs
owner_idNULL orusr_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.start(), before this plugin'sstart(). It touches only the engine (resolved lazily) and the settlement service, writes asisSystemthrough the same predicates, and is best-effort: a failure is logged atwarnand never breaks the boot.Acceptance notes
objectstack dev/serveregistersSecurityPluginbefore the app'sAppPlugin.@objectstack/verify'sbootStackregisters the app first. The kernel keeps registration order among plugins with no edge between them, so an in-budgetapp:seededfires before anystart()-time subscriber registered later. This PR makes the claim independent of that by subscribing ininit(). The otherapp:seededsubscribers (plugin-auth's membership backfill, platform-objects' attestation) subscribe instart(), but each has akernel:readybackstop, so no defect was found there. Observation only. carrier: none ("承接者:无").skills/objectstack-platform/references/plugin-hooks.mdshowsctx.hook('app:seeded', …)without saying that a subscription made instart()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 resyncnever runs the claim on an install that already has an admin: it short-circuits onalready_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/mainafterwards. The overlapping region insecurity-plugin.tsmerged cleanly, and itsmetadata:reloadedblock sits above the unchanged heading line of the old start-time block.Generated by Claude Code