fix(plugin-auth): name a remedy that works in the no-sign-in boot report - #15720
Conversation
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
The `no_sign_in_account_at_boot` report fires on the deployment nobody can sign in to: human `sys_user` rows, zero `sys_account` rows. It ended with two remedies, and measured on the exact population it fires on, neither did what its sentence said. "Open the audience posture so an existing person can register their own login" produced no login: every posture other than `invite_only` forces `requireEmailVerification` on, so a login registered that way is refused `EMAIL_NOT_VERIFIED` at first sign-in, and a locked-out self-hosted install is usually the shape with no mail transport wired. "Write a `sys_account` credential row directly against the store" was worse than useless. The `password` column carries a secret in the platform's own hash format, so a plaintext one authenticates nothing — and `probeSignInAccountsPresence` asks only whether ANY `sys_account` row exists, so writing one turns this very report off. The operator's first attempt at the named remedy turned the loud dead end back into the silent one the report was written to end. The line now names the path measured to work: one pending `sys_invitation` row written directly against the store, then an ordinary sign-up. The invitation carve-out admits that one creation under every posture, so no door is widened and no mail transport is needed. The other two are still named, as the two things that look like remedies and are not. Message text only — no admission semantics move, exactly as the diagnostic's own card scoped itself. The probe is untouched; tightening it would move that card's diagnostic semantics and is deliberately not done here. Each clause is pinned against the behaviour it describes rather than string- matched: the invitation carve-out is driven across the whole `AUDIENCE_POSTURES` vocabulary with an un-invited control, the self-silencing claim is driven through the real probe with a hand-written plaintext credential row, and the forced-verification claim is read off `getPublicConfig()`'s wiring mirror with an `invite_only` control. Every new pin was mutation-checked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
📓 Docs Drift CheckThis PR changes 1 package(s): 4 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 13ec7c313aabc1a26c2ff2ac45de6250f9650680 && git checkout 13ec7c313aabc1a26c2ff2ac45de6250f9650680
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 87f0ccc3f7a4cc56ae38ca41efed0b9edc239f02 885451e5d4473717a70dde8c3ca481c6bb2f8caa && git checkout -B drift-repro 87f0ccc3f7a4cc56ae38ca41efed0b9edc239f02 && git merge --no-ff 885451e5d4473717a70dde8c3ca481c6bb2f8caa
node scripts/docs-audit/affected-docs.mjs --json 87f0ccc3f7a4cc56ae38ca41efed0b9edc239f02
|
Clause-② contract review — PR #15720 (card #15588), head
|
| Clause | Measured |
|---|---|
| (i) the carve-out admits that one creation under every posture | ✅ decideAudienceAdmission short-circuits on hasPendingInvitation before the posture switch (read). Driven: invite_only → sign-up 200; open → 200; email_domain with the list holding a different domain → 200 (carve-out trumps the list). Control without the row: 403 SELF_REGISTRATION_CLOSED / 403 EMAIL_DOMAIN_NOT_ALLOWED. |
(ii) every posture other than invite_only forces email verification |
✅ createAuthInstance wires requireEmailVerification: true off audiencePermitsSelfRegistration(posture) (auth-manager.ts:1431), getPublicConfig mirrors it (:5780), assertAudienceConfig refuses an explicit false beside such a posture. Driven: getPublicConfig().emailPassword.requireEmailVerification is true under open/email_domain, false under the default. |
(iii) writing ANY sys_account row silences the report |
✅ probeSignInAccountsPresence is rows.length > 0 over limit: 1 (read). Driven: one hand-written plaintext row → probeSignInReachability = {present, present} → resolveNoSignInAccountReport = null. |
| the four-column row is enough | ✅ sys_invitation's required fields are exactly id, email, status, inviter_id, expires_at (organization_id/role/team_id optional); the real engine accepted the message's row (+id) — sqlite DDL even defaults status to 'pending' and role to 'member'. |
default invite_only: invitation → sign-up 200 → sign-in 200, no mail transport |
✅ Reproduced end to end: sign-up 200, sign-in 200, then {present, present} and the report goes quiet. The card's measurement stands. |
| "an address this directory does NOT already hold" | ✅ Seeded address + invitation under invite_only → 422 USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL, 0 accounts (today's answer; see §3). |
single: the next boot promotes the account holder |
✅ Driven with plugin-security's real bootstrapPlatformAdmin + defaultPermissionSets: boot #1 on the dead end → adminPromoted: false, reason: 'no_authenticable_user' (directory rows never promoted); after the invited sign-up, boot #2 → adminPromoted: true, grant row on recovery@corp.example. Selection is oldestAuthenticable — oldest human with any sys_account row. |
plaintext sys_account row "authenticates nothing (INVALID_EMAIL_OR_PASSWORD)" |
✅ for the operator-likely shape / |
⛔ F1 — one clause is FALSE on a reachable population (blocking-class, one-clause fix)
"The invitation carve-out admits that ONE creation under EVERY audience posture, so no door is widened and no mail transport is needed"
The report fires on humans + zero accounts regardless of audience posture, and the mechanism the dev pinned for (ii) applies to the invited login too — the carve-out is an admission verdict, not a verification bypass. Measured on open and on email_domain (+ pending invitation):
POST /sign-up/email -> 200 (admitted by the carve-out, emailVerified: false)
POST /sign-in/email -> 403 EMAIL_NOT_VERIFIED
probeSignInReachability -> { humanUsers: 'present', signInAccounts: 'present' } -> report SILENT
So on a widened posture the operator who follows this line gets no login and turns the report off — the "loud dead end → quiet dead end" transition remedy (a) is warned about, delivered by the primary remedy. The population is not exotic: #15587's own measured population is email_domain with a seeded directory, and any install that declared open/email_domain before mail was wired and before anyone signed up fires this report. The docs page avoids this overreach by stating its measurement inside a section scoped to "the default invite_only audience posture"; the message generalises it under "EVERY".
Fix (wording only, no semantics): scope the rider and tell the operator the order, e.g.
"...admits that ONE creation under EVERY audience posture, so no door needs widening; under the default 'invite_only' posture no mail transport is needed either — an 'open' or 'email_domain' posture forces email verification on the invited login too (see (b)), so close the posture to 'invite_only' before that person registers; on the 'single' tenancy posture ..."
Measured that this order works: under invite_only the invited login signs in without verification. The same sentence is repeated in the new block comment ("No door is widened and no mail transport is involved") and in the changeset — align all three. A pin that reads getPublicConfig() under open with an invitation would hold it, but the existing "email_domain and open both wire requireEmailVerification ON" pin already proves the mechanism; what is missing is only the wording's scope.
2. Mutations — all eight reproduced, every pin discriminates
Harness: perl -0pi mutation, proven on disk by blob hash before the run, vitest JSON reporter, restore by git checkout HEAD -- <abs path> proven by blob-hash equality + empty git diff HEAD. Baseline at head: 40/40 green.
| Mutation | Red (measured) | Matches dev's claim |
|---|---|---|
| M5 tighten the probe to judge the credential | new one credential row … turns the report OFF + the three pre-existing #14353 pins by name: ANY sys_account row counts, NEGATIVE CONTROL — one account exists and the boot is silent, the neighbour is UNTOUCHED when this report did not fire |
✅ exactly — the scope boundary is real: #14353's own suite pins existence-only as intended |
M2 write USER_ALREADY_EXISTS / "answers 200 and persists nothing" into the line |
only says NOTHING about what a SEEDED person's own re-registration answers |
✅ the cross-card guard discriminates and nothing else moves |
| M1 drop "INVITE ONE ADDRESS" | NAMES THE REMEDY THAT WORKS |
✅ |
| M3 remove the carve-out | every posture … admits it + CONTROL — the carve-out … not the creation class |
✅ |
| M4 admit every self-serve creation | both refusal CONTROLs | ✅ |
M6 probe always present |
CONTROL — identical store WITHOUT that row still reports + 7 #14353 pins |
✅ |
| M7 drop the forced-verification mirror | both email_domain/open pins |
✅ |
| M8 force verification under every posture | CONTROL — invite_only does NOT force it + the exact-claim pin |
✅ |
Coverage: each of the 10 new pins is reddened by ≥1 mutation; each mutation is caught by ≥1 pin. No pin is worthless.
3. After #15587 lands — nothing in this message becomes false
Read clause by clause against a refusal landing: the address constraint is written as a choice ("does NOT already hold") and becomes more uniformly true; remedy (b) rests only on forced verification (orthogonal; measured independent of the phantom 200); the carve-out, the probe and the promotion are untouched by #15587; no status code is asserted for a seeded re-registration and M2 guards it. ✅. Heads-up, not a finding here: self-hosting.mdx's "answers 200 and persists nothing" bullet and its 422 sentence will go stale on that day — that is #15587's docs rider.
4. Scope — no admission semantics moved ✅
Source diff filtered of comment and string-literal lines is empty; decideAudienceAdmission, probeHumanUsersPresence, probeSignInAccountsPresence, probeSignInReachability, reportIfNoSignInAccountExists and the if (hasPendingInvitation) branch do not appear in the diff; audience-posture.ts, auth-manager.ts, auth-plugin.ts untouched; the block additions are inside the existing /** … */ header; sign-up path untouched (0 files).
5. Agreement with content/docs/deployment/self-hosting.mdx ✅ (with F1 as the one scope difference)
Same four columns, same "not already held" constraint, same forced-verification and plaintext-row facts, same single promotion statement. The docs additionally carry the walled-posture step (OS_PLATFORM_OWNER_EMAIL + verified) — the message is correctly scoped to single and silent there (N4). The only divergence is F1: the docs' "no mail transport wired" sits inside a section scoped to the default posture; the message says "EVERY".
6. Gate honesty ✅
- Exit 3 really is "nothing measured":
check-dual-build-cjs-loads.mjsEXIT_PREREQ = 3→ "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/";check-type-check-coverage.mjsheader: "3 PREREQUISITE NOT MET. The gate refused to measure, so nothing was …", with self-test cases pinning 3 ≠ findings ≠ ok. - The later greens are real — re-measured on the built closure in my worktree:
check:dual-build-cjs-loadsexit 0 (103 entry points / 66 packages / 619 CJS files — the dev's exact numbers);check:type-check-debtexit 0 (--re-measure: 12 ledger entries, 140 raw errors, none above — identical);pnpm --filter @objectstack/plugin-auth typecheckexit 0. - Typecheck reasoning holds:
tsc -p tsconfig.test.json --noEmit --listFileslists both edited files (1 hit each); 94 errors total = the ledgered pre-existing pile, 0 in either edited file. - NOT MEASURED by me: the other 51 derived families and
pnpm lint— read from the report; ADR-0087 exit 0 was the PM's.
7. #15718 ✅
Exists, open, pm:queue only, no assignee, no domain:*/type/priority; describes probeSignInAccountsPresence being existence-only so one unusable row silences the report permanently; cites the M5 evidence (the three #14353 pins); notes the pull against #15074.
Non-blocking notes
- N1
(INVALID_EMAIL_OR_PASSWORD)is row-shape-dependent. A hand-written row without better-auth 1.7'sissuercolumn (local:credential) → 401 (the "user not found" branch, before any verify — the card's shape and the likely operator shape). A fully-shaped row with a plaintextpassword→ 500Invalid password hash(@better-auth/utilsverifyPasswordthrows on a hash with no:). "Authenticates nothing" holds either way; consider "(a 401 or a 500 — never a session)" or dropping the code. - N2 The invitation
emailmust be stored lowercase:hasPendingInvitationForquerieswhere: { email: target.toLowerCase() }. Measured: aRecovery@Corp.examplerow →403 SELF_REGISTRATION_CLOSEDfor the lowercase sign-up. Neither text says so; one word ("a lowercase address") on the docs page would remove a confusing dead end. - N3 "the next boot then promotes" is a timing simplification: in a composed deployment plugin-security's bootstrap-replay middleware fires on the
sys_accountinsert undersingle, so the holder is usually promoted at the sign-up itself and the next boot reportsalready_have_admin. Outcome identical. - N4 On walled tenancy postures the login works (audience
invite_onlydoes not force verification) but admin standing needs the docs' extra step; a pointer to the self-hosting page from the line would help. - N5 Remedy (b)'s parenthetical hardcodes
('open', 'email_domain'); a fourth posture would leave that list stale without a red pin (the exact-claim pin reads the vocabulary, the string pin only checks the two names). Trivial.
Generated by Claude Code
… remedy
Clause-② review F1: the rider was FALSE on a reachable population. The
invitation carve-out is an ADMISSION verdict, not a verification bypass, so
the forced-verification mechanism this same line warns about for remedy (b)
applies to the INVITED login too. Measured by the reviewer on `open` and on
`email_domain` with a pending invitation: sign-up 200 with emailVerified
false, sign-in 403 EMAIL_NOT_VERIFIED — and the created row makes
probeSignInAccountsPresence answer 'present', so the report goes silent. An
operator following the PRIMARY remedy on a widened posture therefore got no
login and turned this report off: the loud-dead-end-to-quiet-dead-end
transition remedy (a) warns about, delivered by the remedy the card
commissioned.
The rider is now scoped to the default posture and carries the ordering the
reviewer measured to work — close a widened posture back to `invite_only`
BEFORE the invited person registers. All three copies (the string literal,
the block comment, the changeset) are aligned again.
Also folded in from the same review:
- N2: the invitation `email` must be stored LOWERCASE. `hasPendingInvitationFor`
searches `where: { email: target.toLowerCase() }`, so a mixed-case row is
never found and the sign-up is refused SELF_REGISTRATION_CLOSED — a silent
dead end the line was sending operators into.
- N1: `(INVALID_EMAIL_OR_PASSWORD)` is row-shape-dependent — an issuer-less
hand-written row answers 401, a fully-shaped one with a plaintext password
answers 500. Widened to "a 401 or a 500 depending on the row shape, never a
session", which holds for both.
- N3: dropped the "next boot" timing claim; under `single` the holder is
usually promoted at the sign-up itself. The outcome is unchanged, so the
line now states the outcome and not the timing.
Five new pins, because the clause that was wrong is the one nothing guarded:
the scoped phrasing itself; a widened posture admitting the invited creation
AND still forcing verification on it (the two mechanisms driven together,
since the false claim was that the first cancels the second); only the default
posture being mail-transport-free; the structural fact that an admission
verdict carries no field that could exempt verification; and N5 — every
widening posture in `AUDIENCE_POSTURES` is named in the message, read off the
vocabulary so a fourth one cannot leave the line silently stale.
⛔ Still message-text only: no admission semantics, no probe change, sign-up
path untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
… all Input from #15587's dev, who searched before concluding it: that fix does not make this remedy true, it makes it fail HONESTLY — the person still cannot register a login, and for an EXISTING person that is by design, not by defect (#14349 is closed on option A, the door stays shut, which the line above already cites). The previous wording rested entirely on forced email verification. That is the story for a NEW address. The operator reading this line is usually trying to recover somebody the directory already holds, and for them the posture is not the lever at all: self-registration is a user-CREATION path, so it cannot hand a login to an address that already carries a `sys_user` row, whatever the posture. Both failures are real; they are different failures, and the more likely one was missing. ⛔ Stated in MECHANISM terms, deliberately. #15587 is a draft under review, so this line asserts no wire status for that refusal — the M2 pin that forbids USER_ALREADY_EXISTS / "answers 200" / "persists nothing" / "no new row" in this message stays green, and the new clause is written to keep it that way. Two more pins: the gate ADMITS this address under `open` with no invitation at all — which is what shows the posture is not what stops an existing person, since whatever refuses them sits downstream of it — and the message states the mechanism rather than a status code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
Rework pushed — head
|
| New pin | Reddened by |
|---|---|
| a widened posture admits the INVITED creation AND still forces verification on it — both mechanisms in one pin, since the false claim was that the first cancels the second | M3, M7 |
| ONLY the default posture is mail-transport-free | M3, M8 |
| an admission verdict CANNOT express a verification exemption (structural — if a field that could exempt the invited login is ever added, the rider's scope changes and this is where it shows) | M11 |
| the rider is SCOPED to the default posture (the exact phrasing, so it cannot be re-generalised) | M9, M10 |
under open the gate ADMITS the address outright — which is what shows the posture is not the lever for an existing person |
M15 |
| (b) states it in MECHANISM terms | M14 |
N5 — every widening posture is NAMED in the message, read off AUDIENCE_POSTURES |
M12, M13 |
⭐ M9 is the F1 defect itself, replayed: reverting to my original unscoped wording now reddens a pin. M13 is the one N5 exists for — a fourth widening posture added to AUDIENCE_POSTURES in @objectstack/spec. That crosses a package boundary, so both legs rebuild and prove the marker by reading dist/: 0 files before, 6 after the mutation leg, 0 again after the restore. A source-only mutation would have measured the pre-mutation artifact and reported a meaningless green.
⭐ Your harness discipline caught one of mine: M14's first spelling was refused because its removed-marker also appears in the block comment, so the mutation could not be proven to have landed — a silent no-op that would have read as a passing measurement.
N1/N2/N3/N5 folded; N4 declined
- N1 → "a 401 or a 500 depending on the row shape, never a session".
- N2 → "a LOWERCASE address", with the reason (
hasPendingInvitationForlowercases what it searches for). - N3 → dropped the "next boot" timing; the line states the outcome, which is what the operator needs.
- N5 → taken as a pin only, no message change, so no added review surface in the string.
- N4 declined. The promotion claim is already scoped to
'single', so the line says nothing false about walled postures; the extra step there is about admin standing, not signing in, and the docs page is reachable from this report's own grep token. A URL in a boot line is scope this card should not take — happy to be overruled.
Verification at 885451e5d
54/54 derived families exit 0 (family byte-identical to the first head; exit codes by redirect). check:dual-build-cjs-loads and check:type-check-debt answered exit 3 PREREQUISITE NOT MET on the fresh worktree and were re-measured green only after the closure was built (71/71); and my gate driver was killed by a timeout wrapper at 560s — that 124 is the kill, not a gate result — so the last four ran in a second batch and are green. pnpm lint whole-repo exit 0. plugin-auth: 94 files / 2002 tests pass, typecheck exit 0 with the test layer confirmed in-program by --listFiles.
⛔ Still message-text only; the sign-up path and self-hosting.mdx are untouched. PR left as a draft.
Generated by Claude Code
Clause-② contract review — round 2, PR #15720 (card #15588), head
|
| Mutation | Red (measured at 885451e5d) |
Dev's claim |
|---|---|---|
| M9 re-generalise the rider ("under EVERY audience posture no mail transport…") | [#15588/F1] the no-mail-transport rider is SCOPED to the default posture — and only it |
✅ the pin whose absence let F1 through now fires |
| M10 drop the LOWERCASE qualifier | the same rider-scope pin | ✅ |
M11 carve-out verdict carries skipEmailVerification: true |
an admission verdict CANNOT express a verification exemption — only it |
✅ |
M12 misspell 'email_domain' throughout the string |
N5 + NAMES THE REMEDY THAT WORKS |
✅ |
M13 fourth widening posture in @objectstack/spec (AUDIENCE_POSTURES + audiencePermitsSelfRegistration) |
N5, and only N5 — see §4 | ✅ |
| M14 revert (b) to the new-address-only framing | the message states it in MECHANISM terms — only it |
✅ |
M15 make open refuse |
under open the gate ADMITS the address outright — only it |
✅ |
| M3 / M7 / M8 re-run at this head (they carry the two new F1 mechanism pins) | M3 → 2 remedy pins + both F1 pins; M7 → both open/email_domain pins + the F1 widened pin; M8 → invite_only CONTROL + exact-claim + the F1 default pin |
✅ |
Invariant, as measured: every one of the 17 pins is reddened by ≥1 mutation (the 10 round-1 pins are unchanged and were covered at the first head; the 7 new ones above), and every one of the 15 mutations is caught by ≥1 pin (M1, M2, M4, M5, M6 not re-run at this head — their target pins are byte-identical to the first head, where they reproduced).
4. M13 — both legs verified, with the stale-artifact control ✅
@objectstack/spec/system resolves through spec's exports to dist/system/index.mjs; plugin-auth's vitest aliases cover only plugin-security and service-messaging, so the dev's premise is right.
- Leg 0 (control): source mutated, dist not rebuilt → 47/47 green — the meaningless green a source-only mutation would have reported.
- Leg 1:
tsuprebuild (22 s) → marker present in 8dist/files (system/index.{js,mjs}, thebrowser/pair, and their.maps — the dev's "6" is a file-set counting difference, the runtime artifact the suite loads is among them) → suite: N5 red, only N5. - Leg 2: source restored (blob == HEAD), rebuilt through turbo (cache hit restores the HEAD dist) → marker files 0 → 47/47 green. Worktree clean.
5. Honesty notes ✅
- (a)
timeoutreturns 124 on a kill (timeout 1 sleep 2→ 124), so DRIVER_EXIT 124 is the wrapper, not a gate. The four re-run gates exist as scripts (check:type-check-debt,check:type-source-resolution,check:watch-hint-literal,check:where-matcher). Their exit-0 results are the dev's — NOT MEASURED by me this round (type-check-debtwas green in my round-1 re-measure at the first head). - Family byte-identical: derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackin two throwaway worktrees atc80045dd9and885451e5d— identical apart from the banner's commit and timestamp; the output states "the 54 this card owes" (43 by path, the rest by kind / whole-tree). Both derivations carry the driver's own STALE TREE notice (the branch base is 31 commits behindorigin/main, 16 derivation-source files changed) — a property of the branch point, identical for the dev's run, not a finding. - (b) The M14 refusal is genuine:
cannot recover an EXISTING personappears at both comment line 66 and string line 297, so a removed-marker grep on it still hits after the string mutation and the harness rightly refused to count it;user-CREATION path,NO posture changes thatandWidening only everare string-only, so a rescoped marker lands. The dev's harness run itself is NOT MEASURED by me; my own M14 used a string-only spelling and applied cleanly.
6. N-items ✅
- N1 shipped as "a 401 or a 500 depending on the row shape, never a session" — matches round-1 measurement (issuer-less row → 401; fully-shaped plaintext row → 500).
- N2 "a LOWERCASE address … the lookup lowercases the address it searches for, so a mixed-case row is never found" — matches
hasPendingInvitationFor'swhere: { email: target.toLowerCase() }and the round-1 403. - N3 the string no longer says "next boot"; "that account holder is then promoted" states the outcome I measured.
- N5 taken as a pin only; M12 and M13 both discriminate it, and its second assertion (
invite_onlyis the sole non-widening posture) also catches a fourth non-widening posture. - N4 declined — reasonable. The promotion sentence is scoped to
'single'so nothing false is said about walled postures; the walled step concerns admin standing, not signing in (audienceinvite_onlydoes not force verification there); the docs page is reachable from the report's grep token; and a URL in a boot line is scope this card should not take. Agreed.
The F1 question, asked once: what does the message now claim, and which pin fails if it is false?
| New claim | Pin | Note |
|---|---|---|
| "no mail transport is needed" under the default posture | ONLY the default posture is mail-transport-free (M3/M8) |
requireEmailVerification as well as the posture. Measured (G): default posture + an operator's explicit emailAndPassword.requireEmailVerification: true → invited sign-up 200 → sign-in 403 EMAIL_NOT_VERIFIED, report silenced. If the platform default ever flipped, the invite_only CONTROL goes red; an operator's own explicit setting is not pinnable and is a deployment that has already committed to a transport. Non-blocking; "(with the default verification setting)" would close it if wanted. |
| forces verification on the INVITED login too | a widened posture admits the INVITED creation AND still forces (M3/M7) |
|
| admission verdict, not a verification bypass | structural pin (M11) | |
| close the posture BEFORE that person registers | rider-scope string (M9); order measured H | |
| LOWERCASE address / mixed-case row never found | rider-scope string (M10); mechanism measured round 1 | no behavioural pin; the harmless direction — a store that started folding case would make the instruction redundant, not wrong |
| (b) cannot recover an EXISTING person; NO posture changes that | gate-admits (M15) + mechanism string (M14) | the "cannot hand a login" half is read off the vendor route, not pinned — a real-engine pin would assert #15587's in-flight wire behaviour, which is the thing this line deliberately avoids |
| a 401 or a 500, never a session | string only; measured round 1 | acceptable — a plaintext row minting a session would be a security defect caught elsewhere |
'single': that account holder is promoted |
not in this suite; measured round 1 against plugin-security's real bootstrapPlatformAdmin |
cross-package; plugin-security's own suite pins oldestAuthenticable |
Nothing false; N6 is the only clause resting on a default the message does not name, and it fails safe toward operators who configured the requirement themselves.
Generated by Claude Code
PM note — one correction to this PR's own body, and one optional wording nitPosted by the 1. The PR body has the direction backwards. It says the existing-person refusal "sits downstream of" the audience posture. In better-auth 1.7.2 it is upstream: ⭐ The real ordering strengthens the claim the body was making: it is not merely that the posture does not lift the refusal, it is that the posture is never reached for an address that already has a 2. Optional, not held for (N6). The clause "under the default Both come from the round-2 review (comment 5549908244); the verdict there is PASS. Generated by Claude Code |
Fixes #15588
The
no_sign_in_account_at_bootreport fires on the deployment nobody can sign in to: humansys_userrows, zerosys_accountrows. It ended with two remedies, and measured on the exact population it fires on, neither did what its sentence said — and one of them turned this report off.Message text only. No admission semantics move, exactly as #14353 scoped itself. Nothing widens, nothing narrows, no accept set changes, the probe is untouched, and the sign-up path (#15587's surface) is untouched. This changes what an operator reads, not what the platform admits.
The message
Why each clause is true
decideAudienceAdmissionshort-circuits onhasPendingInvitationbefore the posture switch, so the verdict isadmit: truefor every posture inAUDIENCE_POSTURES. Reviewer re-drove it on a real engine:invite_only/open/email_domain(list holding a different domain) all answer sign-up 200.hasPendingInvitationForsearcheswhere: { email: target.toLowerCase() }. A mixed-case row is invisible to it and the sign-up is refusedSELF_REGISTRATION_CLOSED.requireEmailVerification, whichcreateAuthInstance()wires offaudiencePermitsSelfRegistration(posture). So on a widened posture the invited login is created and then refused at first sign-in. This is F1 — see below.'invite_only'no mail transport is neededaudiencePermitsSelfRegistration('invite_only')is false, so nothing is forced. Reproduced end to end by the reviewer: invitation → sign-up 200 → sign-in 200, no transport.'single'that account holder is then promotedbootstrapPlatformAdmin: on the dead end,adminPromoted: false, reason: 'no_authenticable_user'; after the invited sign-up,adminPromoted: truewith the grant on the account holder. The line states the outcome and not the timing, because undersinglethe promotion usually happens at the sign-up itself (N3).passwordthrowsInvalid password hash(500). Never a session, either way.sys_accountrow SILENCES THIS REPORTprobeSignInAccountsPresenceisrows.length > 0over alimit: 1read;resolveNoSignInAccountReportreturnsnullon anything butabsent.sys_userrow, whatever the posture. The audience gate itself admits that address underopen(pinned) — which is exactly what shows the posture is not the lever: whatever refuses them sits downstream of it.'invite_only'FORCES email verification ONcreateAuthInstance()wiresrequireEmailVerification: trueoff the predicate,getPublicConfig()mirrors it, andassertAudienceConfigrefuses an explicitfalsebeside such a posture.Staying true across #15587's landing
#15587 fixes the other end of this story. Its dev's conclusion, searched rather than assumed: that fix does not make remedy (b) work — it makes it fail honestly. For an existing person it can never work, and that is a maintainer ruling (#14349, option A, 「the door stays shut」), which the first half of this very message already cites.
So remedy (b) now leads with that, in mechanism terms:
sys_userrow answers 200 and persists nothing (postureemail_domain) #15587's surface. It is a draft under review; a status code here would be stale the day it lands. The pinsays NOTHING about what a SEEDED person's own re-registration answersfails onUSER_ALREADY_EXISTS, "answers 200", "persists nothing", "silently" or "no new row" appearing in this line, and it stayed green through both revisions (mutation M2).The docs half landed on
content/docs/deployment/self-hosting.mdxvia #14495's PR and was read so the two agree; ⛔ not re-litigated here and not edited.The pins are behavioural, not string matches
A
toContainon this message proves only that somebody typed the words — and the defect being closed is a message whose sentences were false while every string assertion around them stayed green. F1 proved that again: the one clause nothing guarded is the one that was wrong. 17 pins now, each driving the mechanism its sentence rests on.decideAudienceAdmissionacross all ofAUDIENCE_POSTURES,email_domain's allowlist holding a different domain so an admission can only be the carve-outSELF_REGISTRATION_CLOSED/EMAIL_DOMAIN_NOT_ALLOWEDself-serveclass so an operator/provider exemption cannot mask a dead carve-outopenthe gate ADMITS the address outrightemail_domainandopenboth wire requireEmailVerification ONgetPublicConfig()'s wiring mirrorinvite_onlydoes NOT force ittrueinvite_onlyis one that widensAUDIENCE_POSTURES, so a fourth one cannot leave the line silently staleMutation checks — 15 mutations, every pin discriminating
Each mutation proven on disk before its run (injected marker present and removed marker gone and blob differing from HEAD), each restore by
git checkout HEAD -- ABSOLUTE_PATHproven by blob-hash equality plus an emptygit diff HEAD, trap on EXIT/INT/TERM with absolute paths. ⭐ The harness earned its keep: M14's first spelling was refused because its removed-marker also appears in the block comment, so the mutation could not be proven to have landed — a silent no-op that would have read as a passing measurement.presentopen/email_domainpins + the F1 widened pininvite_onlyCONTROL + exact-claim + F1 default pinAUDIENCE_POSTURESin@objectstack/specopenrefuse⭐ M13 crosses a package boundary, so both legs rebuild. The suite imports
@objectstack/spec/systemas a bare specifier, resolved through that package'sexportsto itsdist/— so a source-only mutation would have measured the pre-mutation artifact and reported a green that means nothing. Both legs therefore rebuild spec and prove the marker by readingdist/: 0 files before, 6 after the mutation leg, 0 again after the restore leg. Without that last check every later run in this worktree would have measured a mutant tree.⭐ M5 remains the load-bearing one for scope. Simulating the "obvious" fix — tightening the probe to judge the credential — reddens three pre-existing #14353 pins. Existence-only is pinned as intended behaviour by that card's own suite, which is why tightening it is a separate card: #15718.
Verification — head
885451e5d, after the final commitnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(43 by path + 6 by change kind + 7 declared whole-tree, 2 reached twice); family byte-identical to the first head. Exit codes captured by redirect, never through a pipe. Run in two batches (50 + 4) because atimeoutwrapper killed the driver at 560s — that 124 is the kill, not a gate result, and the remaining four were then run and are green.check:dual-build-cjs-loadsandcheck:type-check-debtanswerPREREQUISITE NOT MET(exit 3) — read as NOT MEASURED. The full closure was built (71/71) and both then measured: dual-build 103 require entry points across 66 packages, 619 CJS files parse;check:type-check-debt --re-measure12 ledger entries, 140 raw errors, none above its recorded number.pnpm lint(whole repo,eslint . --no-inline-config) exit 0 — full population, not a narrowing.pnpm --filter @objectstack/plugin-auth exec vitest run: 94 files, 2002 tests, all pass.pnpm --filter @objectstack/plugin-auth typecheckexit 0, test layer included.tsconfig.jsonexcludes**/*.test.ts, sotsc -p tsconfig.test.json --listFileswas used to confirm both edited files are in that program rather than assuming the green covered them; 0 errors in either.What changed since
c80045dd9open/email_domainthe invited login is created, refusedEMAIL_NOT_VERIFIED, and has silenced this report on the way past — the loud-to-quiet transition remedy (a) warns about, delivered by the primary remedy. Now scoped to the default posture and carrying the ordering that was measured to work. Aligned in all three copies (string literal, block comment, changeset).(INVALID_EMAIL_OR_PASSWORD)→ "a 401 or a 500 depending on the row shape, never a session".'single', so the line says nothing false about walled postures; the extra step there concerns admin standing, not signing in, and the docs page is reachable from this report's own grep token. Adding a URL to a boot line is scope this card should not take.🤖 Generated with Claude Code
https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y