fix(plugin-auth): gate the dev-admin seed on a LOGIN, not on user rows, and admit its own provisioning call - #14352

Merged
os-sales merged 4 commits into
mainfrom
claude/issue-14157-dev-admin-seed-credential-gate
Sep 2, 2026
Merged

fix(plugin-auth): gate the dev-admin seed on a LOGIN, not on user rows, and admit its own provisioning call#14352
os-sales merged 4 commits into
mainfrom
claude/issue-14157-dev-admin-seed-credential-gate

Conversation

@claude

@claudeclaudeBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes#14157

objectstack dev declares, in its own --help, a known loginable dev admin. The gate that implemented it asked a different question — does any human sys_user row exist? — and those two are the same question only while every user row carries a credential. An app that declares people in defineStack({ data }) breaks that: the declarative seed is awaited inside AppPlugin.start(), so it always lands before the seed's own kernel:ready hook, the database is non-zero-user before the check runs, and the admin is never minted — on that boot or on any later one, because the rows survive. The deployment ends up with no loginable account at all.

A seeded person is a directory row with no account. It is not a login, and treating it as one is the defect.

The fix is TWO halves, and the second one is what makes the first work

Half 1 — the gate (packages/plugins/plugin-auth/src/dev-admin-seed-gate.ts, new; owns the predicate and the argument). The seed acts while no account holds the configured seed address AND no local password login (sys_account.provider_id = 'credential') exists anywhere. Those are the card's own two suggested spellings, both used, because each guards a case the other does not: the first is the never-overwrite half (and it now covers a federated account on that address too), the second is "the environment already has the thing the seed provides". A credential store that cannot be read is its own verdict — unanswerable — and the seed declines and says so rather than minting a known-credential admin into an environment it could not see.

Half 2 — admission for the seed's own provisioning call (AuthManager.stageOperatorProvisioning). This is not a nicety. The seed provisions through better-auth's real signUpEmail, and that call has to be admitted; its admission used to ride on the audience gate's bootstrap bypass, "zero HUMAN users" — which the same 13 seeded rows also answer "populated". Measured: with 13 seeded people and zero accounts, under the default invite_only posture, the seed's own lane comes back SELF_REGISTRATION_CLOSED. A fix that moved only the gate would therefore have produced a seed that decides to run and a gate that then refuses it — the exact drift isHumanUserRow's own doc warns about. Case ⓪ of the new pin measures that refusal directly, which is what keeps every case after it non-vacuous.

So the seed now declares what it is — the deployment's own boot command provisioning its admin, the operator creation class — instead of inferring admission from a population it does not own. This moves no public door: nothing outside the process can stage the declaration, the only caller is hard-gated to NODE_ENV==='development', it names one address, it is cleared in a finally, and a caller killed between the two is cleaned up by a 60-second TTL. Case ④ pins that the stranger's /sign-up/email is refused SELF_REGISTRATION_CLOSED on exactly this population, before and after the seed runs.

The audience gate's own predicate is deliberately unchanged. It is a public self-registration carve-out, so the population it counts has to stay humans.

The second surface: bootstrap-status — measured, and folded in

Triage asked for this to be measured and folded in or declared out of scope with the reading. Measured, it is a real disagreement of its own rather than a look-alike: the handler counted sys_user rows with no filter at all, which made it the one call site out of step with the three isHumanUserRow consumers. On a database still carrying the legacy usr_system service row it answered hasOwner: true — the console withholding the first-run setup flow — while the admission gate and plugin-security's first-user detection both stood ready to admit and promote the first human.

It now asks AuthManager.hasBootstrapWindow(), the same question the admission gate answers, so the console can never offer a first-run creation the platform would refuse, nor withhold one it would allow. Cases ⑦–⑧ drive the real route. A no-engine composition (MSW/mock mode) still reads as bootstrapped, so the SPA falls through to its normal login flow exactly as before.

What is deliberately NOT in this PR, with the reading: whether that window should mean "no LOGIN" rather than "no human users". Flipping it is coupled to the admission gate's predicate — moving only bootstrap-status makes the console offer a setup flow the gate then refuses 403, and moving both admits a stranger's self-registration on a directory-seeded deployment. That is a posture ruling, not an implementation choice, so it is filed with its measurements and three costed options as #14349 rather than decided here.

Declared riders, both named on purpose

  • packages/cli/src/commands/dev.ts — the claim fenced packages/cli with one permitted exception: the sentence that states the gate predicate. Two lines carry that one statement and both are corrected: the --seed-admin flag description (the declared contract, user-visible) and the in-code comment restating it above const seedAdmin. Nothing else in that package is touched — notably not the "then promote it to platform admin" clause, which is a separate claim and a separate finding (below).
  • scripts/check-route-envelope.mjs — the ruled exemption for auth-plugin.ts drops 3 → 2. The third unenveloped body was the handler's no-engine early return, which existed only because the handler resolved the data engine itself; asking the probe (which reports "no window" without an engine) folds that branch into the ordinary answer. Shrinking a ruled boundary needs nobody's leave; widening is the maintainer-only direction. The gate's own --self-test runs ahead of it and passes.

Interplay with #14000 (not a dependency)

#14000's ruled remedy asserts that the seeded admin can sign in on a fresh install. That assertion is sound for the current scaffold, which seeds no users, and this change is what makes it robust if the scaffold ever gains a person seed. Neither blocks the other and no Blocked-by: is implied in either direction.

Verification

All of the following ran on c89600f4, with a clean working tree, through the shared verify lock.

whatresult
pnpm --filter @objectstack/plugin-auth exec vitest run88 files, 1816 tests, all passed
pnpm --filter @objectstack/plugin-auth typecheckpassed (after building the package's own dist — the examples program imports it by name)
the new pin alone13 tests passed
pnpm exec eslint --no-inline-config .5674 files, 0 errors, 0 warnings — the whole population, no narrowing
derived gate families (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, re-derived after the gate-script edit: 49 commands)all measured ones green, including check:route-envelope, check:engine-double-contract, check:where-matcher, check:test-source-alias, check:type-check-coverage, check:dispatcher-error-vocabulary, check:auth-mount-ledger, check:nul-bytes

Not measured locally, each for a stated reason, and each of them something CI runs anyway: check:test-completeness (exit 3, PREREQUISITE NOT MET by design — it grades a saved turbo test log and none was passed); check:i18n and check:i18n-coverage (PREREQUISITE NOT MET — they run the built CLI and its whole build closure); check:type-check-debt --re-measure and check:dual-build-cjs-loads (need the full workspace build); check:pm-dispatch-gates and check-half-states (both exceeded the container's foreground cap — timed out at 150s and 380s, so NOT MEASURED rather than red).

For the debt ledger specifically, the risk it covers was measured directly instead: plugin-auth's tsconfig.json excludes **/*.test.ts, so the package typecheck says nothing about a new test file. Compiling the new test file plus the changed sources through a temporary config with that exclusion lifted exits 0, and --listFiles confirms both new files were actually in the program — so the ledgered count cannot drift up because of this diff.

Ablation — predicted RED, both legs, and both went red

Predicted before the run. No rebuild leg is owed: the pin imports its subjects relatively (./auth-plugin.js, ./auth-manager.js), so vitest resolves them to this package's src/, never through the package exports to dist/ — and a leg that had stayed green would itself have been the dist-resolution signature. Each leg proved the mutation reached disk (anchor-count before/after plus a git hash-object change, refusing to run the measurement otherwise) and proved the restore (git checkout HEAD --, then blob hash equal to the HEAD blob and git diff HEAD empty). The whole script carried trap restore EXIT INT TERM.

  • baseline, unmutated: 13 passed
  • leg A1 — the gate reverted to humans.length > 0, ticket left in place: 6 of 13 failed (①, ①b, ②, ②b, ④, ⑤b)
  • leg A2 — the ticket's two read sites replaced by false, gate left in place: 4 of 13 failed (①, ①b, ②, ④)

A2 is the one worth reading: it is the measurement that this PR's second half is load-bearing, not decoration.

Out-of-scope findings, filed unassigned (searched first; a positive control confirmed the search channel was answering)

Adjacent but not a duplicate of #14348: #11979 is about config-anchoring the single posture, not about which row it selects.

Contract review (Clause ②)

Re-declared yes on the card after review: this diff widens a public surface, so the @objectstack/plugin-auth changeset is minor, not patch. The exported-symbol delta, split by whether it actually reaches a consumer:

On the package barrelsrc/index.ts carries export * from './auth-manager.js', so every public member of the exported AuthManager class is public API. Four are new:

new public memberwhat it is
stageOperatorProvisioning(email: string): voiddeclares that the deployment's own boot command is provisioning this address right now
clearOperatorProvisioning(email: string): voiddrops that declaration
isOperatorProvisioning(email: unknown): booleana peek, not a consume — both admission seams ask about the same single creation
hasBootstrapWindow() — async, answers a booleanpublic read of the same bootstrap-window question the admission gate answers

A fifth member added in the same hunk, prunePendingOperatorProvisioning(), is private and is not part of the surface.

Also on this side, and the reason "minor" is not only about added symbols: GET /api/v1/auth/bootstrap-statuschanges its published answer. On a database whose only row is the legacy usr_system service account it answered hasOwner: true and now answers false. Same field, same type, same route, different verdict on that population — a consumer-visible behaviour change, which is exactly what the level is for.

Module-only, not on the barrelsrc/dev-admin-seed-gate.ts is imported relatively by auth-plugin.ts and is not re-exported from src/index.ts, so these four are internal to the package and carry no compatibility promise:

  • export interface DevAdminSeedProbeEngine
  • export type DevAdminSeedGateVerdict
  • export const CREDENTIAL_PROVIDER_ID
  • export async function decideDevAdminSeedGate

Changeset levels: @objectstack/plugin-authminor · @objectstack/clipatch (flag description text and a comment only — no exported symbol and no behaviour of its own). Nothing here is breaking: every addition is additive, no export is removed or narrowed, and no ADR-0087 disposition is owed.

Adjacent mechanical fix

The census anchor in content/docs/permissions/system-context.mdx moved auth-plugin.ts:1296 -> auth-plugin.ts:1301, because this PR inserted lines above that read site. Gate family: check-system-context-census (lint.yml). Pure line rot, not a population change — written by the gate's own --fix, which re-anchored one row and refused nothing; git diff --stat for that commit is one file, one line. Verdict after: check-system-context-census: OK — 109 elevation read sites in 20 packages across 45 files, all anchored; 145 anchors resolve, 27 declared non-read, and its --self-test passes.

That file is outside this claim's original file surface, so it is declared here and the claim comment on #14157 is amended to include it.

Worth recording for whoever derives gates next: this family is not derivable from the change that breaks it. It declares 29 path literals — its own anchor page plus specific packages/spec files — and packages/plugins/plugin-auth/src/auth-plugin.ts is not among them, so a code-only change set scores it silent (the derivation's own weakest verdict, explicitly "not a clearance"). It appears in the derived list only once the anchor page itself is in the change set — i.e. after the repair, never before it. Its real trigger is any source line movement under one of 145 anchors across 45 files, which no path literal can enumerate.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8

Generated by Claude Code


Generated by Claude Code


Generated by Claude Code

@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cli, @objectstack/plugin-auth, touching 21 documentable anchor(s).

28 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json d62f990a985eb15152579ad34c99f43e76e3657a.

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

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)
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 29 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 d62f990a985eb15152579ad34c99f43e76e3657apackageMentionDocs.

Which tree this was computed on

This run read content/docs from d99eee1c39c61aa4ae5a5f320934bcb5530c4875 — the merge of head 31fd3968cf0c0aadc5f75575bbbb411541b8bf53 into base d62f990a985eb15152579ad34c99f43e76e3657a, 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 d99eee1c39c61aa4ae5a5f320934bcb5530c4875 && git checkout d99eee1c39c61aa4ae5a5f320934bcb5530c4875
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d62f990a985eb15152579ad34c99f43e76e3657a 31fd3968cf0c0aadc5f75575bbbb411541b8bf53 && git checkout -B drift-repro d62f990a985eb15152579ad34c99f43e76e3657a && git merge --no-ff 31fd3968cf0c0aadc5f75575bbbb411541b8bf53
node scripts/docs-audit/affected-docs.mjs --json d62f990a985eb15152579ad34c99f43e76e3657a

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

@os-salesClaude

Copy link
Copy Markdown
Collaborator

CI status note (PM seat, session_01AUF1NoViznQK32gqpK8wS8): Lint & Repo Gates is red on 04fcb96a at one gate, check-system-context-census — a pure anchor shift: content/docs/permissions/system-context.mdx cites packages/plugins/plugin-auth/src/auth-plugin.ts:1296, and this PR's insertions moved the opCtx.context.isSystem read to :1301. It is this PR's to fix; the fix is in flight (the delivering dev runs the gate's own --fix and pushes one docs-anchor commit, declared on the body as an adjacent mechanical fix). No re-run is owed — the failure is deterministic on this head. The Clause-② PASS recorded on #14157 covers the contract increment; the seat verifies the next head differs from 04fcb96a by that one docs file only before marking ready.


Generated by Claude Code

@os-salesClaude

Copy link
Copy Markdown
Collaborator

Landing provenance (PM seat, domain:services, session_01AUF1NoViznQK32gqpK8wS8)

  • In-seat Clause-② contract review: PASS on the card — maybeSeedDevAdmin gates on "any sys_user row", but runs at kernel:ready — so any app that seeds people permanently loses the objectstack dev login #14157 (comment) — an isolated fable second opinion, transcript-verified (55 of 55 harness stamps at tier), adopted verbatim; needs:contract-review cleared on both carriers in that stroke (maintainer 2026-08-31: a PASS is cleared and landed by the dispatching seat; check-clause2-carriers --pair 14352 exit 0).
  • Head 31fd3968 = the reviewed 04fcb96a plus one docs-anchor line (content/docs/permissions/system-context.mdx, auth-plugin.ts:1296:1301), verified by git diff --stat; the contract increment is unchanged.
  • Landing checks on 31fd3968: every check green — Lint & Repo Gatessuccess 03:43:36Z (the check-system-context-census red on the previous head is repaired), Test Core (1/6)success 03:49:39Z, all other shards, type checks, Dogfood, Temporal Conformance, guards and changeset checks success; the remainder skipped by path filter. Closing keyword: Fixes #14157 is the only one in the body.
  • Marked ready for review; auto-merge armed for the merge queue in the same stroke.

Generated by Claude Code

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.

maybeSeedDevAdmin gates on "any sys_user row", but runs at kernel:ready — so any app that seeds people permanently loses the objectstack dev login

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-auth): gate the dev-admin seed on a LOGIN, not on user rows, and admit its own provisioning call - #14352

Merged
os-sales merged 4 commits into
mainfrom
claude/issue-14157-dev-admin-seed-credential-gate
Sep 2, 2026
Merged

fix(plugin-auth): gate the dev-admin seed on a LOGIN, not on user rows, and admit its own provisioning call#14352
os-sales merged 4 commits into
mainfrom
claude/issue-14157-dev-admin-seed-credential-gate

Conversation

@claude

@claudeclaudeBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes#14157

objectstack dev declares, in its own --help, a known loginable dev admin. The gate that implemented it asked a different question — does any human sys_user row exist? — and those two are the same question only while every user row carries a credential. An app that declares people in defineStack({ data }) breaks that: the declarative seed is awaited inside AppPlugin.start(), so it always lands before the seed's own kernel:ready hook, the database is non-zero-user before the check runs, and the admin is never minted — on that boot or on any later one, because the rows survive. The deployment ends up with no loginable account at all.

A seeded person is a directory row with no account. It is not a login, and treating it as one is the defect.

The fix is TWO halves, and the second one is what makes the first work

Half 1 — the gate (packages/plugins/plugin-auth/src/dev-admin-seed-gate.ts, new; owns the predicate and the argument). The seed acts while no account holds the configured seed address AND no local password login (sys_account.provider_id = 'credential') exists anywhere. Those are the card's own two suggested spellings, both used, because each guards a case the other does not: the first is the never-overwrite half (and it now covers a federated account on that address too), the second is "the environment already has the thing the seed provides". A credential store that cannot be read is its own verdict — unanswerable — and the seed declines and says so rather than minting a known-credential admin into an environment it could not see.

Half 2 — admission for the seed's own provisioning call (AuthManager.stageOperatorProvisioning). This is not a nicety. The seed provisions through better-auth's real signUpEmail, and that call has to be admitted; its admission used to ride on the audience gate's bootstrap bypass, "zero HUMAN users" — which the same 13 seeded rows also answer "populated". Measured: with 13 seeded people and zero accounts, under the default invite_only posture, the seed's own lane comes back SELF_REGISTRATION_CLOSED. A fix that moved only the gate would therefore have produced a seed that decides to run and a gate that then refuses it — the exact drift isHumanUserRow's own doc warns about. Case ⓪ of the new pin measures that refusal directly, which is what keeps every case after it non-vacuous.

So the seed now declares what it is — the deployment's own boot command provisioning its admin, the operator creation class — instead of inferring admission from a population it does not own. This moves no public door: nothing outside the process can stage the declaration, the only caller is hard-gated to NODE_ENV==='development', it names one address, it is cleared in a finally, and a caller killed between the two is cleaned up by a 60-second TTL. Case ④ pins that the stranger's /sign-up/email is refused SELF_REGISTRATION_CLOSED on exactly this population, before and after the seed runs.

The audience gate's own predicate is deliberately unchanged. It is a public self-registration carve-out, so the population it counts has to stay humans.

The second surface: bootstrap-status — measured, and folded in

Triage asked for this to be measured and folded in or declared out of scope with the reading. Measured, it is a real disagreement of its own rather than a look-alike: the handler counted sys_user rows with no filter at all, which made it the one call site out of step with the three isHumanUserRow consumers. On a database still carrying the legacy usr_system service row it answered hasOwner: true — the console withholding the first-run setup flow — while the admission gate and plugin-security's first-user detection both stood ready to admit and promote the first human.

It now asks AuthManager.hasBootstrapWindow(), the same question the admission gate answers, so the console can never offer a first-run creation the platform would refuse, nor withhold one it would allow. Cases ⑦–⑧ drive the real route. A no-engine composition (MSW/mock mode) still reads as bootstrapped, so the SPA falls through to its normal login flow exactly as before.

What is deliberately NOT in this PR, with the reading: whether that window should mean "no LOGIN" rather than "no human users". Flipping it is coupled to the admission gate's predicate — moving only bootstrap-status makes the console offer a setup flow the gate then refuses 403, and moving both admits a stranger's self-registration on a directory-seeded deployment. That is a posture ruling, not an implementation choice, so it is filed with its measurements and three costed options as #14349 rather than decided here.

Declared riders, both named on purpose

  • packages/cli/src/commands/dev.ts — the claim fenced packages/cli with one permitted exception: the sentence that states the gate predicate. Two lines carry that one statement and both are corrected: the --seed-admin flag description (the declared contract, user-visible) and the in-code comment restating it above const seedAdmin. Nothing else in that package is touched — notably not the "then promote it to platform admin" clause, which is a separate claim and a separate finding (below).
  • scripts/check-route-envelope.mjs — the ruled exemption for auth-plugin.ts drops 3 → 2. The third unenveloped body was the handler's no-engine early return, which existed only because the handler resolved the data engine itself; asking the probe (which reports "no window" without an engine) folds that branch into the ordinary answer. Shrinking a ruled boundary needs nobody's leave; widening is the maintainer-only direction. The gate's own --self-test runs ahead of it and passes.

Interplay with #14000 (not a dependency)

#14000's ruled remedy asserts that the seeded admin can sign in on a fresh install. That assertion is sound for the current scaffold, which seeds no users, and this change is what makes it robust if the scaffold ever gains a person seed. Neither blocks the other and no Blocked-by: is implied in either direction.

Verification

All of the following ran on c89600f4, with a clean working tree, through the shared verify lock.

whatresult
pnpm --filter @objectstack/plugin-auth exec vitest run88 files, 1816 tests, all passed
pnpm --filter @objectstack/plugin-auth typecheckpassed (after building the package's own dist — the examples program imports it by name)
the new pin alone13 tests passed
pnpm exec eslint --no-inline-config .5674 files, 0 errors, 0 warnings — the whole population, no narrowing
derived gate families (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, re-derived after the gate-script edit: 49 commands)all measured ones green, including check:route-envelope, check:engine-double-contract, check:where-matcher, check:test-source-alias, check:type-check-coverage, check:dispatcher-error-vocabulary, check:auth-mount-ledger, check:nul-bytes

Not measured locally, each for a stated reason, and each of them something CI runs anyway: check:test-completeness (exit 3, PREREQUISITE NOT MET by design — it grades a saved turbo test log and none was passed); check:i18n and check:i18n-coverage (PREREQUISITE NOT MET — they run the built CLI and its whole build closure); check:type-check-debt --re-measure and check:dual-build-cjs-loads (need the full workspace build); check:pm-dispatch-gates and check-half-states (both exceeded the container's foreground cap — timed out at 150s and 380s, so NOT MEASURED rather than red).

For the debt ledger specifically, the risk it covers was measured directly instead: plugin-auth's tsconfig.json excludes **/*.test.ts, so the package typecheck says nothing about a new test file. Compiling the new test file plus the changed sources through a temporary config with that exclusion lifted exits 0, and --listFiles confirms both new files were actually in the program — so the ledgered count cannot drift up because of this diff.

Ablation — predicted RED, both legs, and both went red

Predicted before the run. No rebuild leg is owed: the pin imports its subjects relatively (./auth-plugin.js, ./auth-manager.js), so vitest resolves them to this package's src/, never through the package exports to dist/ — and a leg that had stayed green would itself have been the dist-resolution signature. Each leg proved the mutation reached disk (anchor-count before/after plus a git hash-object change, refusing to run the measurement otherwise) and proved the restore (git checkout HEAD --, then blob hash equal to the HEAD blob and git diff HEAD empty). The whole script carried trap restore EXIT INT TERM.

  • baseline, unmutated: 13 passed
  • leg A1 — the gate reverted to humans.length > 0, ticket left in place: 6 of 13 failed (①, ①b, ②, ②b, ④, ⑤b)
  • leg A2 — the ticket's two read sites replaced by false, gate left in place: 4 of 13 failed (①, ①b, ②, ④)

A2 is the one worth reading: it is the measurement that this PR's second half is load-bearing, not decoration.

Out-of-scope findings, filed unassigned (searched first; a positive control confirmed the search channel was answering)

Adjacent but not a duplicate of #14348: #11979 is about config-anchoring the single posture, not about which row it selects.

Contract review (Clause ②)

Re-declared yes on the card after review: this diff widens a public surface, so the @objectstack/plugin-auth changeset is minor, not patch. The exported-symbol delta, split by whether it actually reaches a consumer:

On the package barrelsrc/index.ts carries export * from './auth-manager.js', so every public member of the exported AuthManager class is public API. Four are new:

new public memberwhat it is
stageOperatorProvisioning(email: string): voiddeclares that the deployment's own boot command is provisioning this address right now
clearOperatorProvisioning(email: string): voiddrops that declaration
isOperatorProvisioning(email: unknown): booleana peek, not a consume — both admission seams ask about the same single creation
hasBootstrapWindow() — async, answers a booleanpublic read of the same bootstrap-window question the admission gate answers

A fifth member added in the same hunk, prunePendingOperatorProvisioning(), is private and is not part of the surface.

Also on this side, and the reason "minor" is not only about added symbols: GET /api/v1/auth/bootstrap-statuschanges its published answer. On a database whose only row is the legacy usr_system service account it answered hasOwner: true and now answers false. Same field, same type, same route, different verdict on that population — a consumer-visible behaviour change, which is exactly what the level is for.

Module-only, not on the barrelsrc/dev-admin-seed-gate.ts is imported relatively by auth-plugin.ts and is not re-exported from src/index.ts, so these four are internal to the package and carry no compatibility promise:

  • export interface DevAdminSeedProbeEngine
  • export type DevAdminSeedGateVerdict
  • export const CREDENTIAL_PROVIDER_ID
  • export async function decideDevAdminSeedGate

Changeset levels: @objectstack/plugin-authminor · @objectstack/clipatch (flag description text and a comment only — no exported symbol and no behaviour of its own). Nothing here is breaking: every addition is additive, no export is removed or narrowed, and no ADR-0087 disposition is owed.

Adjacent mechanical fix

The census anchor in content/docs/permissions/system-context.mdx moved auth-plugin.ts:1296 -> auth-plugin.ts:1301, because this PR inserted lines above that read site. Gate family: check-system-context-census (lint.yml). Pure line rot, not a population change — written by the gate's own --fix, which re-anchored one row and refused nothing; git diff --stat for that commit is one file, one line. Verdict after: check-system-context-census: OK — 109 elevation read sites in 20 packages across 45 files, all anchored; 145 anchors resolve, 27 declared non-read, and its --self-test passes.

That file is outside this claim's original file surface, so it is declared here and the claim comment on #14157 is amended to include it.

Worth recording for whoever derives gates next: this family is not derivable from the change that breaks it. It declares 29 path literals — its own anchor page plus specific packages/spec files — and packages/plugins/plugin-auth/src/auth-plugin.ts is not among them, so a code-only change set scores it silent (the derivation's own weakest verdict, explicitly "not a clearance"). It appears in the derived list only once the anchor page itself is in the change set — i.e. after the repair, never before it. Its real trigger is any source line movement under one of 145 anchors across 45 files, which no path literal can enumerate.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8

Generated by Claude Code


Generated by Claude Code


Generated by Claude Code

@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cli, @objectstack/plugin-auth, touching 21 documentable anchor(s).

28 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json d62f990a985eb15152579ad34c99f43e76e3657a.

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

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)
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 29 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 d62f990a985eb15152579ad34c99f43e76e3657apackageMentionDocs.

Which tree this was computed on

This run read content/docs from d99eee1c39c61aa4ae5a5f320934bcb5530c4875 — the merge of head 31fd3968cf0c0aadc5f75575bbbb411541b8bf53 into base d62f990a985eb15152579ad34c99f43e76e3657a, 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 d99eee1c39c61aa4ae5a5f320934bcb5530c4875 && git checkout d99eee1c39c61aa4ae5a5f320934bcb5530c4875
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d62f990a985eb15152579ad34c99f43e76e3657a 31fd3968cf0c0aadc5f75575bbbb411541b8bf53 && git checkout -B drift-repro d62f990a985eb15152579ad34c99f43e76e3657a && git merge --no-ff 31fd3968cf0c0aadc5f75575bbbb411541b8bf53
node scripts/docs-audit/affected-docs.mjs --json d62f990a985eb15152579ad34c99f43e76e3657a

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

@os-salesClaude

Copy link
Copy Markdown
Collaborator

CI status note (PM seat, session_01AUF1NoViznQK32gqpK8wS8): Lint & Repo Gates is red on 04fcb96a at one gate, check-system-context-census — a pure anchor shift: content/docs/permissions/system-context.mdx cites packages/plugins/plugin-auth/src/auth-plugin.ts:1296, and this PR's insertions moved the opCtx.context.isSystem read to :1301. It is this PR's to fix; the fix is in flight (the delivering dev runs the gate's own --fix and pushes one docs-anchor commit, declared on the body as an adjacent mechanical fix). No re-run is owed — the failure is deterministic on this head. The Clause-② PASS recorded on #14157 covers the contract increment; the seat verifies the next head differs from 04fcb96a by that one docs file only before marking ready.


Generated by Claude Code

@os-salesClaude

Copy link
Copy Markdown
Collaborator

Landing provenance (PM seat, domain:services, session_01AUF1NoViznQK32gqpK8wS8)

  • In-seat Clause-② contract review: PASS on the card — maybeSeedDevAdmin gates on "any sys_user row", but runs at kernel:ready — so any app that seeds people permanently loses the objectstack dev login #14157 (comment) — an isolated fable second opinion, transcript-verified (55 of 55 harness stamps at tier), adopted verbatim; needs:contract-review cleared on both carriers in that stroke (maintainer 2026-08-31: a PASS is cleared and landed by the dispatching seat; check-clause2-carriers --pair 14352 exit 0).
  • Head 31fd3968 = the reviewed 04fcb96a plus one docs-anchor line (content/docs/permissions/system-context.mdx, auth-plugin.ts:1296:1301), verified by git diff --stat; the contract increment is unchanged.
  • Landing checks on 31fd3968: every check green — Lint & Repo Gatessuccess 03:43:36Z (the check-system-context-census red on the previous head is repaired), Test Core (1/6)success 03:49:39Z, all other shards, type checks, Dogfood, Temporal Conformance, guards and changeset checks success; the remainder skipped by path filter. Closing keyword: Fixes #14157 is the only one in the body.
  • Marked ready for review; auto-merge armed for the merge queue in the same stroke.

Generated by Claude Code

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.

maybeSeedDevAdmin gates on "any sys_user row", but runs at kernel:ready — so any app that seeds people permanently loses the objectstack dev login

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-auth): gate the dev-admin seed on a LOGIN, not on user rows, and admit its own provisioning call - #14352

Merged
os-sales merged 4 commits into
mainfrom
claude/issue-14157-dev-admin-seed-credential-gate
Sep 2, 2026
Merged

fix(plugin-auth): gate the dev-admin seed on a LOGIN, not on user rows, and admit its own provisioning call#14352
os-sales merged 4 commits into
mainfrom
claude/issue-14157-dev-admin-seed-credential-gate

Conversation

@claude

@claudeclaudeBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes#14157

objectstack dev declares, in its own --help, a known loginable dev admin. The gate that implemented it asked a different question — does any human sys_user row exist? — and those two are the same question only while every user row carries a credential. An app that declares people in defineStack({ data }) breaks that: the declarative seed is awaited inside AppPlugin.start(), so it always lands before the seed's own kernel:ready hook, the database is non-zero-user before the check runs, and the admin is never minted — on that boot or on any later one, because the rows survive. The deployment ends up with no loginable account at all.

A seeded person is a directory row with no account. It is not a login, and treating it as one is the defect.

The fix is TWO halves, and the second one is what makes the first work

Half 1 — the gate (packages/plugins/plugin-auth/src/dev-admin-seed-gate.ts, new; owns the predicate and the argument). The seed acts while no account holds the configured seed address AND no local password login (sys_account.provider_id = 'credential') exists anywhere. Those are the card's own two suggested spellings, both used, because each guards a case the other does not: the first is the never-overwrite half (and it now covers a federated account on that address too), the second is "the environment already has the thing the seed provides". A credential store that cannot be read is its own verdict — unanswerable — and the seed declines and says so rather than minting a known-credential admin into an environment it could not see.

Half 2 — admission for the seed's own provisioning call (AuthManager.stageOperatorProvisioning). This is not a nicety. The seed provisions through better-auth's real signUpEmail, and that call has to be admitted; its admission used to ride on the audience gate's bootstrap bypass, "zero HUMAN users" — which the same 13 seeded rows also answer "populated". Measured: with 13 seeded people and zero accounts, under the default invite_only posture, the seed's own lane comes back SELF_REGISTRATION_CLOSED. A fix that moved only the gate would therefore have produced a seed that decides to run and a gate that then refuses it — the exact drift isHumanUserRow's own doc warns about. Case ⓪ of the new pin measures that refusal directly, which is what keeps every case after it non-vacuous.

So the seed now declares what it is — the deployment's own boot command provisioning its admin, the operator creation class — instead of inferring admission from a population it does not own. This moves no public door: nothing outside the process can stage the declaration, the only caller is hard-gated to NODE_ENV==='development', it names one address, it is cleared in a finally, and a caller killed between the two is cleaned up by a 60-second TTL. Case ④ pins that the stranger's /sign-up/email is refused SELF_REGISTRATION_CLOSED on exactly this population, before and after the seed runs.

The audience gate's own predicate is deliberately unchanged. It is a public self-registration carve-out, so the population it counts has to stay humans.

The second surface: bootstrap-status — measured, and folded in

Triage asked for this to be measured and folded in or declared out of scope with the reading. Measured, it is a real disagreement of its own rather than a look-alike: the handler counted sys_user rows with no filter at all, which made it the one call site out of step with the three isHumanUserRow consumers. On a database still carrying the legacy usr_system service row it answered hasOwner: true — the console withholding the first-run setup flow — while the admission gate and plugin-security's first-user detection both stood ready to admit and promote the first human.

It now asks AuthManager.hasBootstrapWindow(), the same question the admission gate answers, so the console can never offer a first-run creation the platform would refuse, nor withhold one it would allow. Cases ⑦–⑧ drive the real route. A no-engine composition (MSW/mock mode) still reads as bootstrapped, so the SPA falls through to its normal login flow exactly as before.

What is deliberately NOT in this PR, with the reading: whether that window should mean "no LOGIN" rather than "no human users". Flipping it is coupled to the admission gate's predicate — moving only bootstrap-status makes the console offer a setup flow the gate then refuses 403, and moving both admits a stranger's self-registration on a directory-seeded deployment. That is a posture ruling, not an implementation choice, so it is filed with its measurements and three costed options as #14349 rather than decided here.

Declared riders, both named on purpose

  • packages/cli/src/commands/dev.ts — the claim fenced packages/cli with one permitted exception: the sentence that states the gate predicate. Two lines carry that one statement and both are corrected: the --seed-admin flag description (the declared contract, user-visible) and the in-code comment restating it above const seedAdmin. Nothing else in that package is touched — notably not the "then promote it to platform admin" clause, which is a separate claim and a separate finding (below).
  • scripts/check-route-envelope.mjs — the ruled exemption for auth-plugin.ts drops 3 → 2. The third unenveloped body was the handler's no-engine early return, which existed only because the handler resolved the data engine itself; asking the probe (which reports "no window" without an engine) folds that branch into the ordinary answer. Shrinking a ruled boundary needs nobody's leave; widening is the maintainer-only direction. The gate's own --self-test runs ahead of it and passes.

Interplay with #14000 (not a dependency)

#14000's ruled remedy asserts that the seeded admin can sign in on a fresh install. That assertion is sound for the current scaffold, which seeds no users, and this change is what makes it robust if the scaffold ever gains a person seed. Neither blocks the other and no Blocked-by: is implied in either direction.

Verification

All of the following ran on c89600f4, with a clean working tree, through the shared verify lock.

whatresult
pnpm --filter @objectstack/plugin-auth exec vitest run88 files, 1816 tests, all passed
pnpm --filter @objectstack/plugin-auth typecheckpassed (after building the package's own dist — the examples program imports it by name)
the new pin alone13 tests passed
pnpm exec eslint --no-inline-config .5674 files, 0 errors, 0 warnings — the whole population, no narrowing
derived gate families (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, re-derived after the gate-script edit: 49 commands)all measured ones green, including check:route-envelope, check:engine-double-contract, check:where-matcher, check:test-source-alias, check:type-check-coverage, check:dispatcher-error-vocabulary, check:auth-mount-ledger, check:nul-bytes

Not measured locally, each for a stated reason, and each of them something CI runs anyway: check:test-completeness (exit 3, PREREQUISITE NOT MET by design — it grades a saved turbo test log and none was passed); check:i18n and check:i18n-coverage (PREREQUISITE NOT MET — they run the built CLI and its whole build closure); check:type-check-debt --re-measure and check:dual-build-cjs-loads (need the full workspace build); check:pm-dispatch-gates and check-half-states (both exceeded the container's foreground cap — timed out at 150s and 380s, so NOT MEASURED rather than red).

For the debt ledger specifically, the risk it covers was measured directly instead: plugin-auth's tsconfig.json excludes **/*.test.ts, so the package typecheck says nothing about a new test file. Compiling the new test file plus the changed sources through a temporary config with that exclusion lifted exits 0, and --listFiles confirms both new files were actually in the program — so the ledgered count cannot drift up because of this diff.

Ablation — predicted RED, both legs, and both went red

Predicted before the run. No rebuild leg is owed: the pin imports its subjects relatively (./auth-plugin.js, ./auth-manager.js), so vitest resolves them to this package's src/, never through the package exports to dist/ — and a leg that had stayed green would itself have been the dist-resolution signature. Each leg proved the mutation reached disk (anchor-count before/after plus a git hash-object change, refusing to run the measurement otherwise) and proved the restore (git checkout HEAD --, then blob hash equal to the HEAD blob and git diff HEAD empty). The whole script carried trap restore EXIT INT TERM.

  • baseline, unmutated: 13 passed
  • leg A1 — the gate reverted to humans.length > 0, ticket left in place: 6 of 13 failed (①, ①b, ②, ②b, ④, ⑤b)
  • leg A2 — the ticket's two read sites replaced by false, gate left in place: 4 of 13 failed (①, ①b, ②, ④)

A2 is the one worth reading: it is the measurement that this PR's second half is load-bearing, not decoration.

Out-of-scope findings, filed unassigned (searched first; a positive control confirmed the search channel was answering)

Adjacent but not a duplicate of #14348: #11979 is about config-anchoring the single posture, not about which row it selects.

Contract review (Clause ②)

Re-declared yes on the card after review: this diff widens a public surface, so the @objectstack/plugin-auth changeset is minor, not patch. The exported-symbol delta, split by whether it actually reaches a consumer:

On the package barrelsrc/index.ts carries export * from './auth-manager.js', so every public member of the exported AuthManager class is public API. Four are new:

new public memberwhat it is
stageOperatorProvisioning(email: string): voiddeclares that the deployment's own boot command is provisioning this address right now
clearOperatorProvisioning(email: string): voiddrops that declaration
isOperatorProvisioning(email: unknown): booleana peek, not a consume — both admission seams ask about the same single creation
hasBootstrapWindow() — async, answers a booleanpublic read of the same bootstrap-window question the admission gate answers

A fifth member added in the same hunk, prunePendingOperatorProvisioning(), is private and is not part of the surface.

Also on this side, and the reason "minor" is not only about added symbols: GET /api/v1/auth/bootstrap-statuschanges its published answer. On a database whose only row is the legacy usr_system service account it answered hasOwner: true and now answers false. Same field, same type, same route, different verdict on that population — a consumer-visible behaviour change, which is exactly what the level is for.

Module-only, not on the barrelsrc/dev-admin-seed-gate.ts is imported relatively by auth-plugin.ts and is not re-exported from src/index.ts, so these four are internal to the package and carry no compatibility promise:

  • export interface DevAdminSeedProbeEngine
  • export type DevAdminSeedGateVerdict
  • export const CREDENTIAL_PROVIDER_ID
  • export async function decideDevAdminSeedGate

Changeset levels: @objectstack/plugin-authminor · @objectstack/clipatch (flag description text and a comment only — no exported symbol and no behaviour of its own). Nothing here is breaking: every addition is additive, no export is removed or narrowed, and no ADR-0087 disposition is owed.

Adjacent mechanical fix

The census anchor in content/docs/permissions/system-context.mdx moved auth-plugin.ts:1296 -> auth-plugin.ts:1301, because this PR inserted lines above that read site. Gate family: check-system-context-census (lint.yml). Pure line rot, not a population change — written by the gate's own --fix, which re-anchored one row and refused nothing; git diff --stat for that commit is one file, one line. Verdict after: check-system-context-census: OK — 109 elevation read sites in 20 packages across 45 files, all anchored; 145 anchors resolve, 27 declared non-read, and its --self-test passes.

That file is outside this claim's original file surface, so it is declared here and the claim comment on #14157 is amended to include it.

Worth recording for whoever derives gates next: this family is not derivable from the change that breaks it. It declares 29 path literals — its own anchor page plus specific packages/spec files — and packages/plugins/plugin-auth/src/auth-plugin.ts is not among them, so a code-only change set scores it silent (the derivation's own weakest verdict, explicitly "not a clearance"). It appears in the derived list only once the anchor page itself is in the change set — i.e. after the repair, never before it. Its real trigger is any source line movement under one of 145 anchors across 45 files, which no path literal can enumerate.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8

Generated by Claude Code


Generated by Claude Code


Generated by Claude Code

@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cli, @objectstack/plugin-auth, touching 21 documentable anchor(s).

28 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json d62f990a985eb15152579ad34c99f43e76e3657a.

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

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)
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 29 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 d62f990a985eb15152579ad34c99f43e76e3657apackageMentionDocs.

Which tree this was computed on

This run read content/docs from d99eee1c39c61aa4ae5a5f320934bcb5530c4875 — the merge of head 31fd3968cf0c0aadc5f75575bbbb411541b8bf53 into base d62f990a985eb15152579ad34c99f43e76e3657a, 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 d99eee1c39c61aa4ae5a5f320934bcb5530c4875 && git checkout d99eee1c39c61aa4ae5a5f320934bcb5530c4875
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d62f990a985eb15152579ad34c99f43e76e3657a 31fd3968cf0c0aadc5f75575bbbb411541b8bf53 && git checkout -B drift-repro d62f990a985eb15152579ad34c99f43e76e3657a && git merge --no-ff 31fd3968cf0c0aadc5f75575bbbb411541b8bf53
node scripts/docs-audit/affected-docs.mjs --json d62f990a985eb15152579ad34c99f43e76e3657a

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

@os-salesClaude

Copy link
Copy Markdown
Collaborator

CI status note (PM seat, session_01AUF1NoViznQK32gqpK8wS8): Lint & Repo Gates is red on 04fcb96a at one gate, check-system-context-census — a pure anchor shift: content/docs/permissions/system-context.mdx cites packages/plugins/plugin-auth/src/auth-plugin.ts:1296, and this PR's insertions moved the opCtx.context.isSystem read to :1301. It is this PR's to fix; the fix is in flight (the delivering dev runs the gate's own --fix and pushes one docs-anchor commit, declared on the body as an adjacent mechanical fix). No re-run is owed — the failure is deterministic on this head. The Clause-② PASS recorded on #14157 covers the contract increment; the seat verifies the next head differs from 04fcb96a by that one docs file only before marking ready.


Generated by Claude Code

@os-salesClaude

Copy link
Copy Markdown
Collaborator

Landing provenance (PM seat, domain:services, session_01AUF1NoViznQK32gqpK8wS8)

  • In-seat Clause-② contract review: PASS on the card — maybeSeedDevAdmin gates on "any sys_user row", but runs at kernel:ready — so any app that seeds people permanently loses the objectstack dev login #14157 (comment) — an isolated fable second opinion, transcript-verified (55 of 55 harness stamps at tier), adopted verbatim; needs:contract-review cleared on both carriers in that stroke (maintainer 2026-08-31: a PASS is cleared and landed by the dispatching seat; check-clause2-carriers --pair 14352 exit 0).
  • Head 31fd3968 = the reviewed 04fcb96a plus one docs-anchor line (content/docs/permissions/system-context.mdx, auth-plugin.ts:1296:1301), verified by git diff --stat; the contract increment is unchanged.
  • Landing checks on 31fd3968: every check green — Lint & Repo Gatessuccess 03:43:36Z (the check-system-context-census red on the previous head is repaired), Test Core (1/6)success 03:49:39Z, all other shards, type checks, Dogfood, Temporal Conformance, guards and changeset checks success; the remainder skipped by path filter. Closing keyword: Fixes #14157 is the only one in the body.
  • Marked ready for review; auto-merge armed for the merge queue in the same stroke.

Generated by Claude Code

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.

maybeSeedDevAdmin gates on "any sys_user row", but runs at kernel:ready — so any app that seeds people permanently loses the objectstack dev login

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-auth): gate the dev-admin seed on a LOGIN, not on user rows, and admit its own provisioning call - #14352

Merged
os-sales merged 4 commits into
mainfrom
claude/issue-14157-dev-admin-seed-credential-gate
Sep 2, 2026
Merged

fix(plugin-auth): gate the dev-admin seed on a LOGIN, not on user rows, and admit its own provisioning call#14352
os-sales merged 4 commits into
mainfrom
claude/issue-14157-dev-admin-seed-credential-gate

Conversation

@claude

@claudeclaudeBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes#14157

objectstack dev declares, in its own --help, a known loginable dev admin. The gate that implemented it asked a different question — does any human sys_user row exist? — and those two are the same question only while every user row carries a credential. An app that declares people in defineStack({ data }) breaks that: the declarative seed is awaited inside AppPlugin.start(), so it always lands before the seed's own kernel:ready hook, the database is non-zero-user before the check runs, and the admin is never minted — on that boot or on any later one, because the rows survive. The deployment ends up with no loginable account at all.

A seeded person is a directory row with no account. It is not a login, and treating it as one is the defect.

The fix is TWO halves, and the second one is what makes the first work

Half 1 — the gate (packages/plugins/plugin-auth/src/dev-admin-seed-gate.ts, new; owns the predicate and the argument). The seed acts while no account holds the configured seed address AND no local password login (sys_account.provider_id = 'credential') exists anywhere. Those are the card's own two suggested spellings, both used, because each guards a case the other does not: the first is the never-overwrite half (and it now covers a federated account on that address too), the second is "the environment already has the thing the seed provides". A credential store that cannot be read is its own verdict — unanswerable — and the seed declines and says so rather than minting a known-credential admin into an environment it could not see.

Half 2 — admission for the seed's own provisioning call (AuthManager.stageOperatorProvisioning). This is not a nicety. The seed provisions through better-auth's real signUpEmail, and that call has to be admitted; its admission used to ride on the audience gate's bootstrap bypass, "zero HUMAN users" — which the same 13 seeded rows also answer "populated". Measured: with 13 seeded people and zero accounts, under the default invite_only posture, the seed's own lane comes back SELF_REGISTRATION_CLOSED. A fix that moved only the gate would therefore have produced a seed that decides to run and a gate that then refuses it — the exact drift isHumanUserRow's own doc warns about. Case ⓪ of the new pin measures that refusal directly, which is what keeps every case after it non-vacuous.

So the seed now declares what it is — the deployment's own boot command provisioning its admin, the operator creation class — instead of inferring admission from a population it does not own. This moves no public door: nothing outside the process can stage the declaration, the only caller is hard-gated to NODE_ENV==='development', it names one address, it is cleared in a finally, and a caller killed between the two is cleaned up by a 60-second TTL. Case ④ pins that the stranger's /sign-up/email is refused SELF_REGISTRATION_CLOSED on exactly this population, before and after the seed runs.

The audience gate's own predicate is deliberately unchanged. It is a public self-registration carve-out, so the population it counts has to stay humans.

The second surface: bootstrap-status — measured, and folded in

Triage asked for this to be measured and folded in or declared out of scope with the reading. Measured, it is a real disagreement of its own rather than a look-alike: the handler counted sys_user rows with no filter at all, which made it the one call site out of step with the three isHumanUserRow consumers. On a database still carrying the legacy usr_system service row it answered hasOwner: true — the console withholding the first-run setup flow — while the admission gate and plugin-security's first-user detection both stood ready to admit and promote the first human.

It now asks AuthManager.hasBootstrapWindow(), the same question the admission gate answers, so the console can never offer a first-run creation the platform would refuse, nor withhold one it would allow. Cases ⑦–⑧ drive the real route. A no-engine composition (MSW/mock mode) still reads as bootstrapped, so the SPA falls through to its normal login flow exactly as before.

What is deliberately NOT in this PR, with the reading: whether that window should mean "no LOGIN" rather than "no human users". Flipping it is coupled to the admission gate's predicate — moving only bootstrap-status makes the console offer a setup flow the gate then refuses 403, and moving both admits a stranger's self-registration on a directory-seeded deployment. That is a posture ruling, not an implementation choice, so it is filed with its measurements and three costed options as #14349 rather than decided here.

Declared riders, both named on purpose

  • packages/cli/src/commands/dev.ts — the claim fenced packages/cli with one permitted exception: the sentence that states the gate predicate. Two lines carry that one statement and both are corrected: the --seed-admin flag description (the declared contract, user-visible) and the in-code comment restating it above const seedAdmin. Nothing else in that package is touched — notably not the "then promote it to platform admin" clause, which is a separate claim and a separate finding (below).
  • scripts/check-route-envelope.mjs — the ruled exemption for auth-plugin.ts drops 3 → 2. The third unenveloped body was the handler's no-engine early return, which existed only because the handler resolved the data engine itself; asking the probe (which reports "no window" without an engine) folds that branch into the ordinary answer. Shrinking a ruled boundary needs nobody's leave; widening is the maintainer-only direction. The gate's own --self-test runs ahead of it and passes.

Interplay with #14000 (not a dependency)

#14000's ruled remedy asserts that the seeded admin can sign in on a fresh install. That assertion is sound for the current scaffold, which seeds no users, and this change is what makes it robust if the scaffold ever gains a person seed. Neither blocks the other and no Blocked-by: is implied in either direction.

Verification

All of the following ran on c89600f4, with a clean working tree, through the shared verify lock.

whatresult
pnpm --filter @objectstack/plugin-auth exec vitest run88 files, 1816 tests, all passed
pnpm --filter @objectstack/plugin-auth typecheckpassed (after building the package's own dist — the examples program imports it by name)
the new pin alone13 tests passed
pnpm exec eslint --no-inline-config .5674 files, 0 errors, 0 warnings — the whole population, no narrowing
derived gate families (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, re-derived after the gate-script edit: 49 commands)all measured ones green, including check:route-envelope, check:engine-double-contract, check:where-matcher, check:test-source-alias, check:type-check-coverage, check:dispatcher-error-vocabulary, check:auth-mount-ledger, check:nul-bytes

Not measured locally, each for a stated reason, and each of them something CI runs anyway: check:test-completeness (exit 3, PREREQUISITE NOT MET by design — it grades a saved turbo test log and none was passed); check:i18n and check:i18n-coverage (PREREQUISITE NOT MET — they run the built CLI and its whole build closure); check:type-check-debt --re-measure and check:dual-build-cjs-loads (need the full workspace build); check:pm-dispatch-gates and check-half-states (both exceeded the container's foreground cap — timed out at 150s and 380s, so NOT MEASURED rather than red).

For the debt ledger specifically, the risk it covers was measured directly instead: plugin-auth's tsconfig.json excludes **/*.test.ts, so the package typecheck says nothing about a new test file. Compiling the new test file plus the changed sources through a temporary config with that exclusion lifted exits 0, and --listFiles confirms both new files were actually in the program — so the ledgered count cannot drift up because of this diff.

Ablation — predicted RED, both legs, and both went red

Predicted before the run. No rebuild leg is owed: the pin imports its subjects relatively (./auth-plugin.js, ./auth-manager.js), so vitest resolves them to this package's src/, never through the package exports to dist/ — and a leg that had stayed green would itself have been the dist-resolution signature. Each leg proved the mutation reached disk (anchor-count before/after plus a git hash-object change, refusing to run the measurement otherwise) and proved the restore (git checkout HEAD --, then blob hash equal to the HEAD blob and git diff HEAD empty). The whole script carried trap restore EXIT INT TERM.

  • baseline, unmutated: 13 passed
  • leg A1 — the gate reverted to humans.length > 0, ticket left in place: 6 of 13 failed (①, ①b, ②, ②b, ④, ⑤b)
  • leg A2 — the ticket's two read sites replaced by false, gate left in place: 4 of 13 failed (①, ①b, ②, ④)

A2 is the one worth reading: it is the measurement that this PR's second half is load-bearing, not decoration.

Out-of-scope findings, filed unassigned (searched first; a positive control confirmed the search channel was answering)

Adjacent but not a duplicate of #14348: #11979 is about config-anchoring the single posture, not about which row it selects.

Contract review (Clause ②)

Re-declared yes on the card after review: this diff widens a public surface, so the @objectstack/plugin-auth changeset is minor, not patch. The exported-symbol delta, split by whether it actually reaches a consumer:

On the package barrelsrc/index.ts carries export * from './auth-manager.js', so every public member of the exported AuthManager class is public API. Four are new:

new public memberwhat it is
stageOperatorProvisioning(email: string): voiddeclares that the deployment's own boot command is provisioning this address right now
clearOperatorProvisioning(email: string): voiddrops that declaration
isOperatorProvisioning(email: unknown): booleana peek, not a consume — both admission seams ask about the same single creation
hasBootstrapWindow() — async, answers a booleanpublic read of the same bootstrap-window question the admission gate answers

A fifth member added in the same hunk, prunePendingOperatorProvisioning(), is private and is not part of the surface.

Also on this side, and the reason "minor" is not only about added symbols: GET /api/v1/auth/bootstrap-statuschanges its published answer. On a database whose only row is the legacy usr_system service account it answered hasOwner: true and now answers false. Same field, same type, same route, different verdict on that population — a consumer-visible behaviour change, which is exactly what the level is for.

Module-only, not on the barrelsrc/dev-admin-seed-gate.ts is imported relatively by auth-plugin.ts and is not re-exported from src/index.ts, so these four are internal to the package and carry no compatibility promise:

  • export interface DevAdminSeedProbeEngine
  • export type DevAdminSeedGateVerdict
  • export const CREDENTIAL_PROVIDER_ID
  • export async function decideDevAdminSeedGate

Changeset levels: @objectstack/plugin-authminor · @objectstack/clipatch (flag description text and a comment only — no exported symbol and no behaviour of its own). Nothing here is breaking: every addition is additive, no export is removed or narrowed, and no ADR-0087 disposition is owed.

Adjacent mechanical fix

The census anchor in content/docs/permissions/system-context.mdx moved auth-plugin.ts:1296 -> auth-plugin.ts:1301, because this PR inserted lines above that read site. Gate family: check-system-context-census (lint.yml). Pure line rot, not a population change — written by the gate's own --fix, which re-anchored one row and refused nothing; git diff --stat for that commit is one file, one line. Verdict after: check-system-context-census: OK — 109 elevation read sites in 20 packages across 45 files, all anchored; 145 anchors resolve, 27 declared non-read, and its --self-test passes.

That file is outside this claim's original file surface, so it is declared here and the claim comment on #14157 is amended to include it.

Worth recording for whoever derives gates next: this family is not derivable from the change that breaks it. It declares 29 path literals — its own anchor page plus specific packages/spec files — and packages/plugins/plugin-auth/src/auth-plugin.ts is not among them, so a code-only change set scores it silent (the derivation's own weakest verdict, explicitly "not a clearance"). It appears in the derived list only once the anchor page itself is in the change set — i.e. after the repair, never before it. Its real trigger is any source line movement under one of 145 anchors across 45 files, which no path literal can enumerate.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8

Generated by Claude Code


Generated by Claude Code


Generated by Claude Code

@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cli, @objectstack/plugin-auth, touching 21 documentable anchor(s).

28 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json d62f990a985eb15152579ad34c99f43e76e3657a.

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

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)
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 29 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 d62f990a985eb15152579ad34c99f43e76e3657apackageMentionDocs.

Which tree this was computed on

This run read content/docs from d99eee1c39c61aa4ae5a5f320934bcb5530c4875 — the merge of head 31fd3968cf0c0aadc5f75575bbbb411541b8bf53 into base d62f990a985eb15152579ad34c99f43e76e3657a, 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 d99eee1c39c61aa4ae5a5f320934bcb5530c4875 && git checkout d99eee1c39c61aa4ae5a5f320934bcb5530c4875
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d62f990a985eb15152579ad34c99f43e76e3657a 31fd3968cf0c0aadc5f75575bbbb411541b8bf53 && git checkout -B drift-repro d62f990a985eb15152579ad34c99f43e76e3657a && git merge --no-ff 31fd3968cf0c0aadc5f75575bbbb411541b8bf53
node scripts/docs-audit/affected-docs.mjs --json d62f990a985eb15152579ad34c99f43e76e3657a

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

@os-salesClaude

Copy link
Copy Markdown
Collaborator

CI status note (PM seat, session_01AUF1NoViznQK32gqpK8wS8): Lint & Repo Gates is red on 04fcb96a at one gate, check-system-context-census — a pure anchor shift: content/docs/permissions/system-context.mdx cites packages/plugins/plugin-auth/src/auth-plugin.ts:1296, and this PR's insertions moved the opCtx.context.isSystem read to :1301. It is this PR's to fix; the fix is in flight (the delivering dev runs the gate's own --fix and pushes one docs-anchor commit, declared on the body as an adjacent mechanical fix). No re-run is owed — the failure is deterministic on this head. The Clause-② PASS recorded on #14157 covers the contract increment; the seat verifies the next head differs from 04fcb96a by that one docs file only before marking ready.


Generated by Claude Code

@os-salesClaude

Copy link
Copy Markdown
Collaborator

Landing provenance (PM seat, domain:services, session_01AUF1NoViznQK32gqpK8wS8)

  • In-seat Clause-② contract review: PASS on the card — maybeSeedDevAdmin gates on "any sys_user row", but runs at kernel:ready — so any app that seeds people permanently loses the objectstack dev login #14157 (comment) — an isolated fable second opinion, transcript-verified (55 of 55 harness stamps at tier), adopted verbatim; needs:contract-review cleared on both carriers in that stroke (maintainer 2026-08-31: a PASS is cleared and landed by the dispatching seat; check-clause2-carriers --pair 14352 exit 0).
  • Head 31fd3968 = the reviewed 04fcb96a plus one docs-anchor line (content/docs/permissions/system-context.mdx, auth-plugin.ts:1296:1301), verified by git diff --stat; the contract increment is unchanged.
  • Landing checks on 31fd3968: every check green — Lint & Repo Gatessuccess 03:43:36Z (the check-system-context-census red on the previous head is repaired), Test Core (1/6)success 03:49:39Z, all other shards, type checks, Dogfood, Temporal Conformance, guards and changeset checks success; the remainder skipped by path filter. Closing keyword: Fixes #14157 is the only one in the body.
  • Marked ready for review; auto-merge armed for the merge queue in the same stroke.

Generated by Claude Code

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.

maybeSeedDevAdmin gates on "any sys_user row", but runs at kernel:ready — so any app that seeds people permanently loses the objectstack dev login

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-auth): gate the dev-admin seed on a LOGIN, not on user rows, and admit its own provisioning call - #14352

Merged
os-sales merged 4 commits into
mainfrom
claude/issue-14157-dev-admin-seed-credential-gate
Sep 2, 2026
Merged

fix(plugin-auth): gate the dev-admin seed on a LOGIN, not on user rows, and admit its own provisioning call#14352
os-sales merged 4 commits into
mainfrom
claude/issue-14157-dev-admin-seed-credential-gate

Conversation

@claude

@claudeclaudeBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes#14157

objectstack dev declares, in its own --help, a known loginable dev admin. The gate that implemented it asked a different question — does any human sys_user row exist? — and those two are the same question only while every user row carries a credential. An app that declares people in defineStack({ data }) breaks that: the declarative seed is awaited inside AppPlugin.start(), so it always lands before the seed's own kernel:ready hook, the database is non-zero-user before the check runs, and the admin is never minted — on that boot or on any later one, because the rows survive. The deployment ends up with no loginable account at all.

A seeded person is a directory row with no account. It is not a login, and treating it as one is the defect.

The fix is TWO halves, and the second one is what makes the first work

Half 1 — the gate (packages/plugins/plugin-auth/src/dev-admin-seed-gate.ts, new; owns the predicate and the argument). The seed acts while no account holds the configured seed address AND no local password login (sys_account.provider_id = 'credential') exists anywhere. Those are the card's own two suggested spellings, both used, because each guards a case the other does not: the first is the never-overwrite half (and it now covers a federated account on that address too), the second is "the environment already has the thing the seed provides". A credential store that cannot be read is its own verdict — unanswerable — and the seed declines and says so rather than minting a known-credential admin into an environment it could not see.

Half 2 — admission for the seed's own provisioning call (AuthManager.stageOperatorProvisioning). This is not a nicety. The seed provisions through better-auth's real signUpEmail, and that call has to be admitted; its admission used to ride on the audience gate's bootstrap bypass, "zero HUMAN users" — which the same 13 seeded rows also answer "populated". Measured: with 13 seeded people and zero accounts, under the default invite_only posture, the seed's own lane comes back SELF_REGISTRATION_CLOSED. A fix that moved only the gate would therefore have produced a seed that decides to run and a gate that then refuses it — the exact drift isHumanUserRow's own doc warns about. Case ⓪ of the new pin measures that refusal directly, which is what keeps every case after it non-vacuous.

So the seed now declares what it is — the deployment's own boot command provisioning its admin, the operator creation class — instead of inferring admission from a population it does not own. This moves no public door: nothing outside the process can stage the declaration, the only caller is hard-gated to NODE_ENV==='development', it names one address, it is cleared in a finally, and a caller killed between the two is cleaned up by a 60-second TTL. Case ④ pins that the stranger's /sign-up/email is refused SELF_REGISTRATION_CLOSED on exactly this population, before and after the seed runs.

The audience gate's own predicate is deliberately unchanged. It is a public self-registration carve-out, so the population it counts has to stay humans.

The second surface: bootstrap-status — measured, and folded in

Triage asked for this to be measured and folded in or declared out of scope with the reading. Measured, it is a real disagreement of its own rather than a look-alike: the handler counted sys_user rows with no filter at all, which made it the one call site out of step with the three isHumanUserRow consumers. On a database still carrying the legacy usr_system service row it answered hasOwner: true — the console withholding the first-run setup flow — while the admission gate and plugin-security's first-user detection both stood ready to admit and promote the first human.

It now asks AuthManager.hasBootstrapWindow(), the same question the admission gate answers, so the console can never offer a first-run creation the platform would refuse, nor withhold one it would allow. Cases ⑦–⑧ drive the real route. A no-engine composition (MSW/mock mode) still reads as bootstrapped, so the SPA falls through to its normal login flow exactly as before.

What is deliberately NOT in this PR, with the reading: whether that window should mean "no LOGIN" rather than "no human users". Flipping it is coupled to the admission gate's predicate — moving only bootstrap-status makes the console offer a setup flow the gate then refuses 403, and moving both admits a stranger's self-registration on a directory-seeded deployment. That is a posture ruling, not an implementation choice, so it is filed with its measurements and three costed options as #14349 rather than decided here.

Declared riders, both named on purpose

  • packages/cli/src/commands/dev.ts — the claim fenced packages/cli with one permitted exception: the sentence that states the gate predicate. Two lines carry that one statement and both are corrected: the --seed-admin flag description (the declared contract, user-visible) and the in-code comment restating it above const seedAdmin. Nothing else in that package is touched — notably not the "then promote it to platform admin" clause, which is a separate claim and a separate finding (below).
  • scripts/check-route-envelope.mjs — the ruled exemption for auth-plugin.ts drops 3 → 2. The third unenveloped body was the handler's no-engine early return, which existed only because the handler resolved the data engine itself; asking the probe (which reports "no window" without an engine) folds that branch into the ordinary answer. Shrinking a ruled boundary needs nobody's leave; widening is the maintainer-only direction. The gate's own --self-test runs ahead of it and passes.

Interplay with #14000 (not a dependency)

#14000's ruled remedy asserts that the seeded admin can sign in on a fresh install. That assertion is sound for the current scaffold, which seeds no users, and this change is what makes it robust if the scaffold ever gains a person seed. Neither blocks the other and no Blocked-by: is implied in either direction.

Verification

All of the following ran on c89600f4, with a clean working tree, through the shared verify lock.

whatresult
pnpm --filter @objectstack/plugin-auth exec vitest run88 files, 1816 tests, all passed
pnpm --filter @objectstack/plugin-auth typecheckpassed (after building the package's own dist — the examples program imports it by name)
the new pin alone13 tests passed
pnpm exec eslint --no-inline-config .5674 files, 0 errors, 0 warnings — the whole population, no narrowing
derived gate families (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, re-derived after the gate-script edit: 49 commands)all measured ones green, including check:route-envelope, check:engine-double-contract, check:where-matcher, check:test-source-alias, check:type-check-coverage, check:dispatcher-error-vocabulary, check:auth-mount-ledger, check:nul-bytes

Not measured locally, each for a stated reason, and each of them something CI runs anyway: check:test-completeness (exit 3, PREREQUISITE NOT MET by design — it grades a saved turbo test log and none was passed); check:i18n and check:i18n-coverage (PREREQUISITE NOT MET — they run the built CLI and its whole build closure); check:type-check-debt --re-measure and check:dual-build-cjs-loads (need the full workspace build); check:pm-dispatch-gates and check-half-states (both exceeded the container's foreground cap — timed out at 150s and 380s, so NOT MEASURED rather than red).

For the debt ledger specifically, the risk it covers was measured directly instead: plugin-auth's tsconfig.json excludes **/*.test.ts, so the package typecheck says nothing about a new test file. Compiling the new test file plus the changed sources through a temporary config with that exclusion lifted exits 0, and --listFiles confirms both new files were actually in the program — so the ledgered count cannot drift up because of this diff.

Ablation — predicted RED, both legs, and both went red

Predicted before the run. No rebuild leg is owed: the pin imports its subjects relatively (./auth-plugin.js, ./auth-manager.js), so vitest resolves them to this package's src/, never through the package exports to dist/ — and a leg that had stayed green would itself have been the dist-resolution signature. Each leg proved the mutation reached disk (anchor-count before/after plus a git hash-object change, refusing to run the measurement otherwise) and proved the restore (git checkout HEAD --, then blob hash equal to the HEAD blob and git diff HEAD empty). The whole script carried trap restore EXIT INT TERM.

  • baseline, unmutated: 13 passed
  • leg A1 — the gate reverted to humans.length > 0, ticket left in place: 6 of 13 failed (①, ①b, ②, ②b, ④, ⑤b)
  • leg A2 — the ticket's two read sites replaced by false, gate left in place: 4 of 13 failed (①, ①b, ②, ④)

A2 is the one worth reading: it is the measurement that this PR's second half is load-bearing, not decoration.

Out-of-scope findings, filed unassigned (searched first; a positive control confirmed the search channel was answering)

Adjacent but not a duplicate of #14348: #11979 is about config-anchoring the single posture, not about which row it selects.

Contract review (Clause ②)

Re-declared yes on the card after review: this diff widens a public surface, so the @objectstack/plugin-auth changeset is minor, not patch. The exported-symbol delta, split by whether it actually reaches a consumer:

On the package barrelsrc/index.ts carries export * from './auth-manager.js', so every public member of the exported AuthManager class is public API. Four are new:

new public memberwhat it is
stageOperatorProvisioning(email: string): voiddeclares that the deployment's own boot command is provisioning this address right now
clearOperatorProvisioning(email: string): voiddrops that declaration
isOperatorProvisioning(email: unknown): booleana peek, not a consume — both admission seams ask about the same single creation
hasBootstrapWindow() — async, answers a booleanpublic read of the same bootstrap-window question the admission gate answers

A fifth member added in the same hunk, prunePendingOperatorProvisioning(), is private and is not part of the surface.

Also on this side, and the reason "minor" is not only about added symbols: GET /api/v1/auth/bootstrap-statuschanges its published answer. On a database whose only row is the legacy usr_system service account it answered hasOwner: true and now answers false. Same field, same type, same route, different verdict on that population — a consumer-visible behaviour change, which is exactly what the level is for.

Module-only, not on the barrelsrc/dev-admin-seed-gate.ts is imported relatively by auth-plugin.ts and is not re-exported from src/index.ts, so these four are internal to the package and carry no compatibility promise:

  • export interface DevAdminSeedProbeEngine
  • export type DevAdminSeedGateVerdict
  • export const CREDENTIAL_PROVIDER_ID
  • export async function decideDevAdminSeedGate

Changeset levels: @objectstack/plugin-authminor · @objectstack/clipatch (flag description text and a comment only — no exported symbol and no behaviour of its own). Nothing here is breaking: every addition is additive, no export is removed or narrowed, and no ADR-0087 disposition is owed.

Adjacent mechanical fix

The census anchor in content/docs/permissions/system-context.mdx moved auth-plugin.ts:1296 -> auth-plugin.ts:1301, because this PR inserted lines above that read site. Gate family: check-system-context-census (lint.yml). Pure line rot, not a population change — written by the gate's own --fix, which re-anchored one row and refused nothing; git diff --stat for that commit is one file, one line. Verdict after: check-system-context-census: OK — 109 elevation read sites in 20 packages across 45 files, all anchored; 145 anchors resolve, 27 declared non-read, and its --self-test passes.

That file is outside this claim's original file surface, so it is declared here and the claim comment on #14157 is amended to include it.

Worth recording for whoever derives gates next: this family is not derivable from the change that breaks it. It declares 29 path literals — its own anchor page plus specific packages/spec files — and packages/plugins/plugin-auth/src/auth-plugin.ts is not among them, so a code-only change set scores it silent (the derivation's own weakest verdict, explicitly "not a clearance"). It appears in the derived list only once the anchor page itself is in the change set — i.e. after the repair, never before it. Its real trigger is any source line movement under one of 145 anchors across 45 files, which no path literal can enumerate.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8

Generated by Claude Code


Generated by Claude Code


Generated by Claude Code

@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cli, @objectstack/plugin-auth, touching 21 documentable anchor(s).

28 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json d62f990a985eb15152579ad34c99f43e76e3657a.

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

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)
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 29 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 d62f990a985eb15152579ad34c99f43e76e3657apackageMentionDocs.

Which tree this was computed on

This run read content/docs from d99eee1c39c61aa4ae5a5f320934bcb5530c4875 — the merge of head 31fd3968cf0c0aadc5f75575bbbb411541b8bf53 into base d62f990a985eb15152579ad34c99f43e76e3657a, 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 d99eee1c39c61aa4ae5a5f320934bcb5530c4875 && git checkout d99eee1c39c61aa4ae5a5f320934bcb5530c4875
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d62f990a985eb15152579ad34c99f43e76e3657a 31fd3968cf0c0aadc5f75575bbbb411541b8bf53 && git checkout -B drift-repro d62f990a985eb15152579ad34c99f43e76e3657a && git merge --no-ff 31fd3968cf0c0aadc5f75575bbbb411541b8bf53
node scripts/docs-audit/affected-docs.mjs --json d62f990a985eb15152579ad34c99f43e76e3657a

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

@os-salesClaude

Copy link
Copy Markdown
Collaborator

CI status note (PM seat, session_01AUF1NoViznQK32gqpK8wS8): Lint & Repo Gates is red on 04fcb96a at one gate, check-system-context-census — a pure anchor shift: content/docs/permissions/system-context.mdx cites packages/plugins/plugin-auth/src/auth-plugin.ts:1296, and this PR's insertions moved the opCtx.context.isSystem read to :1301. It is this PR's to fix; the fix is in flight (the delivering dev runs the gate's own --fix and pushes one docs-anchor commit, declared on the body as an adjacent mechanical fix). No re-run is owed — the failure is deterministic on this head. The Clause-② PASS recorded on #14157 covers the contract increment; the seat verifies the next head differs from 04fcb96a by that one docs file only before marking ready.


Generated by Claude Code

@os-salesClaude

Copy link
Copy Markdown
Collaborator

Landing provenance (PM seat, domain:services, session_01AUF1NoViznQK32gqpK8wS8)

  • In-seat Clause-② contract review: PASS on the card — maybeSeedDevAdmin gates on "any sys_user row", but runs at kernel:ready — so any app that seeds people permanently loses the objectstack dev login #14157 (comment) — an isolated fable second opinion, transcript-verified (55 of 55 harness stamps at tier), adopted verbatim; needs:contract-review cleared on both carriers in that stroke (maintainer 2026-08-31: a PASS is cleared and landed by the dispatching seat; check-clause2-carriers --pair 14352 exit 0).
  • Head 31fd3968 = the reviewed 04fcb96a plus one docs-anchor line (content/docs/permissions/system-context.mdx, auth-plugin.ts:1296:1301), verified by git diff --stat; the contract increment is unchanged.
  • Landing checks on 31fd3968: every check green — Lint & Repo Gatessuccess 03:43:36Z (the check-system-context-census red on the previous head is repaired), Test Core (1/6)success 03:49:39Z, all other shards, type checks, Dogfood, Temporal Conformance, guards and changeset checks success; the remainder skipped by path filter. Closing keyword: Fixes #14157 is the only one in the body.
  • Marked ready for review; auto-merge armed for the merge queue in the same stroke.

Generated by Claude Code

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.

maybeSeedDevAdmin gates on "any sys_user row", but runs at kernel:ready — so any app that seeds people permanently loses the objectstack dev login

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-auth): gate the dev-admin seed on a LOGIN, not on user rows, and admit its own provisioning call - #14352

Merged
os-sales merged 4 commits into
mainfrom
claude/issue-14157-dev-admin-seed-credential-gate
Sep 2, 2026
Merged

fix(plugin-auth): gate the dev-admin seed on a LOGIN, not on user rows, and admit its own provisioning call#14352
os-sales merged 4 commits into
mainfrom
claude/issue-14157-dev-admin-seed-credential-gate

Conversation

@claude

@claudeclaudeBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes#14157

objectstack dev declares, in its own --help, a known loginable dev admin. The gate that implemented it asked a different question — does any human sys_user row exist? — and those two are the same question only while every user row carries a credential. An app that declares people in defineStack({ data }) breaks that: the declarative seed is awaited inside AppPlugin.start(), so it always lands before the seed's own kernel:ready hook, the database is non-zero-user before the check runs, and the admin is never minted — on that boot or on any later one, because the rows survive. The deployment ends up with no loginable account at all.

A seeded person is a directory row with no account. It is not a login, and treating it as one is the defect.

The fix is TWO halves, and the second one is what makes the first work

Half 1 — the gate (packages/plugins/plugin-auth/src/dev-admin-seed-gate.ts, new; owns the predicate and the argument). The seed acts while no account holds the configured seed address AND no local password login (sys_account.provider_id = 'credential') exists anywhere. Those are the card's own two suggested spellings, both used, because each guards a case the other does not: the first is the never-overwrite half (and it now covers a federated account on that address too), the second is "the environment already has the thing the seed provides". A credential store that cannot be read is its own verdict — unanswerable — and the seed declines and says so rather than minting a known-credential admin into an environment it could not see.

Half 2 — admission for the seed's own provisioning call (AuthManager.stageOperatorProvisioning). This is not a nicety. The seed provisions through better-auth's real signUpEmail, and that call has to be admitted; its admission used to ride on the audience gate's bootstrap bypass, "zero HUMAN users" — which the same 13 seeded rows also answer "populated". Measured: with 13 seeded people and zero accounts, under the default invite_only posture, the seed's own lane comes back SELF_REGISTRATION_CLOSED. A fix that moved only the gate would therefore have produced a seed that decides to run and a gate that then refuses it — the exact drift isHumanUserRow's own doc warns about. Case ⓪ of the new pin measures that refusal directly, which is what keeps every case after it non-vacuous.

So the seed now declares what it is — the deployment's own boot command provisioning its admin, the operator creation class — instead of inferring admission from a population it does not own. This moves no public door: nothing outside the process can stage the declaration, the only caller is hard-gated to NODE_ENV==='development', it names one address, it is cleared in a finally, and a caller killed between the two is cleaned up by a 60-second TTL. Case ④ pins that the stranger's /sign-up/email is refused SELF_REGISTRATION_CLOSED on exactly this population, before and after the seed runs.

The audience gate's own predicate is deliberately unchanged. It is a public self-registration carve-out, so the population it counts has to stay humans.

The second surface: bootstrap-status — measured, and folded in

Triage asked for this to be measured and folded in or declared out of scope with the reading. Measured, it is a real disagreement of its own rather than a look-alike: the handler counted sys_user rows with no filter at all, which made it the one call site out of step with the three isHumanUserRow consumers. On a database still carrying the legacy usr_system service row it answered hasOwner: true — the console withholding the first-run setup flow — while the admission gate and plugin-security's first-user detection both stood ready to admit and promote the first human.

It now asks AuthManager.hasBootstrapWindow(), the same question the admission gate answers, so the console can never offer a first-run creation the platform would refuse, nor withhold one it would allow. Cases ⑦–⑧ drive the real route. A no-engine composition (MSW/mock mode) still reads as bootstrapped, so the SPA falls through to its normal login flow exactly as before.

What is deliberately NOT in this PR, with the reading: whether that window should mean "no LOGIN" rather than "no human users". Flipping it is coupled to the admission gate's predicate — moving only bootstrap-status makes the console offer a setup flow the gate then refuses 403, and moving both admits a stranger's self-registration on a directory-seeded deployment. That is a posture ruling, not an implementation choice, so it is filed with its measurements and three costed options as #14349 rather than decided here.

Declared riders, both named on purpose

  • packages/cli/src/commands/dev.ts — the claim fenced packages/cli with one permitted exception: the sentence that states the gate predicate. Two lines carry that one statement and both are corrected: the --seed-admin flag description (the declared contract, user-visible) and the in-code comment restating it above const seedAdmin. Nothing else in that package is touched — notably not the "then promote it to platform admin" clause, which is a separate claim and a separate finding (below).
  • scripts/check-route-envelope.mjs — the ruled exemption for auth-plugin.ts drops 3 → 2. The third unenveloped body was the handler's no-engine early return, which existed only because the handler resolved the data engine itself; asking the probe (which reports "no window" without an engine) folds that branch into the ordinary answer. Shrinking a ruled boundary needs nobody's leave; widening is the maintainer-only direction. The gate's own --self-test runs ahead of it and passes.

Interplay with #14000 (not a dependency)

#14000's ruled remedy asserts that the seeded admin can sign in on a fresh install. That assertion is sound for the current scaffold, which seeds no users, and this change is what makes it robust if the scaffold ever gains a person seed. Neither blocks the other and no Blocked-by: is implied in either direction.

Verification

All of the following ran on c89600f4, with a clean working tree, through the shared verify lock.

whatresult
pnpm --filter @objectstack/plugin-auth exec vitest run88 files, 1816 tests, all passed
pnpm --filter @objectstack/plugin-auth typecheckpassed (after building the package's own dist — the examples program imports it by name)
the new pin alone13 tests passed
pnpm exec eslint --no-inline-config .5674 files, 0 errors, 0 warnings — the whole population, no narrowing
derived gate families (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, re-derived after the gate-script edit: 49 commands)all measured ones green, including check:route-envelope, check:engine-double-contract, check:where-matcher, check:test-source-alias, check:type-check-coverage, check:dispatcher-error-vocabulary, check:auth-mount-ledger, check:nul-bytes

Not measured locally, each for a stated reason, and each of them something CI runs anyway: check:test-completeness (exit 3, PREREQUISITE NOT MET by design — it grades a saved turbo test log and none was passed); check:i18n and check:i18n-coverage (PREREQUISITE NOT MET — they run the built CLI and its whole build closure); check:type-check-debt --re-measure and check:dual-build-cjs-loads (need the full workspace build); check:pm-dispatch-gates and check-half-states (both exceeded the container's foreground cap — timed out at 150s and 380s, so NOT MEASURED rather than red).

For the debt ledger specifically, the risk it covers was measured directly instead: plugin-auth's tsconfig.json excludes **/*.test.ts, so the package typecheck says nothing about a new test file. Compiling the new test file plus the changed sources through a temporary config with that exclusion lifted exits 0, and --listFiles confirms both new files were actually in the program — so the ledgered count cannot drift up because of this diff.

Ablation — predicted RED, both legs, and both went red

Predicted before the run. No rebuild leg is owed: the pin imports its subjects relatively (./auth-plugin.js, ./auth-manager.js), so vitest resolves them to this package's src/, never through the package exports to dist/ — and a leg that had stayed green would itself have been the dist-resolution signature. Each leg proved the mutation reached disk (anchor-count before/after plus a git hash-object change, refusing to run the measurement otherwise) and proved the restore (git checkout HEAD --, then blob hash equal to the HEAD blob and git diff HEAD empty). The whole script carried trap restore EXIT INT TERM.

  • baseline, unmutated: 13 passed
  • leg A1 — the gate reverted to humans.length > 0, ticket left in place: 6 of 13 failed (①, ①b, ②, ②b, ④, ⑤b)
  • leg A2 — the ticket's two read sites replaced by false, gate left in place: 4 of 13 failed (①, ①b, ②, ④)

A2 is the one worth reading: it is the measurement that this PR's second half is load-bearing, not decoration.

Out-of-scope findings, filed unassigned (searched first; a positive control confirmed the search channel was answering)

Adjacent but not a duplicate of #14348: #11979 is about config-anchoring the single posture, not about which row it selects.

Contract review (Clause ②)

Re-declared yes on the card after review: this diff widens a public surface, so the @objectstack/plugin-auth changeset is minor, not patch. The exported-symbol delta, split by whether it actually reaches a consumer:

On the package barrelsrc/index.ts carries export * from './auth-manager.js', so every public member of the exported AuthManager class is public API. Four are new:

new public memberwhat it is
stageOperatorProvisioning(email: string): voiddeclares that the deployment's own boot command is provisioning this address right now
clearOperatorProvisioning(email: string): voiddrops that declaration
isOperatorProvisioning(email: unknown): booleana peek, not a consume — both admission seams ask about the same single creation
hasBootstrapWindow() — async, answers a booleanpublic read of the same bootstrap-window question the admission gate answers

A fifth member added in the same hunk, prunePendingOperatorProvisioning(), is private and is not part of the surface.

Also on this side, and the reason "minor" is not only about added symbols: GET /api/v1/auth/bootstrap-statuschanges its published answer. On a database whose only row is the legacy usr_system service account it answered hasOwner: true and now answers false. Same field, same type, same route, different verdict on that population — a consumer-visible behaviour change, which is exactly what the level is for.

Module-only, not on the barrelsrc/dev-admin-seed-gate.ts is imported relatively by auth-plugin.ts and is not re-exported from src/index.ts, so these four are internal to the package and carry no compatibility promise:

  • export interface DevAdminSeedProbeEngine
  • export type DevAdminSeedGateVerdict
  • export const CREDENTIAL_PROVIDER_ID
  • export async function decideDevAdminSeedGate

Changeset levels: @objectstack/plugin-authminor · @objectstack/clipatch (flag description text and a comment only — no exported symbol and no behaviour of its own). Nothing here is breaking: every addition is additive, no export is removed or narrowed, and no ADR-0087 disposition is owed.

Adjacent mechanical fix

The census anchor in content/docs/permissions/system-context.mdx moved auth-plugin.ts:1296 -> auth-plugin.ts:1301, because this PR inserted lines above that read site. Gate family: check-system-context-census (lint.yml). Pure line rot, not a population change — written by the gate's own --fix, which re-anchored one row and refused nothing; git diff --stat for that commit is one file, one line. Verdict after: check-system-context-census: OK — 109 elevation read sites in 20 packages across 45 files, all anchored; 145 anchors resolve, 27 declared non-read, and its --self-test passes.

That file is outside this claim's original file surface, so it is declared here and the claim comment on #14157 is amended to include it.

Worth recording for whoever derives gates next: this family is not derivable from the change that breaks it. It declares 29 path literals — its own anchor page plus specific packages/spec files — and packages/plugins/plugin-auth/src/auth-plugin.ts is not among them, so a code-only change set scores it silent (the derivation's own weakest verdict, explicitly "not a clearance"). It appears in the derived list only once the anchor page itself is in the change set — i.e. after the repair, never before it. Its real trigger is any source line movement under one of 145 anchors across 45 files, which no path literal can enumerate.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8

Generated by Claude Code


Generated by Claude Code


Generated by Claude Code

@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cli, @objectstack/plugin-auth, touching 21 documentable anchor(s).

28 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json d62f990a985eb15152579ad34c99f43e76e3657a.

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

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)
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 29 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 d62f990a985eb15152579ad34c99f43e76e3657apackageMentionDocs.

Which tree this was computed on

This run read content/docs from d99eee1c39c61aa4ae5a5f320934bcb5530c4875 — the merge of head 31fd3968cf0c0aadc5f75575bbbb411541b8bf53 into base d62f990a985eb15152579ad34c99f43e76e3657a, 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 d99eee1c39c61aa4ae5a5f320934bcb5530c4875 && git checkout d99eee1c39c61aa4ae5a5f320934bcb5530c4875
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d62f990a985eb15152579ad34c99f43e76e3657a 31fd3968cf0c0aadc5f75575bbbb411541b8bf53 && git checkout -B drift-repro d62f990a985eb15152579ad34c99f43e76e3657a && git merge --no-ff 31fd3968cf0c0aadc5f75575bbbb411541b8bf53
node scripts/docs-audit/affected-docs.mjs --json d62f990a985eb15152579ad34c99f43e76e3657a

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

@os-salesClaude

Copy link
Copy Markdown
Collaborator

CI status note (PM seat, session_01AUF1NoViznQK32gqpK8wS8): Lint & Repo Gates is red on 04fcb96a at one gate, check-system-context-census — a pure anchor shift: content/docs/permissions/system-context.mdx cites packages/plugins/plugin-auth/src/auth-plugin.ts:1296, and this PR's insertions moved the opCtx.context.isSystem read to :1301. It is this PR's to fix; the fix is in flight (the delivering dev runs the gate's own --fix and pushes one docs-anchor commit, declared on the body as an adjacent mechanical fix). No re-run is owed — the failure is deterministic on this head. The Clause-② PASS recorded on #14157 covers the contract increment; the seat verifies the next head differs from 04fcb96a by that one docs file only before marking ready.


Generated by Claude Code

@os-salesClaude

Copy link
Copy Markdown
Collaborator

Landing provenance (PM seat, domain:services, session_01AUF1NoViznQK32gqpK8wS8)

  • In-seat Clause-② contract review: PASS on the card — maybeSeedDevAdmin gates on "any sys_user row", but runs at kernel:ready — so any app that seeds people permanently loses the objectstack dev login #14157 (comment) — an isolated fable second opinion, transcript-verified (55 of 55 harness stamps at tier), adopted verbatim; needs:contract-review cleared on both carriers in that stroke (maintainer 2026-08-31: a PASS is cleared and landed by the dispatching seat; check-clause2-carriers --pair 14352 exit 0).
  • Head 31fd3968 = the reviewed 04fcb96a plus one docs-anchor line (content/docs/permissions/system-context.mdx, auth-plugin.ts:1296:1301), verified by git diff --stat; the contract increment is unchanged.
  • Landing checks on 31fd3968: every check green — Lint & Repo Gatessuccess 03:43:36Z (the check-system-context-census red on the previous head is repaired), Test Core (1/6)success 03:49:39Z, all other shards, type checks, Dogfood, Temporal Conformance, guards and changeset checks success; the remainder skipped by path filter. Closing keyword: Fixes #14157 is the only one in the body.
  • Marked ready for review; auto-merge armed for the merge queue in the same stroke.

Generated by Claude Code

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.

maybeSeedDevAdmin gates on "any sys_user row", but runs at kernel:ready — so any app that seeds people permanently loses the objectstack dev login

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-auth): gate the dev-admin seed on a LOGIN, not on user rows, and admit its own provisioning call - #14352

Merged
os-sales merged 4 commits into
mainfrom
claude/issue-14157-dev-admin-seed-credential-gate
Sep 2, 2026
Merged

fix(plugin-auth): gate the dev-admin seed on a LOGIN, not on user rows, and admit its own provisioning call#14352
os-sales merged 4 commits into
mainfrom
claude/issue-14157-dev-admin-seed-credential-gate

Conversation

@claude

@claudeclaudeBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes#14157

objectstack dev declares, in its own --help, a known loginable dev admin. The gate that implemented it asked a different question — does any human sys_user row exist? — and those two are the same question only while every user row carries a credential. An app that declares people in defineStack({ data }) breaks that: the declarative seed is awaited inside AppPlugin.start(), so it always lands before the seed's own kernel:ready hook, the database is non-zero-user before the check runs, and the admin is never minted — on that boot or on any later one, because the rows survive. The deployment ends up with no loginable account at all.

A seeded person is a directory row with no account. It is not a login, and treating it as one is the defect.

The fix is TWO halves, and the second one is what makes the first work

Half 1 — the gate (packages/plugins/plugin-auth/src/dev-admin-seed-gate.ts, new; owns the predicate and the argument). The seed acts while no account holds the configured seed address AND no local password login (sys_account.provider_id = 'credential') exists anywhere. Those are the card's own two suggested spellings, both used, because each guards a case the other does not: the first is the never-overwrite half (and it now covers a federated account on that address too), the second is "the environment already has the thing the seed provides". A credential store that cannot be read is its own verdict — unanswerable — and the seed declines and says so rather than minting a known-credential admin into an environment it could not see.

Half 2 — admission for the seed's own provisioning call (AuthManager.stageOperatorProvisioning). This is not a nicety. The seed provisions through better-auth's real signUpEmail, and that call has to be admitted; its admission used to ride on the audience gate's bootstrap bypass, "zero HUMAN users" — which the same 13 seeded rows also answer "populated". Measured: with 13 seeded people and zero accounts, under the default invite_only posture, the seed's own lane comes back SELF_REGISTRATION_CLOSED. A fix that moved only the gate would therefore have produced a seed that decides to run and a gate that then refuses it — the exact drift isHumanUserRow's own doc warns about. Case ⓪ of the new pin measures that refusal directly, which is what keeps every case after it non-vacuous.

So the seed now declares what it is — the deployment's own boot command provisioning its admin, the operator creation class — instead of inferring admission from a population it does not own. This moves no public door: nothing outside the process can stage the declaration, the only caller is hard-gated to NODE_ENV==='development', it names one address, it is cleared in a finally, and a caller killed between the two is cleaned up by a 60-second TTL. Case ④ pins that the stranger's /sign-up/email is refused SELF_REGISTRATION_CLOSED on exactly this population, before and after the seed runs.

The audience gate's own predicate is deliberately unchanged. It is a public self-registration carve-out, so the population it counts has to stay humans.

The second surface: bootstrap-status — measured, and folded in

Triage asked for this to be measured and folded in or declared out of scope with the reading. Measured, it is a real disagreement of its own rather than a look-alike: the handler counted sys_user rows with no filter at all, which made it the one call site out of step with the three isHumanUserRow consumers. On a database still carrying the legacy usr_system service row it answered hasOwner: true — the console withholding the first-run setup flow — while the admission gate and plugin-security's first-user detection both stood ready to admit and promote the first human.

It now asks AuthManager.hasBootstrapWindow(), the same question the admission gate answers, so the console can never offer a first-run creation the platform would refuse, nor withhold one it would allow. Cases ⑦–⑧ drive the real route. A no-engine composition (MSW/mock mode) still reads as bootstrapped, so the SPA falls through to its normal login flow exactly as before.

What is deliberately NOT in this PR, with the reading: whether that window should mean "no LOGIN" rather than "no human users". Flipping it is coupled to the admission gate's predicate — moving only bootstrap-status makes the console offer a setup flow the gate then refuses 403, and moving both admits a stranger's self-registration on a directory-seeded deployment. That is a posture ruling, not an implementation choice, so it is filed with its measurements and three costed options as #14349 rather than decided here.

Declared riders, both named on purpose

  • packages/cli/src/commands/dev.ts — the claim fenced packages/cli with one permitted exception: the sentence that states the gate predicate. Two lines carry that one statement and both are corrected: the --seed-admin flag description (the declared contract, user-visible) and the in-code comment restating it above const seedAdmin. Nothing else in that package is touched — notably not the "then promote it to platform admin" clause, which is a separate claim and a separate finding (below).
  • scripts/check-route-envelope.mjs — the ruled exemption for auth-plugin.ts drops 3 → 2. The third unenveloped body was the handler's no-engine early return, which existed only because the handler resolved the data engine itself; asking the probe (which reports "no window" without an engine) folds that branch into the ordinary answer. Shrinking a ruled boundary needs nobody's leave; widening is the maintainer-only direction. The gate's own --self-test runs ahead of it and passes.

Interplay with #14000 (not a dependency)

#14000's ruled remedy asserts that the seeded admin can sign in on a fresh install. That assertion is sound for the current scaffold, which seeds no users, and this change is what makes it robust if the scaffold ever gains a person seed. Neither blocks the other and no Blocked-by: is implied in either direction.

Verification

All of the following ran on c89600f4, with a clean working tree, through the shared verify lock.

whatresult
pnpm --filter @objectstack/plugin-auth exec vitest run88 files, 1816 tests, all passed
pnpm --filter @objectstack/plugin-auth typecheckpassed (after building the package's own dist — the examples program imports it by name)
the new pin alone13 tests passed
pnpm exec eslint --no-inline-config .5674 files, 0 errors, 0 warnings — the whole population, no narrowing
derived gate families (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, re-derived after the gate-script edit: 49 commands)all measured ones green, including check:route-envelope, check:engine-double-contract, check:where-matcher, check:test-source-alias, check:type-check-coverage, check:dispatcher-error-vocabulary, check:auth-mount-ledger, check:nul-bytes

Not measured locally, each for a stated reason, and each of them something CI runs anyway: check:test-completeness (exit 3, PREREQUISITE NOT MET by design — it grades a saved turbo test log and none was passed); check:i18n and check:i18n-coverage (PREREQUISITE NOT MET — they run the built CLI and its whole build closure); check:type-check-debt --re-measure and check:dual-build-cjs-loads (need the full workspace build); check:pm-dispatch-gates and check-half-states (both exceeded the container's foreground cap — timed out at 150s and 380s, so NOT MEASURED rather than red).

For the debt ledger specifically, the risk it covers was measured directly instead: plugin-auth's tsconfig.json excludes **/*.test.ts, so the package typecheck says nothing about a new test file. Compiling the new test file plus the changed sources through a temporary config with that exclusion lifted exits 0, and --listFiles confirms both new files were actually in the program — so the ledgered count cannot drift up because of this diff.

Ablation — predicted RED, both legs, and both went red

Predicted before the run. No rebuild leg is owed: the pin imports its subjects relatively (./auth-plugin.js, ./auth-manager.js), so vitest resolves them to this package's src/, never through the package exports to dist/ — and a leg that had stayed green would itself have been the dist-resolution signature. Each leg proved the mutation reached disk (anchor-count before/after plus a git hash-object change, refusing to run the measurement otherwise) and proved the restore (git checkout HEAD --, then blob hash equal to the HEAD blob and git diff HEAD empty). The whole script carried trap restore EXIT INT TERM.

  • baseline, unmutated: 13 passed
  • leg A1 — the gate reverted to humans.length > 0, ticket left in place: 6 of 13 failed (①, ①b, ②, ②b, ④, ⑤b)
  • leg A2 — the ticket's two read sites replaced by false, gate left in place: 4 of 13 failed (①, ①b, ②, ④)

A2 is the one worth reading: it is the measurement that this PR's second half is load-bearing, not decoration.

Out-of-scope findings, filed unassigned (searched first; a positive control confirmed the search channel was answering)

Adjacent but not a duplicate of #14348: #11979 is about config-anchoring the single posture, not about which row it selects.

Contract review (Clause ②)

Re-declared yes on the card after review: this diff widens a public surface, so the @objectstack/plugin-auth changeset is minor, not patch. The exported-symbol delta, split by whether it actually reaches a consumer:

On the package barrelsrc/index.ts carries export * from './auth-manager.js', so every public member of the exported AuthManager class is public API. Four are new:

new public memberwhat it is
stageOperatorProvisioning(email: string): voiddeclares that the deployment's own boot command is provisioning this address right now
clearOperatorProvisioning(email: string): voiddrops that declaration
isOperatorProvisioning(email: unknown): booleana peek, not a consume — both admission seams ask about the same single creation
hasBootstrapWindow() — async, answers a booleanpublic read of the same bootstrap-window question the admission gate answers

A fifth member added in the same hunk, prunePendingOperatorProvisioning(), is private and is not part of the surface.

Also on this side, and the reason "minor" is not only about added symbols: GET /api/v1/auth/bootstrap-statuschanges its published answer. On a database whose only row is the legacy usr_system service account it answered hasOwner: true and now answers false. Same field, same type, same route, different verdict on that population — a consumer-visible behaviour change, which is exactly what the level is for.

Module-only, not on the barrelsrc/dev-admin-seed-gate.ts is imported relatively by auth-plugin.ts and is not re-exported from src/index.ts, so these four are internal to the package and carry no compatibility promise:

  • export interface DevAdminSeedProbeEngine
  • export type DevAdminSeedGateVerdict
  • export const CREDENTIAL_PROVIDER_ID
  • export async function decideDevAdminSeedGate

Changeset levels: @objectstack/plugin-authminor · @objectstack/clipatch (flag description text and a comment only — no exported symbol and no behaviour of its own). Nothing here is breaking: every addition is additive, no export is removed or narrowed, and no ADR-0087 disposition is owed.

Adjacent mechanical fix

The census anchor in content/docs/permissions/system-context.mdx moved auth-plugin.ts:1296 -> auth-plugin.ts:1301, because this PR inserted lines above that read site. Gate family: check-system-context-census (lint.yml). Pure line rot, not a population change — written by the gate's own --fix, which re-anchored one row and refused nothing; git diff --stat for that commit is one file, one line. Verdict after: check-system-context-census: OK — 109 elevation read sites in 20 packages across 45 files, all anchored; 145 anchors resolve, 27 declared non-read, and its --self-test passes.

That file is outside this claim's original file surface, so it is declared here and the claim comment on #14157 is amended to include it.

Worth recording for whoever derives gates next: this family is not derivable from the change that breaks it. It declares 29 path literals — its own anchor page plus specific packages/spec files — and packages/plugins/plugin-auth/src/auth-plugin.ts is not among them, so a code-only change set scores it silent (the derivation's own weakest verdict, explicitly "not a clearance"). It appears in the derived list only once the anchor page itself is in the change set — i.e. after the repair, never before it. Its real trigger is any source line movement under one of 145 anchors across 45 files, which no path literal can enumerate.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8

Generated by Claude Code


Generated by Claude Code


Generated by Claude Code

@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cli, @objectstack/plugin-auth, touching 21 documentable anchor(s).

28 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json d62f990a985eb15152579ad34c99f43e76e3657a.

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

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)
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 29 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 d62f990a985eb15152579ad34c99f43e76e3657apackageMentionDocs.

Which tree this was computed on

This run read content/docs from d99eee1c39c61aa4ae5a5f320934bcb5530c4875 — the merge of head 31fd3968cf0c0aadc5f75575bbbb411541b8bf53 into base d62f990a985eb15152579ad34c99f43e76e3657a, 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 d99eee1c39c61aa4ae5a5f320934bcb5530c4875 && git checkout d99eee1c39c61aa4ae5a5f320934bcb5530c4875
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d62f990a985eb15152579ad34c99f43e76e3657a 31fd3968cf0c0aadc5f75575bbbb411541b8bf53 && git checkout -B drift-repro d62f990a985eb15152579ad34c99f43e76e3657a && git merge --no-ff 31fd3968cf0c0aadc5f75575bbbb411541b8bf53
node scripts/docs-audit/affected-docs.mjs --json d62f990a985eb15152579ad34c99f43e76e3657a

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

@os-salesClaude

Copy link
Copy Markdown
Collaborator

CI status note (PM seat, session_01AUF1NoViznQK32gqpK8wS8): Lint & Repo Gates is red on 04fcb96a at one gate, check-system-context-census — a pure anchor shift: content/docs/permissions/system-context.mdx cites packages/plugins/plugin-auth/src/auth-plugin.ts:1296, and this PR's insertions moved the opCtx.context.isSystem read to :1301. It is this PR's to fix; the fix is in flight (the delivering dev runs the gate's own --fix and pushes one docs-anchor commit, declared on the body as an adjacent mechanical fix). No re-run is owed — the failure is deterministic on this head. The Clause-② PASS recorded on #14157 covers the contract increment; the seat verifies the next head differs from 04fcb96a by that one docs file only before marking ready.


Generated by Claude Code

@os-salesClaude

Copy link
Copy Markdown
Collaborator

Landing provenance (PM seat, domain:services, session_01AUF1NoViznQK32gqpK8wS8)

  • In-seat Clause-② contract review: PASS on the card — maybeSeedDevAdmin gates on "any sys_user row", but runs at kernel:ready — so any app that seeds people permanently loses the objectstack dev login #14157 (comment) — an isolated fable second opinion, transcript-verified (55 of 55 harness stamps at tier), adopted verbatim; needs:contract-review cleared on both carriers in that stroke (maintainer 2026-08-31: a PASS is cleared and landed by the dispatching seat; check-clause2-carriers --pair 14352 exit 0).
  • Head 31fd3968 = the reviewed 04fcb96a plus one docs-anchor line (content/docs/permissions/system-context.mdx, auth-plugin.ts:1296:1301), verified by git diff --stat; the contract increment is unchanged.
  • Landing checks on 31fd3968: every check green — Lint & Repo Gatessuccess 03:43:36Z (the check-system-context-census red on the previous head is repaired), Test Core (1/6)success 03:49:39Z, all other shards, type checks, Dogfood, Temporal Conformance, guards and changeset checks success; the remainder skipped by path filter. Closing keyword: Fixes #14157 is the only one in the body.
  • Marked ready for review; auto-merge armed for the merge queue in the same stroke.

Generated by Claude Code

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.

maybeSeedDevAdmin gates on "any sys_user row", but runs at kernel:ready — so any app that seeds people permanently loses the objectstack dev login

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-auth): gate the dev-admin seed on a LOGIN, not on user rows, and admit its own provisioning call - #14352

Merged
os-sales merged 4 commits into
mainfrom
claude/issue-14157-dev-admin-seed-credential-gate
Sep 2, 2026
Merged

fix(plugin-auth): gate the dev-admin seed on a LOGIN, not on user rows, and admit its own provisioning call#14352
os-sales merged 4 commits into
mainfrom
claude/issue-14157-dev-admin-seed-credential-gate

Conversation

@claude

@claudeclaudeBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes#14157

objectstack dev declares, in its own --help, a known loginable dev admin. The gate that implemented it asked a different question — does any human sys_user row exist? — and those two are the same question only while every user row carries a credential. An app that declares people in defineStack({ data }) breaks that: the declarative seed is awaited inside AppPlugin.start(), so it always lands before the seed's own kernel:ready hook, the database is non-zero-user before the check runs, and the admin is never minted — on that boot or on any later one, because the rows survive. The deployment ends up with no loginable account at all.

A seeded person is a directory row with no account. It is not a login, and treating it as one is the defect.

The fix is TWO halves, and the second one is what makes the first work

Half 1 — the gate (packages/plugins/plugin-auth/src/dev-admin-seed-gate.ts, new; owns the predicate and the argument). The seed acts while no account holds the configured seed address AND no local password login (sys_account.provider_id = 'credential') exists anywhere. Those are the card's own two suggested spellings, both used, because each guards a case the other does not: the first is the never-overwrite half (and it now covers a federated account on that address too), the second is "the environment already has the thing the seed provides". A credential store that cannot be read is its own verdict — unanswerable — and the seed declines and says so rather than minting a known-credential admin into an environment it could not see.

Half 2 — admission for the seed's own provisioning call (AuthManager.stageOperatorProvisioning). This is not a nicety. The seed provisions through better-auth's real signUpEmail, and that call has to be admitted; its admission used to ride on the audience gate's bootstrap bypass, "zero HUMAN users" — which the same 13 seeded rows also answer "populated". Measured: with 13 seeded people and zero accounts, under the default invite_only posture, the seed's own lane comes back SELF_REGISTRATION_CLOSED. A fix that moved only the gate would therefore have produced a seed that decides to run and a gate that then refuses it — the exact drift isHumanUserRow's own doc warns about. Case ⓪ of the new pin measures that refusal directly, which is what keeps every case after it non-vacuous.

So the seed now declares what it is — the deployment's own boot command provisioning its admin, the operator creation class — instead of inferring admission from a population it does not own. This moves no public door: nothing outside the process can stage the declaration, the only caller is hard-gated to NODE_ENV==='development', it names one address, it is cleared in a finally, and a caller killed between the two is cleaned up by a 60-second TTL. Case ④ pins that the stranger's /sign-up/email is refused SELF_REGISTRATION_CLOSED on exactly this population, before and after the seed runs.

The audience gate's own predicate is deliberately unchanged. It is a public self-registration carve-out, so the population it counts has to stay humans.

The second surface: bootstrap-status — measured, and folded in

Triage asked for this to be measured and folded in or declared out of scope with the reading. Measured, it is a real disagreement of its own rather than a look-alike: the handler counted sys_user rows with no filter at all, which made it the one call site out of step with the three isHumanUserRow consumers. On a database still carrying the legacy usr_system service row it answered hasOwner: true — the console withholding the first-run setup flow — while the admission gate and plugin-security's first-user detection both stood ready to admit and promote the first human.

It now asks AuthManager.hasBootstrapWindow(), the same question the admission gate answers, so the console can never offer a first-run creation the platform would refuse, nor withhold one it would allow. Cases ⑦–⑧ drive the real route. A no-engine composition (MSW/mock mode) still reads as bootstrapped, so the SPA falls through to its normal login flow exactly as before.

What is deliberately NOT in this PR, with the reading: whether that window should mean "no LOGIN" rather than "no human users". Flipping it is coupled to the admission gate's predicate — moving only bootstrap-status makes the console offer a setup flow the gate then refuses 403, and moving both admits a stranger's self-registration on a directory-seeded deployment. That is a posture ruling, not an implementation choice, so it is filed with its measurements and three costed options as #14349 rather than decided here.

Declared riders, both named on purpose

  • packages/cli/src/commands/dev.ts — the claim fenced packages/cli with one permitted exception: the sentence that states the gate predicate. Two lines carry that one statement and both are corrected: the --seed-admin flag description (the declared contract, user-visible) and the in-code comment restating it above const seedAdmin. Nothing else in that package is touched — notably not the "then promote it to platform admin" clause, which is a separate claim and a separate finding (below).
  • scripts/check-route-envelope.mjs — the ruled exemption for auth-plugin.ts drops 3 → 2. The third unenveloped body was the handler's no-engine early return, which existed only because the handler resolved the data engine itself; asking the probe (which reports "no window" without an engine) folds that branch into the ordinary answer. Shrinking a ruled boundary needs nobody's leave; widening is the maintainer-only direction. The gate's own --self-test runs ahead of it and passes.

Interplay with #14000 (not a dependency)

#14000's ruled remedy asserts that the seeded admin can sign in on a fresh install. That assertion is sound for the current scaffold, which seeds no users, and this change is what makes it robust if the scaffold ever gains a person seed. Neither blocks the other and no Blocked-by: is implied in either direction.

Verification

All of the following ran on c89600f4, with a clean working tree, through the shared verify lock.

whatresult
pnpm --filter @objectstack/plugin-auth exec vitest run88 files, 1816 tests, all passed
pnpm --filter @objectstack/plugin-auth typecheckpassed (after building the package's own dist — the examples program imports it by name)
the new pin alone13 tests passed
pnpm exec eslint --no-inline-config .5674 files, 0 errors, 0 warnings — the whole population, no narrowing
derived gate families (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, re-derived after the gate-script edit: 49 commands)all measured ones green, including check:route-envelope, check:engine-double-contract, check:where-matcher, check:test-source-alias, check:type-check-coverage, check:dispatcher-error-vocabulary, check:auth-mount-ledger, check:nul-bytes

Not measured locally, each for a stated reason, and each of them something CI runs anyway: check:test-completeness (exit 3, PREREQUISITE NOT MET by design — it grades a saved turbo test log and none was passed); check:i18n and check:i18n-coverage (PREREQUISITE NOT MET — they run the built CLI and its whole build closure); check:type-check-debt --re-measure and check:dual-build-cjs-loads (need the full workspace build); check:pm-dispatch-gates and check-half-states (both exceeded the container's foreground cap — timed out at 150s and 380s, so NOT MEASURED rather than red).

For the debt ledger specifically, the risk it covers was measured directly instead: plugin-auth's tsconfig.json excludes **/*.test.ts, so the package typecheck says nothing about a new test file. Compiling the new test file plus the changed sources through a temporary config with that exclusion lifted exits 0, and --listFiles confirms both new files were actually in the program — so the ledgered count cannot drift up because of this diff.

Ablation — predicted RED, both legs, and both went red

Predicted before the run. No rebuild leg is owed: the pin imports its subjects relatively (./auth-plugin.js, ./auth-manager.js), so vitest resolves them to this package's src/, never through the package exports to dist/ — and a leg that had stayed green would itself have been the dist-resolution signature. Each leg proved the mutation reached disk (anchor-count before/after plus a git hash-object change, refusing to run the measurement otherwise) and proved the restore (git checkout HEAD --, then blob hash equal to the HEAD blob and git diff HEAD empty). The whole script carried trap restore EXIT INT TERM.

  • baseline, unmutated: 13 passed
  • leg A1 — the gate reverted to humans.length > 0, ticket left in place: 6 of 13 failed (①, ①b, ②, ②b, ④, ⑤b)
  • leg A2 — the ticket's two read sites replaced by false, gate left in place: 4 of 13 failed (①, ①b, ②, ④)

A2 is the one worth reading: it is the measurement that this PR's second half is load-bearing, not decoration.

Out-of-scope findings, filed unassigned (searched first; a positive control confirmed the search channel was answering)

Adjacent but not a duplicate of #14348: #11979 is about config-anchoring the single posture, not about which row it selects.

Contract review (Clause ②)

Re-declared yes on the card after review: this diff widens a public surface, so the @objectstack/plugin-auth changeset is minor, not patch. The exported-symbol delta, split by whether it actually reaches a consumer:

On the package barrelsrc/index.ts carries export * from './auth-manager.js', so every public member of the exported AuthManager class is public API. Four are new:

new public memberwhat it is
stageOperatorProvisioning(email: string): voiddeclares that the deployment's own boot command is provisioning this address right now
clearOperatorProvisioning(email: string): voiddrops that declaration
isOperatorProvisioning(email: unknown): booleana peek, not a consume — both admission seams ask about the same single creation
hasBootstrapWindow() — async, answers a booleanpublic read of the same bootstrap-window question the admission gate answers

A fifth member added in the same hunk, prunePendingOperatorProvisioning(), is private and is not part of the surface.

Also on this side, and the reason "minor" is not only about added symbols: GET /api/v1/auth/bootstrap-statuschanges its published answer. On a database whose only row is the legacy usr_system service account it answered hasOwner: true and now answers false. Same field, same type, same route, different verdict on that population — a consumer-visible behaviour change, which is exactly what the level is for.

Module-only, not on the barrelsrc/dev-admin-seed-gate.ts is imported relatively by auth-plugin.ts and is not re-exported from src/index.ts, so these four are internal to the package and carry no compatibility promise:

  • export interface DevAdminSeedProbeEngine
  • export type DevAdminSeedGateVerdict
  • export const CREDENTIAL_PROVIDER_ID
  • export async function decideDevAdminSeedGate

Changeset levels: @objectstack/plugin-authminor · @objectstack/clipatch (flag description text and a comment only — no exported symbol and no behaviour of its own). Nothing here is breaking: every addition is additive, no export is removed or narrowed, and no ADR-0087 disposition is owed.

Adjacent mechanical fix

The census anchor in content/docs/permissions/system-context.mdx moved auth-plugin.ts:1296 -> auth-plugin.ts:1301, because this PR inserted lines above that read site. Gate family: check-system-context-census (lint.yml). Pure line rot, not a population change — written by the gate's own --fix, which re-anchored one row and refused nothing; git diff --stat for that commit is one file, one line. Verdict after: check-system-context-census: OK — 109 elevation read sites in 20 packages across 45 files, all anchored; 145 anchors resolve, 27 declared non-read, and its --self-test passes.

That file is outside this claim's original file surface, so it is declared here and the claim comment on #14157 is amended to include it.

Worth recording for whoever derives gates next: this family is not derivable from the change that breaks it. It declares 29 path literals — its own anchor page plus specific packages/spec files — and packages/plugins/plugin-auth/src/auth-plugin.ts is not among them, so a code-only change set scores it silent (the derivation's own weakest verdict, explicitly "not a clearance"). It appears in the derived list only once the anchor page itself is in the change set — i.e. after the repair, never before it. Its real trigger is any source line movement under one of 145 anchors across 45 files, which no path literal can enumerate.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8

Generated by Claude Code


Generated by Claude Code


Generated by Claude Code

@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cli, @objectstack/plugin-auth, touching 21 documentable anchor(s).

28 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json d62f990a985eb15152579ad34c99f43e76e3657a.

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

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)
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 29 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 d62f990a985eb15152579ad34c99f43e76e3657apackageMentionDocs.

Which tree this was computed on

This run read content/docs from d99eee1c39c61aa4ae5a5f320934bcb5530c4875 — the merge of head 31fd3968cf0c0aadc5f75575bbbb411541b8bf53 into base d62f990a985eb15152579ad34c99f43e76e3657a, 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 d99eee1c39c61aa4ae5a5f320934bcb5530c4875 && git checkout d99eee1c39c61aa4ae5a5f320934bcb5530c4875
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d62f990a985eb15152579ad34c99f43e76e3657a 31fd3968cf0c0aadc5f75575bbbb411541b8bf53 && git checkout -B drift-repro d62f990a985eb15152579ad34c99f43e76e3657a && git merge --no-ff 31fd3968cf0c0aadc5f75575bbbb411541b8bf53
node scripts/docs-audit/affected-docs.mjs --json d62f990a985eb15152579ad34c99f43e76e3657a

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

@os-salesClaude

Copy link
Copy Markdown
Collaborator

CI status note (PM seat, session_01AUF1NoViznQK32gqpK8wS8): Lint & Repo Gates is red on 04fcb96a at one gate, check-system-context-census — a pure anchor shift: content/docs/permissions/system-context.mdx cites packages/plugins/plugin-auth/src/auth-plugin.ts:1296, and this PR's insertions moved the opCtx.context.isSystem read to :1301. It is this PR's to fix; the fix is in flight (the delivering dev runs the gate's own --fix and pushes one docs-anchor commit, declared on the body as an adjacent mechanical fix). No re-run is owed — the failure is deterministic on this head. The Clause-② PASS recorded on #14157 covers the contract increment; the seat verifies the next head differs from 04fcb96a by that one docs file only before marking ready.


Generated by Claude Code

@os-salesClaude

Copy link
Copy Markdown
Collaborator

Landing provenance (PM seat, domain:services, session_01AUF1NoViznQK32gqpK8wS8)

  • In-seat Clause-② contract review: PASS on the card — maybeSeedDevAdmin gates on "any sys_user row", but runs at kernel:ready — so any app that seeds people permanently loses the objectstack dev login #14157 (comment) — an isolated fable second opinion, transcript-verified (55 of 55 harness stamps at tier), adopted verbatim; needs:contract-review cleared on both carriers in that stroke (maintainer 2026-08-31: a PASS is cleared and landed by the dispatching seat; check-clause2-carriers --pair 14352 exit 0).
  • Head 31fd3968 = the reviewed 04fcb96a plus one docs-anchor line (content/docs/permissions/system-context.mdx, auth-plugin.ts:1296:1301), verified by git diff --stat; the contract increment is unchanged.
  • Landing checks on 31fd3968: every check green — Lint & Repo Gatessuccess 03:43:36Z (the check-system-context-census red on the previous head is repaired), Test Core (1/6)success 03:49:39Z, all other shards, type checks, Dogfood, Temporal Conformance, guards and changeset checks success; the remainder skipped by path filter. Closing keyword: Fixes #14157 is the only one in the body.
  • Marked ready for review; auto-merge armed for the merge queue in the same stroke.

Generated by Claude Code

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.

maybeSeedDevAdmin gates on "any sys_user row", but runs at kernel:ready — so any app that seeds people permanently loses the objectstack dev login

2 participants

@os-sales@claude