Skip to content

fix(plugin-auth): gate no_sign_in_account_at_boot on a delegated sign-in path - #17941

Merged
os-project-manager merged 3 commits into
mainfrom
claude/issue-15074-sso-boot-report-gate
Sep 13, 2026
Merged

fix(plugin-auth): gate no_sign_in_account_at_boot on a delegated sign-in path#17941
os-project-manager merged 3 commits into
mainfrom
claude/issue-15074-sso-boot-report-gate

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes #15074

Clause-②: no

What was wrong

no_sign_in_account_at_boot decides on two store facts — human sys_user rows SEEN, sys_account rows SEEN ABSENT — and says of that shape: "NOBODY CAN SIGN IN, and the deployment CANNOT BE RECOVERED FROM INSIDE".

Those two facts were written for a deployment whose only way in is a credential row. On a deployment whose sign-in is delegated to an identity provider, the same shape is the healthy resting state, and the auth-config contract says so in as many words. AuthConfigSchema.ssoOnlyMode (packages/spec/src/system/auth-config.zod.ts), naming the cloud-as-IdP case explicitly:

managed (IdP-provisioned) users simply hold no local credential

The card measured it on a cloud tenant environment: the ERROR on every kernel boot, including a boot that had just served a successful SSO sign-in, and the only ERROR line in the whole smoke log.

The gate

The report now takes a third fact — SignInPathWiring, resolved from the live runtime by probeSignInPathWiring — and fires only when this deployment also has no delegated sign-in path. Three configurations count, each a way in that needs no operator-written sys_account row:

  • ssoOnlyMode (OS_AUTH_SSO_ONLY or the config key, advertised as features.ssoEnforced) — the deployment declaring IdP-only sign-in. Generic over the IdP, which is why a platform-SSO tenant kernel is sure to carry it;
  • a configured social / OIDC provider — its credentials are in the config, and a human's account row is written at their first sign-in rather than by provisioning;
  • enterprise SSO with at least one registered IdPplugins.sso / OS_SSO_ENABLED and a sys_sso_provider row.

When the gate suppresses the report, the shape is still recorded at debug under the same grep token, naming which configuration answered for it — the card's own second option.

What is deliberately NOT silenced

Scope fence held

Evidence

Reproduced first, then fixed

The new suite was written and run before the source change, at commit 370ab289:

 ❯ src/boot-sign-in-reachability.sso-gate.test.ts (8 tests | 5 failed) 30ms
     × SSO-only mode via `OS_AUTH_SSO_ONLY` — humans, zero accounts, and NO error 17ms
     × SSO-only mode declared in CONFIG (`ssoOnlyMode`) reaches the same verdict 2ms
     × the suppressed report still leaves a `debug` line NAMING the reason 2ms
     × a configured SOCIAL provider is a sign-in path — no error 2ms
     × enterprise SSO WITH a registered IdP is a sign-in path — no error 2ms
  Tests  5 failed | 3 passed (8)

The five failures are the false-fire direction (expected [ Array(1) ] to have a length of +0 but got 1 — the ERROR fired on a platform-SSO-shaped population). The three passes are the controls that keep the no-SSO dead end loud, so the suite was already discriminating before the fix. After the fix, the same file: 26 passed (26), both directions.

#14353's pin suite on the delivered tree

vitest run --reporter=verbose src/boot-sign-in-reachability.test.ts47 passed (47), file unedited. The three pins named in the dispatch, plus the independence pin this change had to preserve:

 ✓ #14353 — the probe reads humans, not rows > ANY `sys_account` row counts — provider, issuer and ban state are not asked about 0ms
 ✓ #14353 — the report is wired into AuthPlugin boot > NEGATIVE CONTROL — one account exists and the boot is silent 1ms
 ✓ #14353 — a deployment matching BOTH shapes gets exactly one report > the neighbour is UNTOUCHED when this report did not fire 1ms
 ✓ #14353 — independent of ALL FOUR walled-owner preconditions > FEDERATED SIGN-IN IS WIRED — the neighbour stays quiet; this still reports 1ms

Checks

Check Result
pnpm --filter @objectstack/plugin-auth test Test Files 109 passed (109) · Tests 2313 passed (2313)
pnpm --filter @objectstack/plugin-auth typecheck exit 0 — tsc --noEmit, the examples project, and check:test-typecheck ("OK — the test layer compiles")
pnpm lint (repo-wide, eslint . --no-inline-config) exit 0
pnpm --filter '@objectstack/plugin-auth^...' build and pnpm --filter @objectstack/plugin-auth build exit 0
Derived gate family (node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, 63 commands) 61 exit 0
pnpm check:dual-build-cjs-loads NOT MEASURED — exit 3, PREREQUISITE NOT MET: needs a whole-repo pnpm build (38 packages have no dist). Left to CI.
pnpm check:type-check-debt NOT MEASURED — exit 3, PREREQUISITE NOT MET: --re-measure needs the built closure of the ledgered packages. Its sibling pnpm check:type-check-coverage ran green.

Both NOT MEASURED rows are the gates' own exit-3 "nothing was measured" code, read from the gate's own verdict line, not from a shell $? behind a pipe.

Clause-② — re-derived from the delivered diff

The mechanical floor: minor iff the diff adds a new exported symbol reachable from the package's published entry, or a new key on an already-published payload.

New exported symbols in the diff, all in packages/plugins/plugin-auth/src/boot-sign-in-reachability.ts: SignInPathWiring, SignInPathConfigView, probeSsoProvidersPresence, probeSignInPathWiring, resolveDelegatedSignInPath.

Reachability, measured against the built entries rather than read off the source:

  • packages/plugins/plugin-auth/src/index.ts contains no re-export of ./boot-sign-in-reachability.js (and none of its importers re-export it either);
  • the package's exports map has exactly two entries, . and ./rate-limit-storage. Grepping the built dist/index.d.ts and dist/rate-limit-storage.d.ts for each of the five new names returns 0 occurrences — as it does for every pre-existing name in that module (NO_SIGN_IN_ACCOUNT_AT_BOOT, probeSignInReachability, reportIfNoSignInAccountExists, …). Positive control on the same grep in the same file: AuthPlugin, 30 occurrences.

No key was added to any published payload: SignInReachabilityFacts — what probeSignInReachability returns — is unchanged, getPublicConfig() is read but not modified, and no REST response shape moves. The new optional debug? member sits on BootDiagnosticLogger, which is itself unreachable from both entries.

patch, and the changeset is graded patch.

Acceptance notes

Noted, not filed: the new probe spells its object through SSO_PROVIDER_OBJECT (plugin-auth/src/sso-client-secret.ts) because SystemObjectName in packages/spec carries no member for sys_sso_provider, unlike USER / ACCOUNT / INVITATION used a few lines above it. The two spellings sit side by side in this file. It is a naming-consistency observation, not a defect — nothing is unenforced and nothing is wrong at runtime — and closing it would be a packages/spec edit, which this lane may not make.


Generated by Claude Code

The boot report `no_sign_in_account_at_boot` fires at `error` on a deployment
whose sign-in is delegated to an identity provider, where "human rows, zero
`sys_account` rows" is the declared healthy resting state.

Recorded BEFORE the fix: 5 failed | 3 passed. The five failures are the
delegated-sign-in direction (the report fires where it must not); the three
passes are the controls that keep the no-SSO dead end loud.

Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj
Co-authored-by: Claude <noreply@anthropic.com>
…-in path

The boot report fires on one store shape — human `sys_user` rows, zero
`sys_account` rows — and calls it unrecoverable. On a deployment whose sign-in
is delegated to an identity provider that shape is the healthy resting state:
`AuthConfigSchema.ssoOnlyMode` states it in the contract ("managed
(IdP-provisioned) users simply hold no local credential") and names
cloud-as-IdP. Measured on a cloud tenant environment, the ERROR fired on every
kernel boot, including one that had just served a successful SSO sign-in.

The report now takes a third fact resolved from the live runtime — SSO-only
mode declared, a configured social/OIDC provider, or enterprise SSO with at
least one registered `sys_sso_provider` — and stays silent at `error` when one
of them holds, recording the shape at `debug` under the same grep token with
the reason named.

Untouched, deliberately: `probeSignInAccountsPresence` keeps its existence-only
predicate, and a deployment with no delegated path — including one that merely
switched the SSO plugin on with no IdP registered — still reports at `error`.

Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj
Co-authored-by: Claude <noreply@anthropic.com>
Graded by the mechanical floor and re-derived from the built published entry:
none of the module's symbols (new or pre-existing) appear in dist/index.d.ts or
dist/rate-limit-storage.d.ts, the package's only two `exports` entries, and no
key was added to any published payload.

Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/permissions/authentication.mdx (via socialProviders (symbol, a field of interface SignInPathConfigView))
  • content/docs/permissions/sso.mdx (via socialProviders (symbol, a field of interface SignInPathConfigView))
  • content/docs/plugins/index.mdx (via socialProviders (symbol, a field of interface SignInPathConfigView))
What this run could not see
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 14 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 84e6b05b6d295f1c744d236921300f447cf7791epackageMentionDocs.

Which tree this was computed on

This run read content/docs from c662974294a3910edcb556755ac5626d3702bc22 — the merge of head 9546c7a1119f5bfbfc43d836229784ebbe6f6bf2 into base 84e6b05b6d295f1c744d236921300f447cf7791e, 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 c662974294a3910edcb556755ac5626d3702bc22 && git checkout c662974294a3910edcb556755ac5626d3702bc22
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 84e6b05b6d295f1c744d236921300f447cf7791e 9546c7a1119f5bfbfc43d836229784ebbe6f6bf2 && git checkout -B drift-repro 84e6b05b6d295f1c744d236921300f447cf7791e && git merge --no-ff 9546c7a1119f5bfbfc43d836229784ebbe6f6bf2

node scripts/docs-audit/affected-docs.mjs --json 84e6b05b6d295f1c744d236921300f447cf7791e

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

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 13, 2026
@claude

claude Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Contract review

Head reviewed: 9546c7a1119f5bfbfc43d836229784ebbe6f6bf2

Implemented-by: claude/issue-15074-sso-boot-report-gate (mode:subagent — the branch, not a session)
Reviewed-by: session_01URLHobLUJB9K1ABV6ofdjj (domain:services execution seat)

① Clause-② — re-derived in-seat from the DELIVERED diff, ⛔ not accepted from the report

The diff adds five exported symbols to boot-sign-in-reachability.ts (SignInPathWiring, SignInPathConfigView, probeSsoProvidersPresence, probeSignInPathWiring, resolveDelegatedSignInPath), plus an optional debug? member on the existing BootDiagnosticLogger. ⚠️ A sixth +export line is resolveNoSignInAccountReport, which is not new — it is the existing export re-emitted with a third parameter. ⛔ An existing export changing shape is not the Clause-② test.

Reachability from the published entry — measured, ⛔ not assumed:

reading result
src/index.ts re-exports './boot-sign-in-reachability.js' 0 matches
an importer of it that re-exports it none — auth-plugin.ts:82-89 and walled-owner-verification-path.ts:104-110 are both import { … } from, ⛔ not export … from. ⭐ export * carries what a module exports, ⛔ never what it imports, so export * from './auth-plugin.js' carries none of these
walled-owner-verification-path re-exported from index.ts 0 — moot either way
exports map exactly two entries, "." and "./rate-limit-storage"; rate-limit-storage.ts references this module 0 times
files ["dist","README.md","CHANGELOG.md"]

⭐ The delivery went further than this seat did and grepped the BUILT dist/index.d.ts / dist/rate-limit-storage.d.ts for each new name: 0 occurrences, as for every pre-existing name in the same module — with a positive control (AuthPlugin, 30 occurrences in the same file) proving the grep was not silently empty. ⛔ A zero whose control also reads zero is not evidence; this one has its control. The built reading and this seat's source-level trace agree.

Limb (b) — no new key on an already-published payload: SignInReachabilityFacts is byte-identical between base and head, and probeSignInReachability's body is byte-identical (base :241-247, head :298-304). ⭐ The new fact arrives as a separate optional parameter, not as a key on the existing returned object — which is exactly the flip this seat flagged at claim time ("it flips to yes if the delivered diff adds a key to probeSignInReachability's returned object or a new re-export in index.ts"). The delivery does neither.

Clause-②: no. Changeset patch is the correct grade.

② The scope fence — all four held, each verified in-seat

  1. probeSignInAccountsPresence untouched. Verified byte-identical, base :223-233 vs head :280-290 — same ten lines, shifted only. plugin-auth: probeSignInAccountsPresence is existence-only, so one unusable sys_account row silences no_sign_in_account_at_boot permanently #15718's half is not taken, its three pinned A deployment with human rows and zero sys_account rows boots silently into an unrecoverable state — say so loudly at kernel:ready #14353 behaviours are not re-decided, and its unruled maintainer question is not pre-empted.
  2. The self-hosted direction stays loud. Read in-seat: resolveDelegatedSignInPath returns null unless ssoOnlyMode, socialSignIn, or enterpriseSso with at least one registered provider holds — so an SSO plugin merely switched on with no IdP registered still falls through to the full error. ⭐ The delivery's FEDERATED SIGN-IN IS WIRED — the neighbour stays quiet; this still reports pin is green unedited, which is the machine's version of the same statement.
  3. The 2026-09-02 option-A carve-out untouched. The report text still carries it verbatim: "the first-account bootstrap carve-out counts HUMANS and humans already exist, so it does not open (maintainer ruling 2026-09-02, option A — the door stays shut)".
  4. Zero packages/spec. Four files, all under packages/plugins/plugin-auth/ and .changeset/.

③ Evidence discipline

Reproduced BEFORE fixed, and the suite discriminated before the source moved: at 370ab289 the new test file read 5 failed | 3 passed — the 5 failures the false-fire direction, the 3 passes the no-SSO controls. After the fix, 26/26 both directions. ⛔ A test that only ever passed would not have shown the defect existed.

#14353 pin suite on the delivered tree, file unedited: 47/47, with all three required pins green by name. Full package 109 files / 2313 tests. Typecheck, repo-wide pnpm lint (whole population, no narrowing claimed), and build closure all exit 0.

Gates: 63 derived, 61 exit 0, 2 NOT MEASUREDcheck:dual-build-cjs-loads and check:type-check-debt, both exit 3 (PREREQUISITE NOT MET) read from their own verdict lines, each needing whole-workspace built output. ✅ Reported as NOT MEASURED, ⛔ not as passes.

Noted, ⛔ not filed

SystemObjectName carries no member for sys_sso_provider, so the new probe spells it through plugin-auth's existing SSO_PROVIDER_OBJECT while USER / ACCOUNT / INVITATION a few lines above come from the spec enum. Recorded because a later reader will notice the asymmetry: it is a naming-consistency observation, ⛔ not a defect — nothing is declared-but-unenforced and nothing is wrong at runtime — and closing it would be a packages/spec edit this lane may not make. This seat concurs with the delivery's decision not to file it.

Verdict: PASS

Carrier gate: check-clause2-carriers --pair 17941exit 0, captured before any pipe.

⚠️ This record binds to the head it names. If 9546c7a11 moves, this record is re-issued against the new head — re-verifying alone is not sufficient.


Generated by Claude Code

@os-project-manager
os-project-manager marked this pull request as ready for review September 13, 2026 07:43
@os-project-manager
os-project-manager added this pull request to the merge queue Sep 13, 2026
Merged via the queue into main with commit 2c87a48 Sep 13, 2026
36 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-15074-sso-boot-report-gate branch September 13, 2026 08:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

plugin-auth: no_sign_in_account_at_boot fires at ERROR on every platform-SSO tenant kernel (healthy state reported as an unrecoverable dead end)

2 participants