fix(plugin-auth): report the zero-account boot dead end at kernel:ready - #14866
Conversation
A deployment with human `sys_user` rows and zero `sys_account` rows cannot be recovered from inside: nobody can sign in, the bootstrap carve-out counts humans and so does not open, `invite_only` refuses self-registration, and no administrator exists to invite anyone. Today it boots silently. Reports it at `kernel:ready`, at `error` level, naming both the consequence and the remedy. Extends the existing walled-owner reporter family rather than opening a parallel one: same hook, and the bounded human-population page is read ONCE and shared with `probeWalledOwnerAccountState`, which now accepts the already-known answer. At most one report per boot — the error subsumes the walled-owner warning when a deployment matches both shapes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
… its independence Pins the dead-end shape, all four silent controls, the error level with its `warn` fallback, the boot wiring, independence from each of the walled-owner probe's four preconditions, the one-report-per-boot precedence, and that `sys_user` is paged exactly once per boot. Measured while writing this: breaking the declared-owner precondition alone is an unreachable boot — a walled posture with no declared owner refuses startup in `init()` (#11184) — so the reachable no-owner shape is the default deployment, which is what the suite pins. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
…ew boot check `check:check-system-context-census` caught the line rot my kernel:ready edit caused: the session-resolution elevation read moved from auth-plugin.ts:1380 to :1405. Re-anchored by the gate's own --fix; no census row's meaning changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 5a6aedcb260847d4b18ba8bbda4ef4368a0ae16c && git checkout 5a6aedcb260847d4b18ba8bbda4ef4368a0ae16c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 896c528f4b545a6d6c5ad24d5f1cc4b0c54516c9 && git checkout -B drift-repro f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 && git merge --no-ff 896c528f4b545a6d6c5ad24d5f1cc4b0c54516c9
node scripts/docs-audit/affected-docs.mjs --json f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8
|
…ired `check:optional-error-sink` (gate #9754) failed on this branch: with both `error?` and `warn?` optional, `BootDiagnosticLogger` was a type every value of which may print nothing, so the contract permitted silence even though the emitter's explicit fallback branch was careful. Take the gate's own prescribed fix — `warn` becomes required, `error` stays optional. The two shapes the gate forbids are NOT taken: `error` is not made required (falsified, hosts inject reduced sinks), and this is not satisfied with a required `info` (a lost sign-in path reported at `info` is the reassuring half-truth AGENTS.md "Degradation log levels" removes). Fallout, all of it inside this module's own surface: - the emit branch drops its now-dead `?.` on `warn`; the type guarantees the channel, and the surrounding try/catch still holds for a throwing sink. - the "a logger that throws cannot break the boot" double no longer satisfied the type. It carries a real `vi.fn()` warn rather than a cast — a cast would re-open exactly the hole the gate closes — and now also pins that `warn` stays untouched when `error` is present and throws. The host call site is unaffected: `ctx.logger` is the spec `Logger`, whose `warn` is already required. Gate census moves by exactly one, in the intended direction: sinks declaring an optional `error` beside a REQUIRED `warn` 30 -> 31, sinks permitting silence 2 -> 1 (the remainder is the pre-existing baselined one, shrink-only). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
PM verification — rounds 1 and 2, plus two errors of mine the dev caught
Two facts in my dispatch order were false, and the dev measured them falseBoth were mine, and both are the same failure: I inferred where I should have read.
The dispatch order's instruction to re-derive every position and report the measured value, never the quoted one is what turned both into corrections instead of defects. That instruction earns its place. Round 1, verified independently
Clause-② is
|
Contract review — VERDICT: PASSImplemented-by: ① Derived judgments. The diff is what the card and the #14349 ruling (option A, 2026-09-02) allow and nothing more: a ② Semver / changeset. ③ Boundary flags. No governed path ( Landing. Carriers cleared on this PR and on #14353. PR CI is red only on Generated by Claude Code |
…side the derivation A family whose declared literals all name tracked FILES declares a roster — a baseline, an allowlist of the members it already has — and never a population. A list of the files that already exist can never contain one added tomorrow, so this derivation scores those families `silent` for every card in the tree, and no path a caller passes can move them. Two measured CI reds were carried by exactly that shape: `check:optional-error-sink` on PR #14866 and `check:error-code-provenance` on PR #14930, both invisible to a `--commands` harvest by construction, for every card. `artifactOnlyNote` already said all of this — per family, but only inside the silent listing, which is behind a flag no dispatch brief tells anyone to pass. The block says it where the default run shows it, and where `--commands` puts every other accounting: on stderr, so the stream a consumer executes still carries commands and nothing else. Measured before building it, over the SILENT bucket for the diff of PR #14866: 32 of 120 silent families declare only tracked artifacts, 5 of them with the roster sitting in a directory one of that card's paths is in. A minority, so the block enumerates rather than only counting, and marks the correlated subset. The block is never counted among the derived families and never merged into the runnable list. That is structural, not a filter: rosters are `silent`, and `commandsFor` reads only the matched, convention and always-runs rows. It deliberately does NOT call these gates repo-wide scanners. Whether a roster is a baseline sitting in a directory or a census taken of that directory is intent, and intent is not in the tree — the two live side by side here. The block states what is true of every member instead, and points at the producer-side remedy the residue already carries: declare the scan surface beside the roster. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox
Fixes #14353
A deployment holding human
sys_userrows and zerosys_accountrows cannot be recovered from inside, and until now it booted silently. Nobody can sign in; the first-account bootstrap carve-out counts humans and humans exist, so it does not open; the defaultinvite_onlyaudience posture refuses self-registration; and no administrator exists who could send an invitation. The only symptom was a 401 on credentials nobody holds.That state is now reported at
kernel:ready, at error level, under the grep nameno_sign_in_account_at_boot, naming both the consequence and the remedy.No admission semantics change. Nothing here touches
isBootstrapCreation,bootstrap-status, or any accept/reject decision.packages/specis untouched.How it extends the existing family rather than opening a parallel one
Per the triage addendum, this shares the existing walled-owner reporter's
kernel:readyhook and does not add a second prober:packages/plugins/plugin-auth/src/boot-sign-in-reachability.ts(new) owns the family's one bounded human-population page read.probeWalledOwnerAccountStatenow takes that answer as an optional argument instead of pagingsys_usera second time, so no boot reads the page twice. A test pins the read count, not the prose.WalledOwnerProbeEnginebecomes an alias of the family's singleBootProbeEngineshape, so the two probes cannot drift apart on what they require of a store.isHumanUserRowis reused for the human half; the predicate is not re-spelled.Three quoted facts that turned out false, measured against
origin/main#14349is NOT still in the decision inbox. It was ruled and closednot_plannedon 2026-09-02 — option A, the carve-out keeps counting humans, no public door moves. The ruling names this card explicitly and licenses its wording: its "message text may now say plainly that the door stays shut and the remedy is out-of-band provisioning". The message does exactly that, and a test pins it. The addendum's contingency (if B is ruled, the remedy clause must say the next visitor will be admitted) is moot — B was not taken.walled-owner-verification-path.ts:309is NOT stale.resolveWalledOwnerVerificationPathWarningis still at line 309 on currentmain. Only theauth-plugin.tsposition moved: the cited945-969is now 1020-1054, with the probe call at 1048 and the emitter at 1050.OS_PLATFORM_OWNER_EMAILunset refuses startup ininit()(the Walled postures must not elevate the first self-registrant: platform admin comes ONLY from an env-declared owner email; self-signups never join the Default Organization — ruled fix for cloud#1509 #11184 fail-closed clause), so there is no such deployment to diagnose. The reachable no-owner shape is the default deployment, which is what the independence suite pins instead. This was found by a red test, not by reading.Log level
error, per the card. The #13398-class ruling is satisfied rather than dodged: what it forbids is growingerror?onto a published sink that lacks it.BootDiagnosticLoggerdeclareserror?andwarn?from birth, and the neighbouringwarn?-onlyWalledOwnerVerificationLoggeris untouched. Following theshare-link-service.tsidiom, thewarnfallback is an explicit branch, because a bareerror?.()against a sink withouterroremits nothing — a sink that publishes onlywarnstill hears this, and that is pinned.Unlike the neighbour, an unanswerable probe here is silent. At
errorlevel the neighbour's "noisy over silent" posture would fire on every engine-less boot (every mock embedding), which is the fires-on-every-boot failure mode this family has controls against. This report makes a positive claim or none: humans seen, accounts seen absent.Clause-②: yes
Declared
yes. The measurement is mixed and both halves are reported:dist/index.d.ts)WalledOwnerAccountStateunionindex.tsdoes not re-exportprobeWalledOwnerAccountStatesignatureMeasured with
pnpm --filter @objectstack/plugin-auth buildthen extracting the terminalexport { ... }list fromdist/index.d.tsbefore and after.The public surface is genuinely unchanged, but the last row is a real change to an existing shipped diagnostic's output, and new exported symbols exist by the dispatch order's literal bar. Declaring
yesroutes the suppression decision into contract review, which is the one arguable thing in this PR.Ablation — three legs, direction predicted before running
These tests import the subject by relative path, so vitest resolves source, not
dist; the mutation proof is therefore literal-text counts on the resolved file. Each leg proved the mutation reached disk (injected-text count >= 1 AND removed-text count == 0) before its reading was accepted, and the script carried atrap ... EXIT INT TERMrestore with absolute paths.Tests 5 failed | 54 passed (59)— all 5 were negative controlskernel:readyhookTests 6 failed | 53 passed (59)— exactly those 6Tests 1 failed | 58 passed (59)— exactly that oneRestore proven, not assumed: HEAD blob hash equals disk hash for both mutated files (
3ed72a29...and62bf5624...), andgit diff HEADis empty.Verification
All at
d1c6ccca1, exit codes captured by redirect-then-read, never across a pipe.pnpm --filter @objectstack/plugin-auth test—Test Files 92 passed (92),Tests 1909 passed (1909)pnpm --filter @objectstack/plugin-auth typecheck— exit 0. Confirmed it actually compiled the new files: both appear intsc --listFilesoutput, so the green is not the excluded-tests phantom.pnpm lint(full repo,eslint . --no-inline-config) — exit 0 in 67s. No narrowing to declare.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands(re-derived after the docs edit added 24). 59 green. 3 reportPREREQUISITE NOT METand are recorded NOT MEASURED, not green and not red:check-test-completeness(exit 3, needs a test-run log),check:dual-build-cjs-loads(exit 3, needs a fullpnpm build),check:type-check-debt(exit 3, needs the whole workspace closure built). Its structural halfcheck:type-check-coveragepassed.check-system-context-censuscaught line rot my hook edit caused — the session-resolution elevation read moved fromauth-plugin.ts:1380to:1405. Re-anchored with the gate's own--fix; no census row's meaning changes. Re-run green: "109 elevation read sites in 20 packages across 45 files, all anchored".check:skill-examplesfirst failed only because@objectstack/client-reactwas unbuilt; after building its closure it passes (256 prose examples type-check). Not a finding.No test was skipped, disabled, or quarantined.
Patch round 1 —
check:optional-error-sink(heade10ae5955)Lint & Repo Gatesfailed ond1c6ccca1onpnpm check:optional-error-sink(
scripts/check-optional-error-sink-contract.mjs, gate #9754). It is genuinely thisPR's:
BootDiagnosticLoggerdeclared botherror?andwarn?optional, so everyvalue of the type may print nothing. The round-1 reasoning was one step short rather than
wrong — the emitter's explicit fallback branch was already careful, but the type did
not carry the guarantee the branch assumed, and
logger?.warn?.()can still be nothing.The gate's own prescribed fix, taken as written:
warnbecomes required,errorstays optional. Neither shape the gate forbids was used —
erroris not maderequired (option C, falsified: hosts inject reduced sinks), and this is not satisfied
with a required
info(a lost sign-in path reported atinfois the reassuringhalf-truth AGENTS.md "Degradation log levels" exists to remove).
Fallout, all inside this module's own surface:
?.onwarn— the type guarantees the channel,and the surrounding
try/catchstill holds for a throwing sink.a logger that throws cannot break the bootdouble no longer satisfied the type.It carries a real
vi.fn()warn, not a cast — a cast would re-open exactly thehole the gate closes — and now additionally pins that
warnstays untouched when apresent
errorthrows. No test was weakened, skipped or deleted.ctx.loggeris the specLogger, whosewarnisalready required.
Red, then green — exit codes captured by redirect-then-read, never across a pipe
d1c6ccca1pnpm check:optional-error-sink✗ 1 sink type(s) declare an optional error with no guaranteed fallback channele10ae5955pnpm check:optional-error-sink✓ optional-error sink contract: every sink declaring an optional error guarantees a warn channel (1 baselined, shrink-only)The census moves by exactly one, in the intended direction and no further:
30 declare it optional beside a REQUIRED warn, 2 permit silence (2 optional-fallback, 0 no-fallback)31 declare it optional beside a REQUIRED warn, 1 permit silence (1 optional-fallback, 0 no-fallback)The remaining 1 is the pre-existing baselined entry (shrink-only); this change did not
touch it and did not raise any baseline.
Re-verification at
e10ae5955pnpm --filter @objectstack/plugin-auth test— exit 0,Test Files 92 passed (92),Tests 1909 passed (1909).pnpm --filter @objectstack/plugin-auth typecheck— exit 0. First attempt returnedexit 2 on
examples/basic-usage.tsTS2307 purely because the package's owndistwasabsent; after
pnpm --filter @objectstack/plugin-auth buildit is clean. Coverage ofthe edited files is measured, not assumed: both appear in
tsc --noEmit -p tsconfig.test.json --listFiles, and that program reports 94errors — exactly the frozen
test-typecheck-debt.jsonbaseline, none of them ineither edited file.
check:test-typecheckgreen, shrink-only ledger held.dispatch-gates.mjs --commandsreturns thesame 62, byte-identical to round 1's set.
Declared narrowing. The round-1-to-round-2 delta is two files
(
git diff --name-only d1c6ccca1 e10ae5955=boot-sign-in-reachability.tsand its.test.ts), so only families whose population can contain TypeScript underpackages/can have changed verdict. Those were re-run on
e10ae5955: 23 green, pluscheck:optional-error-sinkgreen, plus the heavy ratchetscheck:type-check-coverage,check:type-source-resolutionandcheck:published-filesgreen. The doc, changeset,skill and release families were not re-run — the files they read are byte-identical to
the tree on which they were green in round 1.
The same 3 gates remain NOT MEASURED (exit 3,
PREREQUISITE NOT MET— not greenand not red), identical to round 1 and by construction, since each needs input only a CI
run produces:
check-test-completeness(a savedturbo run testlog),check:dual-build-cjs-loads(a fullpnpm build),check:type-check-debt(the wholeworkspace closure built).
Not addressed here, and deliberately so
Test Core (1/6)also failed ond1c6ccca1, inpackages/cli/test/run-dev-unbuilt-workspace.e2e.test.ts— a package this diff cannotreach. It is the repo-wide timeout flake tracked on #14822 and is out of scope here;
nothing under
packages/cliwas touched. That run carries an unusually clean control:the same child measured 180103 ms against a 180000 ms cap, and 7295 ms on the same runner
minutes earlier — 24.7x apart.
🤖 Generated with Claude Code
https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
Generated by Claude Code
Generated by Claude Code