fix(plugin-security): promote the oldest human that can authenticate, not the oldest directory row (#14348) - #14532

Merged
os-sales merged 5 commits into
mainfrom
claude/issue-14348-promote-authenticable-first-user
Sep 2, 2026
Merged

fix(plugin-security): promote the oldest human that can authenticate, not the oldest directory row (#14348)#14532
os-sales merged 5 commits into
mainfrom
claude/issue-14348-promote-authenticable-first-user

Conversation

@os-sales

@os-salesos-sales commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14348

Under the single posture bootstrapPlatformAdmin ranked promotion candidates by age with "is human" as its only filter. On an app that declares people through defineStack({ data }) that picks the wrong row every time: a declared person is a credential-less directory row, and the declarative seed is awaited inside AppPlugin.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's bootStack (AppPlugin, then AuthPlugin, then SecurityPlugin — the same relative registration order objectstack dev uses at packages/cli/src/commands/serve.ts:3382 and :3590), over in-memory SQLite, on an app whose data declares two sys_user rows plus two business records. A full objectstack dev CLI 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? On origin/main at 1dcb995f the grant went to person0@demo.example with has_sys_account: false, on a database whose sys_account table 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's kernel:ready dev-admin seed created it (auth-plugin.ts hook at :975, seeding at :976). Its presence at selection time proves the auth kernel:ready handler completes before SecurityPlugin's bootstrap (security-plugin.ts:3612). Consistent with the dispatcher: dispatchHookPropagating runs 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 credential account, was not promoted — the bootstrap replay answered already_have_admin. The wrong grant is permanent.

The premise survives the merge with #14352 (#14157)

origin/main now carries #14352, so objectstack dev does 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 granted admin_full_access to person0@demo.example (holder_has_account: false) and still handed it both seeded records — while a loginable admin@objectos.ai existed. With the selector restored, the same boot promotes admin@objectos.ai and 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.ts short-circuits on if (!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-less admin_full_access grant 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.ts pins that boundary directly: an existing grant on the wrong row, with a loginable account sitting next to it, stays put.

What changed

  • The target is the oldest human holding a sys_account. Any provider counts — a federated or SSO account is a login, and narrowing to provider_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.
  • Humans present but none authenticable: promote nobody, write no grant row, log one info line. No new error-level site through a published sink shape.
  • shouldReplayBootstrapFor also fires on sys_account inserts. This half is load-bearing, not garnish. Measured write order inside a real sign-up: sys_user.insert exit lands beforesys_account.insert enter, so the pre-existing sys_user arm 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 — two no_authenticable_user passes and an empty grant table even after a successful sign-up.

Verification

Union run at 3360e5f97 (branch base: merge of origin/main00ff228fe).

  • pnpm --filter @objectstack/plugin-security test95 files / 1785 tests passed.
  • pnpm --filter @objectstack/plugin-auth test88 files / 1816 tests passed.
  • pnpm --filter @objectstack/plugin-security typecheck — clean; both edited test files confirmed present in the tsconfig.test.json program via --listFiles (1 hit each).
  • pnpm --filter @objectstack/plugin-auth typecheck — clean, including check:test-typecheck ("OK — @objectstack/plugin-auth's test layer compiles"). The edited pin file is confirmed in that program via tsconfig.test.json --listFiles (1 hit); it is absent from the base tsc --noEmit program, 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.
  • Gate families re-derived on the patched head (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 is check-test-completeness.mjs at exit 3 = PREREQUISITE NOT MET: it grades a saved turbo run test log, 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 — note check:i18n signals 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. After turbo run build over ./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, which tsup strips, so ablation-dist-preflight found it only in sourcemaps and refused the reading. Re-run with an executable string literal: preflight confirmed the marker in dist/index.js and dist/index.mjs, the reading stands, and the restore leg rebuilt clean with --absent green and a clean whole-tree git status --porcelain. On the mutated leg pnpm build exits 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 export returns 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: shouldReplayBootstrapFor keeps its name, signature and type, but its body widens to admit sys_account inserts. 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 under packages/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 isHumanUser is a const declared inside the bootstrapPlatformAdmin function 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-② to yes, which is a stop condition, so option 2 it is. Clause-② stays no — re-verified from the diff after this patch.

What changed:

  1. The file's fake engine now models sys_account for every corpus row carrying a usable id. This is what restores the six id-bearing rows: the probe reads plugin-security's human verdict indirectly as report.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.
  2. The id-less row { email: 'ghost@example.test' } is handled explicitly rather than silently. It is human to both predicates and no account can be keyed to it, so adminPromoted cannot carry a predicate verdict for it at all. Its case now asserts the outcome: isHumanUserRow still 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 via reason: '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 id cannot hold an exercisable grant — the pre-#14348 code promoted it by writing sys_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 own NON_OBJECT_CORPUS group 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.ts22 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

@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/validation-rules.mdx(via sys_account (literal, a string literal in bootstrapPlatformAdmin; a string literal in shouldReplayBootstrapFor))
  • content/docs/permissions/authentication.mdx(via sys_account (literal, a string literal in bootstrapPlatformAdmin; a string literal in shouldReplayBootstrapFor))
  • content/docs/protocol/objectui/actions.mdx(via sys_account (literal, a string literal in bootstrapPlatformAdmin; a string literal in shouldReplayBootstrapFor))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via sys_account (literal, a string literal in bootstrapPlatformAdmin; a string literal in shouldReplayBootstrapFor))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: sys_user (literal, 30 pages)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 14 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 fed4fa40924bd1366354ac9b8a55db9d94a7e404packageMentionDocs.

Which tree this was computed on

This run read content/docs from ffa0f942351615ea9a9ac6932025d8ab46cf985d — the merge of head 3360e5f975bf2a931bfdcc903f88bc5982dc5841 into base fed4fa40924bd1366354ac9b8a55db9d94a7e404, 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 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

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

@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

CI status on de7dd456bTest Core (4/6) red, cause confirmed, patch round in flight

Read from the job log (job 100210403629): the only failing task is @objectstack/plugin-auth#test, and the only failing file is src/human-user-predicate-agreement.pin.test.ts — seven corpus rows plus the anti-vacuity check, every one with plugin-security isHumanUser -> false (reason: no_authenticable_user). That is exactly the out-of-surface consequence the PR body reports: the pin reads promotion as a proxy for the human predicate, and promotion now requires an exercisable login, which the pin's fake engine never models. check-test-completeness on the shard is OK (4 of 4 packages reported), so nothing else in the shard is hidden behind it.

Not a flake and not a base-branch failure; it is this PR's to fix. The seat widened the claim's file surface by that one pin file (14348#issuecomment-5508047359) and issued patch round 1 to the same dev: repoint the probe at the predicate without adding an export, or model sys_account for id-bearing rows and assert the fail-closed outcome for the id-less row with a docblock. The next push carries it; the PR stays draft until the patched head is green.


Generated by Claude Code

…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-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

Landing provenance — ready + auto-merge at head 3360e5f97


Generated by Claude Code

@os-sales
os-sales added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 7286dd5Sep 2, 2026
38 checks passed
@os-sales
os-sales deleted the claude/issue-14348-promote-authenticable-first-user branch September 2, 2026 12:25
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants

@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(plugin-security): promote the oldest human that can authenticate, not the oldest directory row (#14348) - #14532

Merged
os-sales merged 5 commits into
mainfrom
claude/issue-14348-promote-authenticable-first-user
Sep 2, 2026
Merged

fix(plugin-security): promote the oldest human that can authenticate, not the oldest directory row (#14348)#14532
os-sales merged 5 commits into
mainfrom
claude/issue-14348-promote-authenticable-first-user

Conversation

@os-sales

@os-salesos-sales commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14348

Under the single posture bootstrapPlatformAdmin ranked promotion candidates by age with "is human" as its only filter. On an app that declares people through defineStack({ data }) that picks the wrong row every time: a declared person is a credential-less directory row, and the declarative seed is awaited inside AppPlugin.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's bootStack (AppPlugin, then AuthPlugin, then SecurityPlugin — the same relative registration order objectstack dev uses at packages/cli/src/commands/serve.ts:3382 and :3590), over in-memory SQLite, on an app whose data declares two sys_user rows plus two business records. A full objectstack dev CLI 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? On origin/main at 1dcb995f the grant went to person0@demo.example with has_sys_account: false, on a database whose sys_account table 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's kernel:ready dev-admin seed created it (auth-plugin.ts hook at :975, seeding at :976). Its presence at selection time proves the auth kernel:ready handler completes before SecurityPlugin's bootstrap (security-plugin.ts:3612). Consistent with the dispatcher: dispatchHookPropagating runs 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 credential account, was not promoted — the bootstrap replay answered already_have_admin. The wrong grant is permanent.

The premise survives the merge with #14352 (#14157)

origin/main now carries #14352, so objectstack dev does 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 granted admin_full_access to person0@demo.example (holder_has_account: false) and still handed it both seeded records — while a loginable admin@objectos.ai existed. With the selector restored, the same boot promotes admin@objectos.ai and 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.ts short-circuits on if (!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-less admin_full_access grant 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.ts pins that boundary directly: an existing grant on the wrong row, with a loginable account sitting next to it, stays put.

What changed

  • The target is the oldest human holding a sys_account. Any provider counts — a federated or SSO account is a login, and narrowing to provider_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.
  • Humans present but none authenticable: promote nobody, write no grant row, log one info line. No new error-level site through a published sink shape.
  • shouldReplayBootstrapFor also fires on sys_account inserts. This half is load-bearing, not garnish. Measured write order inside a real sign-up: sys_user.insert exit lands beforesys_account.insert enter, so the pre-existing sys_user arm 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 — two no_authenticable_user passes and an empty grant table even after a successful sign-up.

Verification

Union run at 3360e5f97 (branch base: merge of origin/main00ff228fe).

  • pnpm --filter @objectstack/plugin-security test95 files / 1785 tests passed.
  • pnpm --filter @objectstack/plugin-auth test88 files / 1816 tests passed.
  • pnpm --filter @objectstack/plugin-security typecheck — clean; both edited test files confirmed present in the tsconfig.test.json program via --listFiles (1 hit each).
  • pnpm --filter @objectstack/plugin-auth typecheck — clean, including check:test-typecheck ("OK — @objectstack/plugin-auth's test layer compiles"). The edited pin file is confirmed in that program via tsconfig.test.json --listFiles (1 hit); it is absent from the base tsc --noEmit program, 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.
  • Gate families re-derived on the patched head (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 is check-test-completeness.mjs at exit 3 = PREREQUISITE NOT MET: it grades a saved turbo run test log, 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 — note check:i18n signals 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. After turbo run build over ./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, which tsup strips, so ablation-dist-preflight found it only in sourcemaps and refused the reading. Re-run with an executable string literal: preflight confirmed the marker in dist/index.js and dist/index.mjs, the reading stands, and the restore leg rebuilt clean with --absent green and a clean whole-tree git status --porcelain. On the mutated leg pnpm build exits 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 export returns 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: shouldReplayBootstrapFor keeps its name, signature and type, but its body widens to admit sys_account inserts. 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 under packages/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 isHumanUser is a const declared inside the bootstrapPlatformAdmin function 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-② to yes, which is a stop condition, so option 2 it is. Clause-② stays no — re-verified from the diff after this patch.

What changed:

  1. The file's fake engine now models sys_account for every corpus row carrying a usable id. This is what restores the six id-bearing rows: the probe reads plugin-security's human verdict indirectly as report.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.
  2. The id-less row { email: 'ghost@example.test' } is handled explicitly rather than silently. It is human to both predicates and no account can be keyed to it, so adminPromoted cannot carry a predicate verdict for it at all. Its case now asserts the outcome: isHumanUserRow still 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 via reason: '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 id cannot hold an exercisable grant — the pre-#14348 code promoted it by writing sys_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 own NON_OBJECT_CORPUS group 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.ts22 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

@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/validation-rules.mdx(via sys_account (literal, a string literal in bootstrapPlatformAdmin; a string literal in shouldReplayBootstrapFor))
  • content/docs/permissions/authentication.mdx(via sys_account (literal, a string literal in bootstrapPlatformAdmin; a string literal in shouldReplayBootstrapFor))
  • content/docs/protocol/objectui/actions.mdx(via sys_account (literal, a string literal in bootstrapPlatformAdmin; a string literal in shouldReplayBootstrapFor))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via sys_account (literal, a string literal in bootstrapPlatformAdmin; a string literal in shouldReplayBootstrapFor))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: sys_user (literal, 30 pages)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 14 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 fed4fa40924bd1366354ac9b8a55db9d94a7e404packageMentionDocs.

Which tree this was computed on

This run read content/docs from ffa0f942351615ea9a9ac6932025d8ab46cf985d — the merge of head 3360e5f975bf2a931bfdcc903f88bc5982dc5841 into base fed4fa40924bd1366354ac9b8a55db9d94a7e404, 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 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

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

@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

CI status on de7dd456bTest Core (4/6) red, cause confirmed, patch round in flight

Read from the job log (job 100210403629): the only failing task is @objectstack/plugin-auth#test, and the only failing file is src/human-user-predicate-agreement.pin.test.ts — seven corpus rows plus the anti-vacuity check, every one with plugin-security isHumanUser -> false (reason: no_authenticable_user). That is exactly the out-of-surface consequence the PR body reports: the pin reads promotion as a proxy for the human predicate, and promotion now requires an exercisable login, which the pin's fake engine never models. check-test-completeness on the shard is OK (4 of 4 packages reported), so nothing else in the shard is hidden behind it.

Not a flake and not a base-branch failure; it is this PR's to fix. The seat widened the claim's file surface by that one pin file (14348#issuecomment-5508047359) and issued patch round 1 to the same dev: repoint the probe at the predicate without adding an export, or model sys_account for id-bearing rows and assert the fail-closed outcome for the id-less row with a docblock. The next push carries it; the PR stays draft until the patched head is green.


Generated by Claude Code

…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-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

Landing provenance — ready + auto-merge at head 3360e5f97


Generated by Claude Code

@os-sales
os-sales added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 7286dd5Sep 2, 2026
38 checks passed
@os-sales
os-sales deleted the claude/issue-14348-promote-authenticable-first-user branch September 2, 2026 12:25
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants

@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(plugin-security): promote the oldest human that can authenticate, not the oldest directory row (#14348) - #14532

Merged
os-sales merged 5 commits into
mainfrom
claude/issue-14348-promote-authenticable-first-user
Sep 2, 2026
Merged

fix(plugin-security): promote the oldest human that can authenticate, not the oldest directory row (#14348)#14532
os-sales merged 5 commits into
mainfrom
claude/issue-14348-promote-authenticable-first-user

Conversation

@os-sales

@os-salesos-sales commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14348

Under the single posture bootstrapPlatformAdmin ranked promotion candidates by age with "is human" as its only filter. On an app that declares people through defineStack({ data }) that picks the wrong row every time: a declared person is a credential-less directory row, and the declarative seed is awaited inside AppPlugin.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's bootStack (AppPlugin, then AuthPlugin, then SecurityPlugin — the same relative registration order objectstack dev uses at packages/cli/src/commands/serve.ts:3382 and :3590), over in-memory SQLite, on an app whose data declares two sys_user rows plus two business records. A full objectstack dev CLI 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? On origin/main at 1dcb995f the grant went to person0@demo.example with has_sys_account: false, on a database whose sys_account table 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's kernel:ready dev-admin seed created it (auth-plugin.ts hook at :975, seeding at :976). Its presence at selection time proves the auth kernel:ready handler completes before SecurityPlugin's bootstrap (security-plugin.ts:3612). Consistent with the dispatcher: dispatchHookPropagating runs 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 credential account, was not promoted — the bootstrap replay answered already_have_admin. The wrong grant is permanent.

The premise survives the merge with #14352 (#14157)

origin/main now carries #14352, so objectstack dev does 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 granted admin_full_access to person0@demo.example (holder_has_account: false) and still handed it both seeded records — while a loginable admin@objectos.ai existed. With the selector restored, the same boot promotes admin@objectos.ai and 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.ts short-circuits on if (!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-less admin_full_access grant 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.ts pins that boundary directly: an existing grant on the wrong row, with a loginable account sitting next to it, stays put.

What changed

  • The target is the oldest human holding a sys_account. Any provider counts — a federated or SSO account is a login, and narrowing to provider_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.
  • Humans present but none authenticable: promote nobody, write no grant row, log one info line. No new error-level site through a published sink shape.
  • shouldReplayBootstrapFor also fires on sys_account inserts. This half is load-bearing, not garnish. Measured write order inside a real sign-up: sys_user.insert exit lands beforesys_account.insert enter, so the pre-existing sys_user arm 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 — two no_authenticable_user passes and an empty grant table even after a successful sign-up.

Verification

Union run at 3360e5f97 (branch base: merge of origin/main00ff228fe).

  • pnpm --filter @objectstack/plugin-security test95 files / 1785 tests passed.
  • pnpm --filter @objectstack/plugin-auth test88 files / 1816 tests passed.
  • pnpm --filter @objectstack/plugin-security typecheck — clean; both edited test files confirmed present in the tsconfig.test.json program via --listFiles (1 hit each).
  • pnpm --filter @objectstack/plugin-auth typecheck — clean, including check:test-typecheck ("OK — @objectstack/plugin-auth's test layer compiles"). The edited pin file is confirmed in that program via tsconfig.test.json --listFiles (1 hit); it is absent from the base tsc --noEmit program, 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.
  • Gate families re-derived on the patched head (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 is check-test-completeness.mjs at exit 3 = PREREQUISITE NOT MET: it grades a saved turbo run test log, 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 — note check:i18n signals 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. After turbo run build over ./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, which tsup strips, so ablation-dist-preflight found it only in sourcemaps and refused the reading. Re-run with an executable string literal: preflight confirmed the marker in dist/index.js and dist/index.mjs, the reading stands, and the restore leg rebuilt clean with --absent green and a clean whole-tree git status --porcelain. On the mutated leg pnpm build exits 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 export returns 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: shouldReplayBootstrapFor keeps its name, signature and type, but its body widens to admit sys_account inserts. 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 under packages/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 isHumanUser is a const declared inside the bootstrapPlatformAdmin function 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-② to yes, which is a stop condition, so option 2 it is. Clause-② stays no — re-verified from the diff after this patch.

What changed:

  1. The file's fake engine now models sys_account for every corpus row carrying a usable id. This is what restores the six id-bearing rows: the probe reads plugin-security's human verdict indirectly as report.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.
  2. The id-less row { email: 'ghost@example.test' } is handled explicitly rather than silently. It is human to both predicates and no account can be keyed to it, so adminPromoted cannot carry a predicate verdict for it at all. Its case now asserts the outcome: isHumanUserRow still 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 via reason: '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 id cannot hold an exercisable grant — the pre-#14348 code promoted it by writing sys_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 own NON_OBJECT_CORPUS group 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.ts22 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

@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/validation-rules.mdx(via sys_account (literal, a string literal in bootstrapPlatformAdmin; a string literal in shouldReplayBootstrapFor))
  • content/docs/permissions/authentication.mdx(via sys_account (literal, a string literal in bootstrapPlatformAdmin; a string literal in shouldReplayBootstrapFor))
  • content/docs/protocol/objectui/actions.mdx(via sys_account (literal, a string literal in bootstrapPlatformAdmin; a string literal in shouldReplayBootstrapFor))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via sys_account (literal, a string literal in bootstrapPlatformAdmin; a string literal in shouldReplayBootstrapFor))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: sys_user (literal, 30 pages)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 14 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 fed4fa40924bd1366354ac9b8a55db9d94a7e404packageMentionDocs.

Which tree this was computed on

This run read content/docs from ffa0f942351615ea9a9ac6932025d8ab46cf985d — the merge of head 3360e5f975bf2a931bfdcc903f88bc5982dc5841 into base fed4fa40924bd1366354ac9b8a55db9d94a7e404, 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 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

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

@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

CI status on de7dd456bTest Core (4/6) red, cause confirmed, patch round in flight

Read from the job log (job 100210403629): the only failing task is @objectstack/plugin-auth#test, and the only failing file is src/human-user-predicate-agreement.pin.test.ts — seven corpus rows plus the anti-vacuity check, every one with plugin-security isHumanUser -> false (reason: no_authenticable_user). That is exactly the out-of-surface consequence the PR body reports: the pin reads promotion as a proxy for the human predicate, and promotion now requires an exercisable login, which the pin's fake engine never models. check-test-completeness on the shard is OK (4 of 4 packages reported), so nothing else in the shard is hidden behind it.

Not a flake and not a base-branch failure; it is this PR's to fix. The seat widened the claim's file surface by that one pin file (14348#issuecomment-5508047359) and issued patch round 1 to the same dev: repoint the probe at the predicate without adding an export, or model sys_account for id-bearing rows and assert the fail-closed outcome for the id-less row with a docblock. The next push carries it; the PR stays draft until the patched head is green.


Generated by Claude Code

…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-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

Landing provenance — ready + auto-merge at head 3360e5f97


Generated by Claude Code

@os-sales
os-sales added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 7286dd5Sep 2, 2026
38 checks passed
@os-sales
os-sales deleted the claude/issue-14348-promote-authenticable-first-user branch September 2, 2026 12:25
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants

@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(plugin-security): promote the oldest human that can authenticate, not the oldest directory row (#14348) - #14532

Merged
os-sales merged 5 commits into
mainfrom
claude/issue-14348-promote-authenticable-first-user
Sep 2, 2026
Merged

fix(plugin-security): promote the oldest human that can authenticate, not the oldest directory row (#14348)#14532
os-sales merged 5 commits into
mainfrom
claude/issue-14348-promote-authenticable-first-user

Conversation

@os-sales

@os-salesos-sales commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14348

Under the single posture bootstrapPlatformAdmin ranked promotion candidates by age with "is human" as its only filter. On an app that declares people through defineStack({ data }) that picks the wrong row every time: a declared person is a credential-less directory row, and the declarative seed is awaited inside AppPlugin.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's bootStack (AppPlugin, then AuthPlugin, then SecurityPlugin — the same relative registration order objectstack dev uses at packages/cli/src/commands/serve.ts:3382 and :3590), over in-memory SQLite, on an app whose data declares two sys_user rows plus two business records. A full objectstack dev CLI 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? On origin/main at 1dcb995f the grant went to person0@demo.example with has_sys_account: false, on a database whose sys_account table 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's kernel:ready dev-admin seed created it (auth-plugin.ts hook at :975, seeding at :976). Its presence at selection time proves the auth kernel:ready handler completes before SecurityPlugin's bootstrap (security-plugin.ts:3612). Consistent with the dispatcher: dispatchHookPropagating runs 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 credential account, was not promoted — the bootstrap replay answered already_have_admin. The wrong grant is permanent.

The premise survives the merge with #14352 (#14157)

origin/main now carries #14352, so objectstack dev does 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 granted admin_full_access to person0@demo.example (holder_has_account: false) and still handed it both seeded records — while a loginable admin@objectos.ai existed. With the selector restored, the same boot promotes admin@objectos.ai and 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.ts short-circuits on if (!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-less admin_full_access grant 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.ts pins that boundary directly: an existing grant on the wrong row, with a loginable account sitting next to it, stays put.

What changed

  • The target is the oldest human holding a sys_account. Any provider counts — a federated or SSO account is a login, and narrowing to provider_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.
  • Humans present but none authenticable: promote nobody, write no grant row, log one info line. No new error-level site through a published sink shape.
  • shouldReplayBootstrapFor also fires on sys_account inserts. This half is load-bearing, not garnish. Measured write order inside a real sign-up: sys_user.insert exit lands beforesys_account.insert enter, so the pre-existing sys_user arm 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 — two no_authenticable_user passes and an empty grant table even after a successful sign-up.

Verification

Union run at 3360e5f97 (branch base: merge of origin/main00ff228fe).

  • pnpm --filter @objectstack/plugin-security test95 files / 1785 tests passed.
  • pnpm --filter @objectstack/plugin-auth test88 files / 1816 tests passed.
  • pnpm --filter @objectstack/plugin-security typecheck — clean; both edited test files confirmed present in the tsconfig.test.json program via --listFiles (1 hit each).
  • pnpm --filter @objectstack/plugin-auth typecheck — clean, including check:test-typecheck ("OK — @objectstack/plugin-auth's test layer compiles"). The edited pin file is confirmed in that program via tsconfig.test.json --listFiles (1 hit); it is absent from the base tsc --noEmit program, 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.
  • Gate families re-derived on the patched head (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 is check-test-completeness.mjs at exit 3 = PREREQUISITE NOT MET: it grades a saved turbo run test log, 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 — note check:i18n signals 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. After turbo run build over ./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, which tsup strips, so ablation-dist-preflight found it only in sourcemaps and refused the reading. Re-run with an executable string literal: preflight confirmed the marker in dist/index.js and dist/index.mjs, the reading stands, and the restore leg rebuilt clean with --absent green and a clean whole-tree git status --porcelain. On the mutated leg pnpm build exits 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 export returns 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: shouldReplayBootstrapFor keeps its name, signature and type, but its body widens to admit sys_account inserts. 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 under packages/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 isHumanUser is a const declared inside the bootstrapPlatformAdmin function 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-② to yes, which is a stop condition, so option 2 it is. Clause-② stays no — re-verified from the diff after this patch.

What changed:

  1. The file's fake engine now models sys_account for every corpus row carrying a usable id. This is what restores the six id-bearing rows: the probe reads plugin-security's human verdict indirectly as report.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.
  2. The id-less row { email: 'ghost@example.test' } is handled explicitly rather than silently. It is human to both predicates and no account can be keyed to it, so adminPromoted cannot carry a predicate verdict for it at all. Its case now asserts the outcome: isHumanUserRow still 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 via reason: '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 id cannot hold an exercisable grant — the pre-#14348 code promoted it by writing sys_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 own NON_OBJECT_CORPUS group 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.ts22 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

@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/validation-rules.mdx(via sys_account (literal, a string literal in bootstrapPlatformAdmin; a string literal in shouldReplayBootstrapFor))
  • content/docs/permissions/authentication.mdx(via sys_account (literal, a string literal in bootstrapPlatformAdmin; a string literal in shouldReplayBootstrapFor))
  • content/docs/protocol/objectui/actions.mdx(via sys_account (literal, a string literal in bootstrapPlatformAdmin; a string literal in shouldReplayBootstrapFor))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via sys_account (literal, a string literal in bootstrapPlatformAdmin; a string literal in shouldReplayBootstrapFor))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: sys_user (literal, 30 pages)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 14 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 fed4fa40924bd1366354ac9b8a55db9d94a7e404packageMentionDocs.

Which tree this was computed on

This run read content/docs from ffa0f942351615ea9a9ac6932025d8ab46cf985d — the merge of head 3360e5f975bf2a931bfdcc903f88bc5982dc5841 into base fed4fa40924bd1366354ac9b8a55db9d94a7e404, 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 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

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

@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

CI status on de7dd456bTest Core (4/6) red, cause confirmed, patch round in flight

Read from the job log (job 100210403629): the only failing task is @objectstack/plugin-auth#test, and the only failing file is src/human-user-predicate-agreement.pin.test.ts — seven corpus rows plus the anti-vacuity check, every one with plugin-security isHumanUser -> false (reason: no_authenticable_user). That is exactly the out-of-surface consequence the PR body reports: the pin reads promotion as a proxy for the human predicate, and promotion now requires an exercisable login, which the pin's fake engine never models. check-test-completeness on the shard is OK (4 of 4 packages reported), so nothing else in the shard is hidden behind it.

Not a flake and not a base-branch failure; it is this PR's to fix. The seat widened the claim's file surface by that one pin file (14348#issuecomment-5508047359) and issued patch round 1 to the same dev: repoint the probe at the predicate without adding an export, or model sys_account for id-bearing rows and assert the fail-closed outcome for the id-less row with a docblock. The next push carries it; the PR stays draft until the patched head is green.


Generated by Claude Code

…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-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

Landing provenance — ready + auto-merge at head 3360e5f97


Generated by Claude Code

@os-sales
os-sales added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 7286dd5Sep 2, 2026
38 checks passed
@os-sales
os-sales deleted the claude/issue-14348-promote-authenticable-first-user branch September 2, 2026 12:25
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants

@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(plugin-security): promote the oldest human that can authenticate, not the oldest directory row (#14348) - #14532

Merged
os-sales merged 5 commits into
mainfrom
claude/issue-14348-promote-authenticable-first-user
Sep 2, 2026
Merged

fix(plugin-security): promote the oldest human that can authenticate, not the oldest directory row (#14348)#14532
os-sales merged 5 commits into
mainfrom
claude/issue-14348-promote-authenticable-first-user

Conversation

@os-sales

@os-salesos-sales commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14348

Under the single posture bootstrapPlatformAdmin ranked promotion candidates by age with "is human" as its only filter. On an app that declares people through defineStack({ data }) that picks the wrong row every time: a declared person is a credential-less directory row, and the declarative seed is awaited inside AppPlugin.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's bootStack (AppPlugin, then AuthPlugin, then SecurityPlugin — the same relative registration order objectstack dev uses at packages/cli/src/commands/serve.ts:3382 and :3590), over in-memory SQLite, on an app whose data declares two sys_user rows plus two business records. A full objectstack dev CLI 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? On origin/main at 1dcb995f the grant went to person0@demo.example with has_sys_account: false, on a database whose sys_account table 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's kernel:ready dev-admin seed created it (auth-plugin.ts hook at :975, seeding at :976). Its presence at selection time proves the auth kernel:ready handler completes before SecurityPlugin's bootstrap (security-plugin.ts:3612). Consistent with the dispatcher: dispatchHookPropagating runs 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 credential account, was not promoted — the bootstrap replay answered already_have_admin. The wrong grant is permanent.

The premise survives the merge with #14352 (#14157)

origin/main now carries #14352, so objectstack dev does 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 granted admin_full_access to person0@demo.example (holder_has_account: false) and still handed it both seeded records — while a loginable admin@objectos.ai existed. With the selector restored, the same boot promotes admin@objectos.ai and 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.ts short-circuits on if (!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-less admin_full_access grant 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.ts pins that boundary directly: an existing grant on the wrong row, with a loginable account sitting next to it, stays put.

What changed

  • The target is the oldest human holding a sys_account. Any provider counts — a federated or SSO account is a login, and narrowing to provider_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.
  • Humans present but none authenticable: promote nobody, write no grant row, log one info line. No new error-level site through a published sink shape.
  • shouldReplayBootstrapFor also fires on sys_account inserts. This half is load-bearing, not garnish. Measured write order inside a real sign-up: sys_user.insert exit lands beforesys_account.insert enter, so the pre-existing sys_user arm 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 — two no_authenticable_user passes and an empty grant table even after a successful sign-up.

Verification

Union run at 3360e5f97 (branch base: merge of origin/main00ff228fe).

  • pnpm --filter @objectstack/plugin-security test95 files / 1785 tests passed.
  • pnpm --filter @objectstack/plugin-auth test88 files / 1816 tests passed.
  • pnpm --filter @objectstack/plugin-security typecheck — clean; both edited test files confirmed present in the tsconfig.test.json program via --listFiles (1 hit each).
  • pnpm --filter @objectstack/plugin-auth typecheck — clean, including check:test-typecheck ("OK — @objectstack/plugin-auth's test layer compiles"). The edited pin file is confirmed in that program via tsconfig.test.json --listFiles (1 hit); it is absent from the base tsc --noEmit program, 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.
  • Gate families re-derived on the patched head (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 is check-test-completeness.mjs at exit 3 = PREREQUISITE NOT MET: it grades a saved turbo run test log, 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 — note check:i18n signals 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. After turbo run build over ./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, which tsup strips, so ablation-dist-preflight found it only in sourcemaps and refused the reading. Re-run with an executable string literal: preflight confirmed the marker in dist/index.js and dist/index.mjs, the reading stands, and the restore leg rebuilt clean with --absent green and a clean whole-tree git status --porcelain. On the mutated leg pnpm build exits 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 export returns 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: shouldReplayBootstrapFor keeps its name, signature and type, but its body widens to admit sys_account inserts. 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 under packages/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 isHumanUser is a const declared inside the bootstrapPlatformAdmin function 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-② to yes, which is a stop condition, so option 2 it is. Clause-② stays no — re-verified from the diff after this patch.

What changed:

  1. The file's fake engine now models sys_account for every corpus row carrying a usable id. This is what restores the six id-bearing rows: the probe reads plugin-security's human verdict indirectly as report.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.
  2. The id-less row { email: 'ghost@example.test' } is handled explicitly rather than silently. It is human to both predicates and no account can be keyed to it, so adminPromoted cannot carry a predicate verdict for it at all. Its case now asserts the outcome: isHumanUserRow still 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 via reason: '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 id cannot hold an exercisable grant — the pre-#14348 code promoted it by writing sys_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 own NON_OBJECT_CORPUS group 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.ts22 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

@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/validation-rules.mdx(via sys_account (literal, a string literal in bootstrapPlatformAdmin; a string literal in shouldReplayBootstrapFor))
  • content/docs/permissions/authentication.mdx(via sys_account (literal, a string literal in bootstrapPlatformAdmin; a string literal in shouldReplayBootstrapFor))
  • content/docs/protocol/objectui/actions.mdx(via sys_account (literal, a string literal in bootstrapPlatformAdmin; a string literal in shouldReplayBootstrapFor))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via sys_account (literal, a string literal in bootstrapPlatformAdmin; a string literal in shouldReplayBootstrapFor))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: sys_user (literal, 30 pages)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 14 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 fed4fa40924bd1366354ac9b8a55db9d94a7e404packageMentionDocs.

Which tree this was computed on

This run read content/docs from ffa0f942351615ea9a9ac6932025d8ab46cf985d — the merge of head 3360e5f975bf2a931bfdcc903f88bc5982dc5841 into base fed4fa40924bd1366354ac9b8a55db9d94a7e404, 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 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

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

@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

CI status on de7dd456bTest Core (4/6) red, cause confirmed, patch round in flight

Read from the job log (job 100210403629): the only failing task is @objectstack/plugin-auth#test, and the only failing file is src/human-user-predicate-agreement.pin.test.ts — seven corpus rows plus the anti-vacuity check, every one with plugin-security isHumanUser -> false (reason: no_authenticable_user). That is exactly the out-of-surface consequence the PR body reports: the pin reads promotion as a proxy for the human predicate, and promotion now requires an exercisable login, which the pin's fake engine never models. check-test-completeness on the shard is OK (4 of 4 packages reported), so nothing else in the shard is hidden behind it.

Not a flake and not a base-branch failure; it is this PR's to fix. The seat widened the claim's file surface by that one pin file (14348#issuecomment-5508047359) and issued patch round 1 to the same dev: repoint the probe at the predicate without adding an export, or model sys_account for id-bearing rows and assert the fail-closed outcome for the id-less row with a docblock. The next push carries it; the PR stays draft until the patched head is green.


Generated by Claude Code

…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-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

Landing provenance — ready + auto-merge at head 3360e5f97


Generated by Claude Code

@os-sales
os-sales added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 7286dd5Sep 2, 2026
38 checks passed
@os-sales
os-sales deleted the claude/issue-14348-promote-authenticable-first-user branch September 2, 2026 12:25
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants

@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(plugin-security): promote the oldest human that can authenticate, not the oldest directory row (#14348) - #14532

Merged
os-sales merged 5 commits into
mainfrom
claude/issue-14348-promote-authenticable-first-user
Sep 2, 2026
Merged

fix(plugin-security): promote the oldest human that can authenticate, not the oldest directory row (#14348)#14532
os-sales merged 5 commits into
mainfrom
claude/issue-14348-promote-authenticable-first-user

Conversation

@os-sales

@os-salesos-sales commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14348

Under the single posture bootstrapPlatformAdmin ranked promotion candidates by age with "is human" as its only filter. On an app that declares people through defineStack({ data }) that picks the wrong row every time: a declared person is a credential-less directory row, and the declarative seed is awaited inside AppPlugin.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's bootStack (AppPlugin, then AuthPlugin, then SecurityPlugin — the same relative registration order objectstack dev uses at packages/cli/src/commands/serve.ts:3382 and :3590), over in-memory SQLite, on an app whose data declares two sys_user rows plus two business records. A full objectstack dev CLI 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? On origin/main at 1dcb995f the grant went to person0@demo.example with has_sys_account: false, on a database whose sys_account table 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's kernel:ready dev-admin seed created it (auth-plugin.ts hook at :975, seeding at :976). Its presence at selection time proves the auth kernel:ready handler completes before SecurityPlugin's bootstrap (security-plugin.ts:3612). Consistent with the dispatcher: dispatchHookPropagating runs 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 credential account, was not promoted — the bootstrap replay answered already_have_admin. The wrong grant is permanent.

The premise survives the merge with #14352 (#14157)

origin/main now carries #14352, so objectstack dev does 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 granted admin_full_access to person0@demo.example (holder_has_account: false) and still handed it both seeded records — while a loginable admin@objectos.ai existed. With the selector restored, the same boot promotes admin@objectos.ai and 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.ts short-circuits on if (!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-less admin_full_access grant 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.ts pins that boundary directly: an existing grant on the wrong row, with a loginable account sitting next to it, stays put.

What changed

  • The target is the oldest human holding a sys_account. Any provider counts — a federated or SSO account is a login, and narrowing to provider_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.
  • Humans present but none authenticable: promote nobody, write no grant row, log one info line. No new error-level site through a published sink shape.
  • shouldReplayBootstrapFor also fires on sys_account inserts. This half is load-bearing, not garnish. Measured write order inside a real sign-up: sys_user.insert exit lands beforesys_account.insert enter, so the pre-existing sys_user arm 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 — two no_authenticable_user passes and an empty grant table even after a successful sign-up.

Verification

Union run at 3360e5f97 (branch base: merge of origin/main00ff228fe).

  • pnpm --filter @objectstack/plugin-security test95 files / 1785 tests passed.
  • pnpm --filter @objectstack/plugin-auth test88 files / 1816 tests passed.
  • pnpm --filter @objectstack/plugin-security typecheck — clean; both edited test files confirmed present in the tsconfig.test.json program via --listFiles (1 hit each).
  • pnpm --filter @objectstack/plugin-auth typecheck — clean, including check:test-typecheck ("OK — @objectstack/plugin-auth's test layer compiles"). The edited pin file is confirmed in that program via tsconfig.test.json --listFiles (1 hit); it is absent from the base tsc --noEmit program, 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.
  • Gate families re-derived on the patched head (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 is check-test-completeness.mjs at exit 3 = PREREQUISITE NOT MET: it grades a saved turbo run test log, 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 — note check:i18n signals 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. After turbo run build over ./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, which tsup strips, so ablation-dist-preflight found it only in sourcemaps and refused the reading. Re-run with an executable string literal: preflight confirmed the marker in dist/index.js and dist/index.mjs, the reading stands, and the restore leg rebuilt clean with --absent green and a clean whole-tree git status --porcelain. On the mutated leg pnpm build exits 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 export returns 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: shouldReplayBootstrapFor keeps its name, signature and type, but its body widens to admit sys_account inserts. 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 under packages/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 isHumanUser is a const declared inside the bootstrapPlatformAdmin function 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-② to yes, which is a stop condition, so option 2 it is. Clause-② stays no — re-verified from the diff after this patch.

What changed:

  1. The file's fake engine now models sys_account for every corpus row carrying a usable id. This is what restores the six id-bearing rows: the probe reads plugin-security's human verdict indirectly as report.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.
  2. The id-less row { email: 'ghost@example.test' } is handled explicitly rather than silently. It is human to both predicates and no account can be keyed to it, so adminPromoted cannot carry a predicate verdict for it at all. Its case now asserts the outcome: isHumanUserRow still 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 via reason: '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 id cannot hold an exercisable grant — the pre-#14348 code promoted it by writing sys_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 own NON_OBJECT_CORPUS group 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.ts22 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

@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/validation-rules.mdx(via sys_account (literal, a string literal in bootstrapPlatformAdmin; a string literal in shouldReplayBootstrapFor))
  • content/docs/permissions/authentication.mdx(via sys_account (literal, a string literal in bootstrapPlatformAdmin; a string literal in shouldReplayBootstrapFor))
  • content/docs/protocol/objectui/actions.mdx(via sys_account (literal, a string literal in bootstrapPlatformAdmin; a string literal in shouldReplayBootstrapFor))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via sys_account (literal, a string literal in bootstrapPlatformAdmin; a string literal in shouldReplayBootstrapFor))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: sys_user (literal, 30 pages)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 14 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 fed4fa40924bd1366354ac9b8a55db9d94a7e404packageMentionDocs.

Which tree this was computed on

This run read content/docs from ffa0f942351615ea9a9ac6932025d8ab46cf985d — the merge of head 3360e5f975bf2a931bfdcc903f88bc5982dc5841 into base fed4fa40924bd1366354ac9b8a55db9d94a7e404, 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 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

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

@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

CI status on de7dd456bTest Core (4/6) red, cause confirmed, patch round in flight

Read from the job log (job 100210403629): the only failing task is @objectstack/plugin-auth#test, and the only failing file is src/human-user-predicate-agreement.pin.test.ts — seven corpus rows plus the anti-vacuity check, every one with plugin-security isHumanUser -> false (reason: no_authenticable_user). That is exactly the out-of-surface consequence the PR body reports: the pin reads promotion as a proxy for the human predicate, and promotion now requires an exercisable login, which the pin's fake engine never models. check-test-completeness on the shard is OK (4 of 4 packages reported), so nothing else in the shard is hidden behind it.

Not a flake and not a base-branch failure; it is this PR's to fix. The seat widened the claim's file surface by that one pin file (14348#issuecomment-5508047359) and issued patch round 1 to the same dev: repoint the probe at the predicate without adding an export, or model sys_account for id-bearing rows and assert the fail-closed outcome for the id-less row with a docblock. The next push carries it; the PR stays draft until the patched head is green.


Generated by Claude Code

…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-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

Landing provenance — ready + auto-merge at head 3360e5f97


Generated by Claude Code

@os-sales
os-sales added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 7286dd5Sep 2, 2026
38 checks passed
@os-sales
os-sales deleted the claude/issue-14348-promote-authenticable-first-user branch September 2, 2026 12:25
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants

@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(plugin-security): promote the oldest human that can authenticate, not the oldest directory row (#14348) - #14532

Merged
os-sales merged 5 commits into
mainfrom
claude/issue-14348-promote-authenticable-first-user
Sep 2, 2026
Merged

fix(plugin-security): promote the oldest human that can authenticate, not the oldest directory row (#14348)#14532
os-sales merged 5 commits into
mainfrom
claude/issue-14348-promote-authenticable-first-user

Conversation

@os-sales

@os-salesos-sales commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14348

Under the single posture bootstrapPlatformAdmin ranked promotion candidates by age with "is human" as its only filter. On an app that declares people through defineStack({ data }) that picks the wrong row every time: a declared person is a credential-less directory row, and the declarative seed is awaited inside AppPlugin.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's bootStack (AppPlugin, then AuthPlugin, then SecurityPlugin — the same relative registration order objectstack dev uses at packages/cli/src/commands/serve.ts:3382 and :3590), over in-memory SQLite, on an app whose data declares two sys_user rows plus two business records. A full objectstack dev CLI 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? On origin/main at 1dcb995f the grant went to person0@demo.example with has_sys_account: false, on a database whose sys_account table 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's kernel:ready dev-admin seed created it (auth-plugin.ts hook at :975, seeding at :976). Its presence at selection time proves the auth kernel:ready handler completes before SecurityPlugin's bootstrap (security-plugin.ts:3612). Consistent with the dispatcher: dispatchHookPropagating runs 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 credential account, was not promoted — the bootstrap replay answered already_have_admin. The wrong grant is permanent.

The premise survives the merge with #14352 (#14157)

origin/main now carries #14352, so objectstack dev does 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 granted admin_full_access to person0@demo.example (holder_has_account: false) and still handed it both seeded records — while a loginable admin@objectos.ai existed. With the selector restored, the same boot promotes admin@objectos.ai and 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.ts short-circuits on if (!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-less admin_full_access grant 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.ts pins that boundary directly: an existing grant on the wrong row, with a loginable account sitting next to it, stays put.

What changed

  • The target is the oldest human holding a sys_account. Any provider counts — a federated or SSO account is a login, and narrowing to provider_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.
  • Humans present but none authenticable: promote nobody, write no grant row, log one info line. No new error-level site through a published sink shape.
  • shouldReplayBootstrapFor also fires on sys_account inserts. This half is load-bearing, not garnish. Measured write order inside a real sign-up: sys_user.insert exit lands beforesys_account.insert enter, so the pre-existing sys_user arm 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 — two no_authenticable_user passes and an empty grant table even after a successful sign-up.

Verification

Union run at 3360e5f97 (branch base: merge of origin/main00ff228fe).

  • pnpm --filter @objectstack/plugin-security test95 files / 1785 tests passed.
  • pnpm --filter @objectstack/plugin-auth test88 files / 1816 tests passed.
  • pnpm --filter @objectstack/plugin-security typecheck — clean; both edited test files confirmed present in the tsconfig.test.json program via --listFiles (1 hit each).
  • pnpm --filter @objectstack/plugin-auth typecheck — clean, including check:test-typecheck ("OK — @objectstack/plugin-auth's test layer compiles"). The edited pin file is confirmed in that program via tsconfig.test.json --listFiles (1 hit); it is absent from the base tsc --noEmit program, 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.
  • Gate families re-derived on the patched head (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 is check-test-completeness.mjs at exit 3 = PREREQUISITE NOT MET: it grades a saved turbo run test log, 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 — note check:i18n signals 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. After turbo run build over ./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, which tsup strips, so ablation-dist-preflight found it only in sourcemaps and refused the reading. Re-run with an executable string literal: preflight confirmed the marker in dist/index.js and dist/index.mjs, the reading stands, and the restore leg rebuilt clean with --absent green and a clean whole-tree git status --porcelain. On the mutated leg pnpm build exits 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 export returns 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: shouldReplayBootstrapFor keeps its name, signature and type, but its body widens to admit sys_account inserts. 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 under packages/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 isHumanUser is a const declared inside the bootstrapPlatformAdmin function 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-② to yes, which is a stop condition, so option 2 it is. Clause-② stays no — re-verified from the diff after this patch.

What changed:

  1. The file's fake engine now models sys_account for every corpus row carrying a usable id. This is what restores the six id-bearing rows: the probe reads plugin-security's human verdict indirectly as report.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.
  2. The id-less row { email: 'ghost@example.test' } is handled explicitly rather than silently. It is human to both predicates and no account can be keyed to it, so adminPromoted cannot carry a predicate verdict for it at all. Its case now asserts the outcome: isHumanUserRow still 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 via reason: '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 id cannot hold an exercisable grant — the pre-#14348 code promoted it by writing sys_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 own NON_OBJECT_CORPUS group 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.ts22 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

@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/validation-rules.mdx(via sys_account (literal, a string literal in bootstrapPlatformAdmin; a string literal in shouldReplayBootstrapFor))
  • content/docs/permissions/authentication.mdx(via sys_account (literal, a string literal in bootstrapPlatformAdmin; a string literal in shouldReplayBootstrapFor))
  • content/docs/protocol/objectui/actions.mdx(via sys_account (literal, a string literal in bootstrapPlatformAdmin; a string literal in shouldReplayBootstrapFor))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via sys_account (literal, a string literal in bootstrapPlatformAdmin; a string literal in shouldReplayBootstrapFor))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: sys_user (literal, 30 pages)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 14 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 fed4fa40924bd1366354ac9b8a55db9d94a7e404packageMentionDocs.

Which tree this was computed on

This run read content/docs from ffa0f942351615ea9a9ac6932025d8ab46cf985d — the merge of head 3360e5f975bf2a931bfdcc903f88bc5982dc5841 into base fed4fa40924bd1366354ac9b8a55db9d94a7e404, 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 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

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

@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

CI status on de7dd456bTest Core (4/6) red, cause confirmed, patch round in flight

Read from the job log (job 100210403629): the only failing task is @objectstack/plugin-auth#test, and the only failing file is src/human-user-predicate-agreement.pin.test.ts — seven corpus rows plus the anti-vacuity check, every one with plugin-security isHumanUser -> false (reason: no_authenticable_user). That is exactly the out-of-surface consequence the PR body reports: the pin reads promotion as a proxy for the human predicate, and promotion now requires an exercisable login, which the pin's fake engine never models. check-test-completeness on the shard is OK (4 of 4 packages reported), so nothing else in the shard is hidden behind it.

Not a flake and not a base-branch failure; it is this PR's to fix. The seat widened the claim's file surface by that one pin file (14348#issuecomment-5508047359) and issued patch round 1 to the same dev: repoint the probe at the predicate without adding an export, or model sys_account for id-bearing rows and assert the fail-closed outcome for the id-less row with a docblock. The next push carries it; the PR stays draft until the patched head is green.


Generated by Claude Code

…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-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

Landing provenance — ready + auto-merge at head 3360e5f97


Generated by Claude Code

@os-sales
os-sales added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 7286dd5Sep 2, 2026
38 checks passed
@os-sales
os-sales deleted the claude/issue-14348-promote-authenticable-first-user branch September 2, 2026 12:25
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants

@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(plugin-security): promote the oldest human that can authenticate, not the oldest directory row (#14348) - #14532

Merged
os-sales merged 5 commits into
mainfrom
claude/issue-14348-promote-authenticable-first-user
Sep 2, 2026
Merged

fix(plugin-security): promote the oldest human that can authenticate, not the oldest directory row (#14348)#14532
os-sales merged 5 commits into
mainfrom
claude/issue-14348-promote-authenticable-first-user

Conversation

@os-sales

@os-salesos-sales commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14348

Under the single posture bootstrapPlatformAdmin ranked promotion candidates by age with "is human" as its only filter. On an app that declares people through defineStack({ data }) that picks the wrong row every time: a declared person is a credential-less directory row, and the declarative seed is awaited inside AppPlugin.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's bootStack (AppPlugin, then AuthPlugin, then SecurityPlugin — the same relative registration order objectstack dev uses at packages/cli/src/commands/serve.ts:3382 and :3590), over in-memory SQLite, on an app whose data declares two sys_user rows plus two business records. A full objectstack dev CLI 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? On origin/main at 1dcb995f the grant went to person0@demo.example with has_sys_account: false, on a database whose sys_account table 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's kernel:ready dev-admin seed created it (auth-plugin.ts hook at :975, seeding at :976). Its presence at selection time proves the auth kernel:ready handler completes before SecurityPlugin's bootstrap (security-plugin.ts:3612). Consistent with the dispatcher: dispatchHookPropagating runs 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 credential account, was not promoted — the bootstrap replay answered already_have_admin. The wrong grant is permanent.

The premise survives the merge with #14352 (#14157)

origin/main now carries #14352, so objectstack dev does 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 granted admin_full_access to person0@demo.example (holder_has_account: false) and still handed it both seeded records — while a loginable admin@objectos.ai existed. With the selector restored, the same boot promotes admin@objectos.ai and 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.ts short-circuits on if (!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-less admin_full_access grant 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.ts pins that boundary directly: an existing grant on the wrong row, with a loginable account sitting next to it, stays put.

What changed

  • The target is the oldest human holding a sys_account. Any provider counts — a federated or SSO account is a login, and narrowing to provider_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.
  • Humans present but none authenticable: promote nobody, write no grant row, log one info line. No new error-level site through a published sink shape.
  • shouldReplayBootstrapFor also fires on sys_account inserts. This half is load-bearing, not garnish. Measured write order inside a real sign-up: sys_user.insert exit lands beforesys_account.insert enter, so the pre-existing sys_user arm 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 — two no_authenticable_user passes and an empty grant table even after a successful sign-up.

Verification

Union run at 3360e5f97 (branch base: merge of origin/main00ff228fe).

  • pnpm --filter @objectstack/plugin-security test95 files / 1785 tests passed.
  • pnpm --filter @objectstack/plugin-auth test88 files / 1816 tests passed.
  • pnpm --filter @objectstack/plugin-security typecheck — clean; both edited test files confirmed present in the tsconfig.test.json program via --listFiles (1 hit each).
  • pnpm --filter @objectstack/plugin-auth typecheck — clean, including check:test-typecheck ("OK — @objectstack/plugin-auth's test layer compiles"). The edited pin file is confirmed in that program via tsconfig.test.json --listFiles (1 hit); it is absent from the base tsc --noEmit program, 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.
  • Gate families re-derived on the patched head (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 is check-test-completeness.mjs at exit 3 = PREREQUISITE NOT MET: it grades a saved turbo run test log, 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 — note check:i18n signals 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. After turbo run build over ./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, which tsup strips, so ablation-dist-preflight found it only in sourcemaps and refused the reading. Re-run with an executable string literal: preflight confirmed the marker in dist/index.js and dist/index.mjs, the reading stands, and the restore leg rebuilt clean with --absent green and a clean whole-tree git status --porcelain. On the mutated leg pnpm build exits 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 export returns 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: shouldReplayBootstrapFor keeps its name, signature and type, but its body widens to admit sys_account inserts. 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 under packages/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 isHumanUser is a const declared inside the bootstrapPlatformAdmin function 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-② to yes, which is a stop condition, so option 2 it is. Clause-② stays no — re-verified from the diff after this patch.

What changed:

  1. The file's fake engine now models sys_account for every corpus row carrying a usable id. This is what restores the six id-bearing rows: the probe reads plugin-security's human verdict indirectly as report.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.
  2. The id-less row { email: 'ghost@example.test' } is handled explicitly rather than silently. It is human to both predicates and no account can be keyed to it, so adminPromoted cannot carry a predicate verdict for it at all. Its case now asserts the outcome: isHumanUserRow still 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 via reason: '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 id cannot hold an exercisable grant — the pre-#14348 code promoted it by writing sys_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 own NON_OBJECT_CORPUS group 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.ts22 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

@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/validation-rules.mdx(via sys_account (literal, a string literal in bootstrapPlatformAdmin; a string literal in shouldReplayBootstrapFor))
  • content/docs/permissions/authentication.mdx(via sys_account (literal, a string literal in bootstrapPlatformAdmin; a string literal in shouldReplayBootstrapFor))
  • content/docs/protocol/objectui/actions.mdx(via sys_account (literal, a string literal in bootstrapPlatformAdmin; a string literal in shouldReplayBootstrapFor))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via sys_account (literal, a string literal in bootstrapPlatformAdmin; a string literal in shouldReplayBootstrapFor))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: sys_user (literal, 30 pages)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 14 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 fed4fa40924bd1366354ac9b8a55db9d94a7e404packageMentionDocs.

Which tree this was computed on

This run read content/docs from ffa0f942351615ea9a9ac6932025d8ab46cf985d — the merge of head 3360e5f975bf2a931bfdcc903f88bc5982dc5841 into base fed4fa40924bd1366354ac9b8a55db9d94a7e404, 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 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

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

@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

CI status on de7dd456bTest Core (4/6) red, cause confirmed, patch round in flight

Read from the job log (job 100210403629): the only failing task is @objectstack/plugin-auth#test, and the only failing file is src/human-user-predicate-agreement.pin.test.ts — seven corpus rows plus the anti-vacuity check, every one with plugin-security isHumanUser -> false (reason: no_authenticable_user). That is exactly the out-of-surface consequence the PR body reports: the pin reads promotion as a proxy for the human predicate, and promotion now requires an exercisable login, which the pin's fake engine never models. check-test-completeness on the shard is OK (4 of 4 packages reported), so nothing else in the shard is hidden behind it.

Not a flake and not a base-branch failure; it is this PR's to fix. The seat widened the claim's file surface by that one pin file (14348#issuecomment-5508047359) and issued patch round 1 to the same dev: repoint the probe at the predicate without adding an export, or model sys_account for id-bearing rows and assert the fail-closed outcome for the id-less row with a docblock. The next push carries it; the PR stays draft until the patched head is green.


Generated by Claude Code

…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-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

Landing provenance — ready + auto-merge at head 3360e5f97


Generated by Claude Code

@os-sales
os-sales added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 7286dd5Sep 2, 2026
38 checks passed
@os-sales
os-sales deleted the claude/issue-14348-promote-authenticable-first-user branch September 2, 2026 12:25
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants

@os-sales@claude