fix(plugin-security): re-run the seed-ownership claim when the background seed settles - #17872
fix(plugin-security): re-run the seed-ownership claim when the background seed settles#17872claude[bot] wants to merge 3 commits into
Conversation
…ettles `claimSeedOwnership` ran exactly once per database lifetime, inside the pass that promotes the first admin, while the platform's own seeder was still writing in the background past `OS_INLINE_SEED_BUDGET_MS`. Every object whose seed rows landed after that walk stayed `owner_id IS NULL` forever. The claim now also runs on `app:seeded` — the published settle signal for that background continuation — against the same admin, with the same predicates. `bootstrapPlatformAdmin` reports `adminUserId` on both the promotion and the `already_have_admin` paths so the re-run reads the one holder scan instead of growing a second copy of it. Every claim pass now reports what it did AND whether its reading was final, read through the published `seed-settlement` contract: "claimed 0 of 0" while a seed is still writing is a `warn`, not the silence that hid this defect. Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <noreply@anthropic.com>
…ity report Ten pins: the ordering half driven through the real `SecurityPlugin` (rows seeded after the promotion pass are re-owned on `app:seeded`, to the same admin, and a row a human already owns is untouched), and the detector half (a provisional pass and a settled pass claim the same zero rows and no longer produce identical evidence). Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <noreply@anthropic.com>
The new test double opens `findOne` with the producer's own `assertEngineFindOnePredicate` (`check:engine-double-contract`), and the pinned ledger learns about its `update` double. Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check8 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 15 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 0790742389943c4b7709b66c5a58d06066215194 && git checkout 0790742389943c4b7709b66c5a58d06066215194
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ed8dea17bd510100320ab42dbac6ec2a78e99deb 569520bdb8d8113ca9ecb48d14af6b061d0a6b5c && git checkout -B drift-repro ed8dea17bd510100320ab42dbac6ec2a78e99deb && git merge --no-ff 569520bdb8d8113ca9ecb48d14af6b061d0a6b5c
node scripts/docs-audit/affected-docs.mjs --json ed8dea17bd510100320ab42dbac6ec2a78e99deb |
Contract reviewPR #17872 · card #17628 · head Clause-② review, run in-seat at the default judgement tier plus gates — the prescribed form for a non-
① Derived judgments — every accept-set / public-surface change, named and judged
Measured, ⛔ not inferred from the report:
② Semver
③ Boundary flags — every dev flag and open question answered
Independence pairImplemented-by:
Verdict: PASS⛔ Carriers stay hung for now, and that is deliberate, not an oversight.
Generated by Claude Code |
Fixes #17628
What was wrong
claimSeedOwnershipwas reached frombootstrapPlatformAdminexactly once per database lifetime — on the pass that promotes the first admin — and it walked the object registry while the platform's own seeder was still writing.AppPluginraces its inline seed againstOS_INLINE_SEED_BUDGET_MS(default 8 s) and continues an over-budget bundle in the background rather than block kernel start, so for any non-trivial app the seeder is guaranteed to still be running when the one-shot claim walks. Registry order and seed order are unrelated: every object whose rows landed after its walk stayedowner_id IS NULLforever, because nothing ever re-ran the claim.The two halves are each individually reasonable and only lethal together, and the card's own sentence is the acceptance criterion:
The repair — re-run the claim when the seed settles
security-plugin.tsnow hooksapp:seeded, the published settle signal for exactly that background continuation, and re-runsclaimSeedOwnershipagainst the same admin.⛔ Deliberately not done by widening
shouldReplayBootstrapFor: a replayed bootstrap short-circuits onalready_have_adminand returns before the claim, so a wider trigger re-runs a pass that cannot do the missed work. What re-runs here is the claim itself.Three supporting details:
app:seededis the right signal and needs no new one. The runtime settles the seed source before it triggers, so a consumer inside the hook sees its own signal already reflected in the tally. ⇒ no file outsidepackages/plugins/plugin-security/src/**is touched — in particular neitherpackages/metadata-protocol/src/seed-loader.tsnorpackages/runtime/src/app-plugin.ts, both in flight on draft PR feat(spec): export SEED_WRITE_EXECUTION_CONTEXT and bind all three seeders to it #17718. No merge ofmainwas needed.bootstrapPlatformAdminreportsadminUserIdon the promotion path and on thealready_have_adminshort-circuit. The short-circuited pass knew the answer and threw it away; reading it back is what keeps this from growing a second copy of the two-leg, ordered, bounded grant scan that plugin-security: thealready_have_adminshort-circuit readssys_user_permission_setwith an UNORDERED cap of 50, so an existing unscoped platform admin can be missed and a SECOND one minted #16861 took a card to get right.owner_id IS NULL,owner_id = usr_system), the object filter and the target admin are the one-shot pass's own, unchanged. A row a human already owns matches neither predicate and cannot be touched. Pinned directly:moves ownership for the missed rows ONLY.The detector — "claimed 0 of 0" is no longer the same evidence as "nothing to claim"
The silence was part of the defect. A pass that matched nothing used to log nothing at all, so a boot that permanently orphaned 73 rows and a boot with nothing to do produced byte-identical evidence, and the banner was clean either way.
The discriminator is not the count — it is whether a seed source was still writing when the pass ran, which the published
seed-settlementcontract answers. It is the same distinction AGENTS.md's startup-registry rule draws: reading a store that is still filling is fine; recording "there was nothing here" as a verdict the same boot can contradict is the defect. Every pass now reports one line, and says which of three it is:inFlight > 0warnapp:seededinFlight === 0infoinfoinFlight, notpending: a suppressed source (multi-tenant replay,skipSeedData) writes no rows this boot, so there is nothing for the pass to miss on its account. Keying onpendingwould mark every multi-tenant boot provisional forever — a permanent warning about behaviour that is correct by design, which is how a log level gets trained away. Pinned.Ablation — every negative pin, put back and watched go red
Both legs: commit first, mutate on disk, prove the mutation landed by occurrence count and
git hash-objectagainst the HEAD blob, run, restore under a trap, prove restoration bygit diff HEADempty plus a matching blob hash.1. Remove the seed-settle re-run wiring (the
app:seededhook is never registered):The fourth ordering pin (
does nothing when no admin has been resolved yet) stays green under this ablation on purpose — it is negative space, not coverage.2. Put the pre-fix silence back (
if (results.length > 0)guard, no finality clause):The first ablation's first attempt was a no-op measurement and is reported as one: dropping
asyncfrom the mutated arrow made the file unparseable, so vitest failed to transform the suite and no assertion ran at all. The mutation was corrected to stay syntactically valid and re-run; only the second reading is quoted above.Verification
At
569520bd:pnpm --filter @objectstack/plugin-security test— 111 files / 2148 tests passed.pnpm --filter @objectstack/plugin-security typecheck— clean (tsc --noEmit, the scripts project, andcheck:test-typecheckOK: 0 files / 0 errors / 0 pinned signatures).pnpm --filter '@objectstack/plugin-security^...' build— dependency closure green.pnpm lint(eslint . --no-inline-config, the whole repo, not a narrowed slice) — exit 0.@objectstack/plugin-auth'shuman-user-predicate-agreement.pin.test.ts, which callsbootstrapPlatformAdmin— 22 passed. The only other external call site,packages/cli/src/commands/meta/resync.ts, is unchanged: the new option is optional and it does not pass it.node scripts/pm/dispatch-gates.mjs --commandsfrom the real change set, then reconciled with--ran— 70 derived, 67 run (all exit 0), 0 unrun, 3 NOT MEASURED. The three arecheck:dual-build-cjs-loads,check:i18nandcheck:type-check-debt, each of which exited 3 = PREREQUISITE NOT MET because it reads a fully built monorepo; CI builds the closure before running them. Two gates were real findings and are fixed in this PR:check:engine-double-contractrequired the new test double'sfindOneto open with the producer's ownassertEngineFindOnePredicate, and its pinned ledger to learn about the double'supdate.nodeclaration is wrong for this shapescripts/pm/check-widening-tells.mjs --declaration noanswers NOT MEASURED on all six files (no declared surface coverspackages/plugins/**), so the mechanical floor is silent here and the tree-readable rule decides. This diff adds new keys on a published payload, which is alwaysyes:bootstrapPlatformAdmin(exported from the package's.entry,index.tsline 33) gainsadminUserId?: stringon its return object;bootstrapPlatformAdminandclaimSeedOwnership(index.tsline 78) each gain an optionalseedSettlementkey on their options bag.All three are additive and optional; nothing existing changed shape. The changeset is
minoraccordingly. The claim comment still readsClause-②: no— that carrier is the PM seat's to correct, and it is reported rather than edited here.Acceptance notes
claimOrgSeedOwnership(@objectstack/organizations) is injected intoensureDefaultOrganizationas a one-shot handoff at the org bind, structurally the same "claim once at a bootstrap instant" as this card's. It is not covered by this repair and is not the same mechanism:app:seededis single-tenant only, and multi-tenant seeds are suppressed at boot and replayed per organization onsys_organizationinsert, so its ordering question is a different one. I have not reproduced anything there, so there is no repro to file on and no card is raised. Successor: whoever next touchespackages/plugins/organizations/src/claim-org-seed-ownership.tsor the per-org seed replay.app:seededcan fire afterkernel:listeningon an over-budget boot, so the claim can run while HTTP is open. The rows it can move are unchanged (unowned only), and a row created through a normal write carries an owner from its context; the widening is from "the promotion instant" to "the seed settles", which is the fix. Recorded because it is a real, bounded consequence a reviewer should see stated rather than discover.Generated by Claude Code