Uh oh!
There was an error while loading. Please reload this page.
fix(plugin-security): promote the oldest human that can authenticate, not the oldest directory row (#14348) - #14532
Conversation
…trap Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
…ount declaration Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
…omotion Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 14 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 ffa0f942351615ea9a9ac6932025d8ab46cf985d && git checkout ffa0f942351615ea9a9ac6932025d8ab46cf985d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fed4fa40924bd1366354ac9b8a55db9d94a7e404 3360e5f975bf2a931bfdcc903f88bc5982dc5841 && git checkout -B drift-repro fed4fa40924bd1366354ac9b8a55db9d94a7e404 && git merge --no-ff 3360e5f975bf2a931bfdcc903f88bc5982dc5841
node scripts/docs-audit/affected-docs.mjs --json fed4fa40924bd1366354ac9b8a55db9d94a7e404
|
os-sales
commented
Sep 2, 2026
CI status on |
…romotion conjunct (#14348) The pin reads plugin-security's `isHumanUser` verdict indirectly, as `bootstrapPlatformAdmin`'s `adminPromoted`. Promotion is now a conjunction — human AND holds a `sys_account` — so an empty account table reported a predicate disagreement that does not exist. Model an account for every corpus row that can key one, and handle the id-less row explicitly: both predicates still call it human, promotion refuses it, and the refusal is proven to come from the authenticable filter. Neither predicate is edited. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
os-sales
commented
Sep 2, 2026
Landing provenance — ready + auto-merge at head |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#14348
Under the
singleposturebootstrapPlatformAdminranked promotion candidates by age with "is human" as its only filter. On an app that declares people throughdefineStack({ data })that picks the wrong row every time: a declared person is a credential-less directory row, and the declarative seed is awaited insideAppPlugin.start()(kernel Phase 2), so those rows are always older than any account. The promotion target is now the oldest human that can authenticate.Measurement first — driven composed boot, not a code reading
Instrument:
@objectstack/verify'sbootStack(AppPlugin, then AuthPlugin, then SecurityPlugin — the same relative registration orderobjectstack devuses atpackages/cli/src/commands/serve.ts:3382and:3590), over in-memory SQLite, on an app whosedatadeclares twosys_userrows plus two business records. A fullobjectstack devCLI boot was not used: the in-process rig fits the container's foreground cap and produces the same three readings. Every number below is read back out of the booted database.Reading 1 — who holds
admin_full_access, and can they sign in? Onorigin/mainat1dcb995fthe grant went toperson0@demo.examplewithhas_sys_account: false, on a database whosesys_accounttable was entirely empty — nobody could sign in at all, and the grant was written and unexercisable.Reading 2 — hook ordering. Measured rather than assumed, using the population as the probe: with no seeded people, the promoted row is
admin@objectos.ai, which exists only because AuthPlugin'skernel:readydev-admin seed created it (auth-plugin.tshook at:975, seeding at:976). Its presence at selection time proves the authkernel:readyhandler completes before SecurityPlugin's bootstrap (security-plugin.ts:3612). Consistent with the dispatcher:dispatchHookPropagatingruns handlers "in registration order", awaited in sequence.Reading 3 —
claimSeedOwnership. It followed the wrong target:ownershipClaimed: 2, both seeded business records handed to the same non-loginable row.Replay. A real sign-up arriving afterwards, holding a
credentialaccount, was not promoted — the bootstrap replay answeredalready_have_admin. The wrong grant is permanent.The premise survives the merge with #14352 (#14157)
origin/mainnow carries #14352, soobjectstack devdoes mint a loginable admin on a people-seeded app. That alone does not fix this card. On the merged tree, with only this change's selector ablated, the driven boot still grantedadmin_full_accesstoperson0@demo.example(holder_has_account: false) and still handed it both seeded records — while a loginableadmin@objectos.aiexisted. With the selector restored, the same boot promotesadmin@objectos.aiand assigns the seeded records to it. This is exactly the second half the card describes.The reserved fork: no existing deployment's admin moves
Answered from the code and confirmed at runtime.
bootstrap-platform-admin.tsshort-circuits onif (!walled && humanUnscopedHolders.length > 0) return already_have_adminbefore any target selection; the replay measurement shows that guard firing live. A deployment already carrying a human, org-lessadmin_full_accessgrant therefore never reaches the code this PR edits. This changes which row a FRESH bootstrap promotes and nothing else, so the maintainer-reserved fork is not triggered.bootstrap-platform-admin-authenticable-target.test.tspins that boundary directly: an existing grant on the wrong row, with a loginable account sitting next to it, stays put.What changed
sys_account. Any provider counts — a federated or SSO account is a login, and narrowing toprovider_id = 'credential'would refuse to promote the admin of an SSO-only deployment, recreating this defect for a different population. Asked per candidate oldest-first and short-circuiting, rather than bulk-reading accounts and intersecting: a bulk read needs a bound, and one user holding two accounts can push another user's only account past it, silently skipping a legitimate target.infoline. No newerror-level site through a published sink shape.shouldReplayBootstrapForalso fires onsys_accountinserts. This half is load-bearing, not garnish. Measured write order inside a real sign-up:sys_user.insert exitlands beforesys_account.insert enter, so the pre-existingsys_userarm fires while the registrant still has no login. Ablating just this arm on the driven boot leaves a people-seeded app with no platform admin ever — twono_authenticable_userpasses and an empty grant table even after a successful sign-up.Verification
Union run at
3360e5f97(branch base: merge oforigin/main00ff228fe).pnpm --filter @objectstack/plugin-security test— 95 files / 1785 tests passed.pnpm --filter @objectstack/plugin-auth test— 88 files / 1816 tests passed.pnpm --filter @objectstack/plugin-security typecheck— clean; both edited test files confirmed present in thetsconfig.test.jsonprogram via--listFiles(1 hit each).pnpm --filter @objectstack/plugin-auth typecheck— clean, includingcheck:test-typecheck("OK — @objectstack/plugin-auth's test layer compiles"). The edited pin file is confirmed in that program viatsconfig.test.json --listFiles(1 hit); it is absent from the basetsc --noEmitprogram, which is why the test-layer check is the one that covers it.pnpm --filter @objectstack/verify test— 9 files / 48 tests passed (real composed boots).showcase-permission-seeding.dogfood.test.ts— 3 passed.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, 37 commands — identical list, no new family despite the grown change set); every exit captured before any pipe. 36 green, 1 NOT MEASURED. The one ischeck-test-completeness.mjsat exit 3 = PREREQUISITE NOT MET: it grades a savedturbo run testlog, which only CI produces. Three others (check:dual-build-cjs-loads,check:i18n,check:type-check-debt) first reported prerequisite-not-met because this worktree lacked a full build — notecheck:i18nsignals that with exit 1, not 3, while its own text says "Nothing was checked", so it was read as NOT MEASURED rather than as a finding. Afterturbo run buildover./packages/*all three were re-run and are green, quoting their own verdict lines: "check:dual-build-cjs-loads — 102 published require entry point(s) across 66 package(s) load"; "check-i18n-bundles: OK (9 package(s) — all bundles in sync)"; "check-type-check-coverage --re-measure: OK — 23 ledger entr(ies) re-measured, 771 raw tsc error(s) total, none above its recorded number".Ablation, selector (source-resolved; mutation confirmed on disk — anchor 1 to 0, injected marker present, blob hash moved): 6 of 11 new cases go red. Restore proven by hash equality with the HEAD blob and an empty
git diff HEAD.Ablation, replay arm (dist-resolved, so both legs rebuild): the first attempt was VOID and is reported as such — its marker was a
//comment, whichtsupstrips, soablation-dist-preflightfound it only in sourcemaps and refused the reading. Re-run with an executable string literal: preflight confirmed the marker indist/index.jsanddist/index.mjs, the reading stands, and the restore leg rebuilt clean with--absentgreen and a clean whole-treegit status --porcelain. On the mutated legpnpm buildexits 1 at the DTS step only (TypeScript narrowing complaint about the synthetic marker); the JS bundles built and carry the mutation, which is what the runtime measurement consumes.Clause-② — no
Re-declared from the patched diff:
git diff -U0 origin/main...HEAD | grep exportreturns nothing. No exported symbol is added, removed, or has its declaration changed. Flagging one thing a reviewer should see anyway even though the mechanical answer is no:shouldReplayBootstrapForkeeps its name, signature and type, but its body widens to admitsys_accountinserts. That widening is deliberate and argued above.Patch round 1 — adjacent fix
File:
packages/plugins/plugin-auth/src/human-user-predicate-agreement.pin.test.ts(the one file the PM added to this claim's surface; everything else underpackages/plugins/plugin-auth/**stays read-only and is untouched). Neither predicate was edited.Path taken: option 2. Option 1 — repointing the probe at the predicate directly — was checked first and is not reachable without a new export: plugin-security's
isHumanUseris aconstdeclared inside thebootstrapPlatformAdminfunction body (bootstrap-platform-admin.ts:516, the function opening at:262), so it is neither module-scoped nor re-exported. Adding an export would flip Clause-② toyes, which is a stop condition, so option 2 it is. Clause-② staysno— re-verified from the diff after this patch.What changed:
sys_accountfor every corpus row carrying a usableid. This is what restores the six id-bearing rows: the probe reads plugin-security's human verdict indirectly asreport.adminPromoted, and since this PR promotion is a conjunction — human and holds a login — so an empty account table made every row fail the second conjunct and reported a predicate disagreement that does not exist. Modelling the account holds that conjunct constant and leaves the human predicate as the only discriminator, which is what this file measures.{ email: 'ghost@example.test' }is handled explicitly rather than silently. It is human to both predicates and no account can be keyed to it, soadminPromotedcannot carry a predicate verdict for it at all. Its case now asserts the outcome:isHumanUserRowstill answers human (asserted on the owner side, so a regression there cannot hide behind this case), promotion refuses it, and the refusal is proven to come from the authenticable filter viareason: 'no_authenticable_user'rather than from an earlier branch. The docblock states why this is not a predicate disagreement and cites plugin-security promotes the OLDEST human sys_user row, so an app that seeds a people directory grants platform admin to a row nobody can log in as #14348.The refusal is the fail-closed direction, and deliberately so: a row with no
idcannot hold an exercisable grant — the pre-#14348 code promoted it by writingsys_user_permission_set.user_id = undefined, a grant addressed to nobody in the table whose job is to say who may administer the platform. That is the same direction this file's ownNON_OBJECT_CORPUSgroup already fixed ("for a promotion predicate the safe answer to malformed input is no"), applied to the one malformed shape that is a real object.Evidence:
pnpm --filter @objectstack/plugin-auth exec vitest run src/human-user-predicate-agreement.pin.test.ts— 22 passed (22), from 8 failed / 14 passed before the patch. The whole package suite is green at 88 files / 1816 tests, and plugin-security is unchanged at 95 / 1785.Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
Generated by Claude Code