Skip to content

fix(plugin-auth): name a remedy that works in the no-sign-in boot report - #15720

Merged
os-warren merged 4 commits into
mainfrom
claude/issue-15588-boot-sign-in-remedy-text
Sep 5, 2026
Merged

fix(plugin-auth): name a remedy that works in the no-sign-in boot report#15720
os-warren merged 4 commits into
mainfrom
claude/issue-15588-boot-sign-in-remedy-text

Conversation

@os-warren

@os-warren os-warren commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Fixes #15588

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 — 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.

Revision 2 — head 885451e5d. Clause-② review returned CHANGES REQUIRED on F1, and #15587's dev returned a further correction to remedy (b). Both are fixed here, each with a new pin and a mutation check. See What changed since c80045dd9 at the end.

The message

[auth] no_sign_in_account_at_boot: this deployment has human 'sys_user' rows but ZERO
'sys_account' rows — there is no credential, no federated link, no login of any kind, for
anyone. NOBODY CAN SIGN IN, and the deployment CANNOT BE RECOVERED FROM INSIDE: 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); self-registration is refused
with SELF_REGISTRATION_CLOSED under the default 'invite_only' audience posture; and no
administrator exists who could send an invitation. Boot continues and this deployment will keep
LOOKING healthy — its only symptom is a 401 on credentials nobody holds. RECOVER IT FROM
OUTSIDE THE RUNNING PRODUCT, and the path that works is ONE ROW: INVITE ONE ADDRESS — write a
pending 'sys_invitation' row directly against the store ('email' a LOWERCASE address this
directory does NOT already hold, 'status' 'pending', a future 'expires_at', 'inviter_id' the id
of any existing 'sys_user' row — the pending-invitation lookup lowercases the address it
searches for, so a mixed-case row is never found), then have that person register through the
ordinary sign-up endpoint. The invitation carve-out admits that ONE creation under EVERY
audience posture, so no door needs widening — but it is an ADMISSION verdict, not a
verification bypass: under the default 'invite_only' posture no mail transport is needed either,
whereas an 'open' or 'email_domain' posture forces email verification on the INVITED login too
(see (b)), so close the posture back to 'invite_only' BEFORE that person registers. On the
'single' tenancy posture that account holder is then promoted to platform admin. Afterwards,
re-run the provisioning job that seeded these people so it seeds their logins too. TWO THINGS
THAT LOOK LIKE REMEDIES AND ARE NOT: (a) HAND-WRITING A CREDENTIAL ROW — a 'sys_account' row's
'password' column must carry a secret in the platform's own hash format, so a plaintext password
authenticates nothing — a 401 or a 500 depending on the row shape, never a session — and
writing ANY 'sys_account' row SILENCES THIS REPORT, which asks only whether such a row EXISTS —
the deployment stops being loudly broken and becomes quietly broken; and (b) OPENING THE
AUDIENCE POSTURE — which cannot recover an EXISTING person at all: self-registration is a
user-CREATION path, so it cannot hand a login to somebody whose 'sys_user' row already exists,
and NO posture changes that. Widening only ever admits a NEW address — and then every posture
other than 'invite_only' ('open', 'email_domain') FORCES email verification ON, so that login is
refused EMAIL_NOT_VERIFIED at its first sign-in until a mail transport delivers the link, and a
locked-out self-hosted install usually has none. Nothing here happens by itself.

Why each clause is true

Clause The reading behind it
the carve-out admits ONE creation under EVERY audience posture decideAudienceAdmission short-circuits on hasPendingInvitation before the posture switch, so the verdict is admit: true for every posture in AUDIENCE_POSTURES. Reviewer re-drove it on a real engine: invite_only/open/email_domain (list holding a different domain) all answer sign-up 200.
a LOWERCASE address, or the row is never found hasPendingInvitationFor searches where: { email: target.toLowerCase() }. A mixed-case row is invisible to it and the sign-up is refused SELF_REGISTRATION_CLOSED.
an address the directory does NOT already hold Written as a constraint on the operator's choice, not as a claim about a status code — see #15587 below.
⭐ it is an ADMISSION verdict, not a verification bypass The carve-out decides whether a user may be created. It does not touch requireEmailVerification, which createAuthInstance() wires off audiencePermitsSelfRegistration(posture). So on a widened posture the invited login is created and then refused at first sign-in. This is F1 — see below.
under 'invite_only' no mail transport is needed audiencePermitsSelfRegistration('invite_only') is false, so nothing is forced. Reproduced end to end by the reviewer: invitation → sign-up 200 → sign-in 200, no transport.
on 'single' that account holder is then promoted Driven with plugin-security's real bootstrapPlatformAdmin: on the dead end, adminPromoted: false, reason: 'no_authenticable_user'; after the invited sign-up, adminPromoted: true with the grant on the account holder. The line states the outcome and not the timing, because under single the promotion usually happens at the sign-up itself (N3).
a plaintext credential row authenticates nothing — a 401 or a 500 Shape-dependent (N1): an issuer-less hand-written row takes the user-not-found branch (401); a fully-shaped row with a plaintext password throws Invalid password hash (500). Never a session, either way.
writing ANY sys_account row SILENCES THIS REPORT probeSignInAccountsPresence is rows.length > 0 over a limit: 1 read; resolveNoSignInAccountReport returns null on anything but absent.
⭐ (b) cannot recover an EXISTING person at all Self-registration is a user-creation path; it cannot attach a login to an address that already carries a sys_user row, whatever the posture. The audience gate itself admits that address under open (pinned) — which is exactly what shows the posture is not the lever: whatever refuses them sits downstream of it.
every posture other than 'invite_only' FORCES email verification ON createAuthInstance() wires requireEmailVerification: true off the predicate, getPublicConfig() mirrors it, and assertAudienceConfig refuses an explicit false beside 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:

  • No wire status is asserted for plugin-auth: a sign-up for an address that already has a sys_user row answers 200 and persists nothing (posture email_domain) #15587's surface. It is a draft under review; a status code here would be stale the day it lands. The pin says NOTHING about what a SEEDED person's own re-registration answers fails on USER_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 invitation instruction is a constraint on the operator's choice ("an address this directory does NOT already hold"), correct on both sides of the landing.
  • ⛔ The sign-up path is not touched: 0 files.

The docs half landed on content/docs/deployment/self-hosting.mdx via #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 toContain on 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.

Pin What it drives
every posture in the vocabulary admits it decideAudienceAdmission across all of AUDIENCE_POSTURES, email_domain's allowlist holding a different domain so an admission can only be the carve-out
CONTROL — without the invitation row it is refused SELF_REGISTRATION_CLOSED / EMAIL_DOMAIN_NOT_ALLOWED
CONTROL — the carve-out, not the creation class pins the self-serve class so an operator/provider exemption cannot mask a dead carve-out
⭐ a widened posture admits the INVITED creation AND still forces verification on it both mechanisms in one pin, because the false claim was that the first cancels the second
⭐ ONLY the default posture is mail-transport-free the half the message still promises
⭐ an admission verdict CANNOT express a verification exemption structural: the verdict carries no field that could exempt the invited login. If one is ever added, the rider's scope changes and this is where it is noticed
⭐ the rider is SCOPED to the default posture the exact scoped phrasing, so no future edit can re-generalise it
⭐ under open the gate ADMITS the address outright why the posture is not the lever for an existing person
⭐ (b) states it in MECHANISM terms the operator is told why, not a status code
one credential row — plaintext and all — turns the report OFF the real probe over a real hand-written row
CONTROL — the identical store WITHOUT that row still reports
email_domain and open both wire requireEmailVerification ON getPublicConfig()'s wiring mirror
CONTROL — invite_only does NOT force it forcing is a property of widening, not a constant true
every posture other than invite_only is one that widens the message's exact quantifier, read off the vocabulary
⭐ N5 — every widening posture is NAMED in the message read off AUDIENCE_POSTURES, so a fourth one cannot leave the line silently stale
NAMES THE REMEDY THAT WORKS / says NOTHING about #15587's surface the message-text guards, including the cross-card one

Mutation 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_PATH proven by blob-hash equality plus an empty git 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.

Mutation Pins reddened
M1 drop "INVITE ONE ADDRESS" NAMES THE REMEDY THAT WORKS
M2 write #15587's surface into the line the cross-card guard, and only it
M3 remove the carve-out 2 remedy pins + both F1 mechanism pins
M4 admit every self-serve creation both refusal CONTROLs
M5 tighten the probe the silencing pin + 3 pre-existing #14353 pins
M6 probe always present the store CONTROL + 7 #14353 pins
M7 drop the forced-verification mirror both open/email_domain pins + the F1 widened pin
M8 force verification everywhere the invite_only CONTROL + exact-claim + F1 default pin
M9 re-generalise the rider (the F1 defect itself) the rider-scope pin
M10 drop the LOWERCASE qualifier (N2) the rider-scope pin
M11 let a verdict carry a verification exemption the structural pin
M12 misspell a vocabulary posture N5 + the message pin
M13grow the vocabulary — a fourth widening posture added to AUDIENCE_POSTURES in @objectstack/spec N5, and only N5
M14 revert (b) to the new-address-only framing the MECHANISM-terms pin
M15 make open refuse the gate-admits pin

M13 crosses a package boundary, so both legs rebuild. The suite imports @objectstack/spec/system as a bare specifier, resolved through that package's exports to its dist/ — 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 reading dist/: 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 commit

  • 54/54 derived gate families exit 0. Re-derived at this head with node 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 a timeout wrapper killed the driver at 560s — that 124 is the kill, not a gate result, and the remaining four were then run and are green.
  • Gate exit 3 is not a pass. On an unbuilt worktree check:dual-build-cjs-loads and check:type-check-debt answer PREREQUISITE 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-measure 12 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 typecheck exit 0, test layer included. tsconfig.json excludes **/*.test.ts, so tsc -p tsconfig.test.json --listFiles was used to confirm both edited files are in that program rather than assuming the green covered them; 0 errors in either.
  • Control-byte scan over the diff: no hits.

What changed since c80045dd9

  1. F1 (blocking). The rider "admits under EVERY posture, so no door is widened and no mail transport is needed" was false on a reachable population. The carve-out is an admission verdict, not a verification bypass, so on open/email_domain the invited login is created, refused EMAIL_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).
  2. Remedy (b) rewritten to lead with the failure the operator will actually hit — an existing person cannot register a login at all — in mechanism terms, keeping the M2 guard green.
  3. N1 (INVALID_EMAIL_OR_PASSWORD) → "a 401 or a 500 depending on the row shape, never a session".
  4. N2 the invitation address must be lowercase, with the reason.
  5. N3 dropped the "next boot" timing claim; the line states the outcome.
  6. N5 taken as a pin only — no message change, so no added review surface in the string — and mutation-checked by actually growing the vocabulary (M13).
  7. N4 declined: a docs-page pointer for walled postures. The promotion claim is already scoped to '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

os-warren and others added 2 commits September 5, 2026 03:29
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
@github-actions github-actions Bot added the size/m label Sep 5, 2026
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/data-modeling/objects.mdx (via expires_at (literal, a string literal in resolveNoSignInAccountReport))
  • content/docs/deployment/self-hosting.mdx (via email_domain (literal, a string literal in resolveNoSignInAccountReport), expires_at (literal, a string literal in resolveNoSignInAccountReport), invite_only (literal, a string literal in resolveNoSignInAccountReport), inviter_id (literal, a string literal in resolveNoSignInAccountReport))
  • content/docs/permissions/authentication.mdx (via expires_at (literal, a string literal in resolveNoSignInAccountReport))
  • content/docs/protocol/kernel/realtime-protocol.mdx (via expires_at (literal, a string literal in resolveNoSignInAccountReport))

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

  • content/docs/releases/v15.mdx (via expires_at (literal, a string literal in resolveNoSignInAccountReport))
  • content/docs/releases/v17.mdx (via email_domain (literal, a string literal in resolveNoSignInAccountReport), invite_only (literal, a string literal in resolveNoSignInAccountReport))

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

What this run could not see
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 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 — 11 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 87f0ccc3f7a4cc56ae38ca41efed0b9edc239f02packageMentionDocs.

Which tree this was computed on

This run read content/docs from 13ec7c313aabc1a26c2ff2ac45de6250f9650680 — the merge of head 885451e5d4473717a70dde8c3ca481c6bb2f8caa into base 87f0ccc3f7a4cc56ae38ca41efed0b9edc239f02, 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 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

⚠️ 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 87f0ccc3f7a4cc56ae38ca41efed0b9edc239f02 → 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 5, 2026

Copy link
Copy Markdown
Collaborator Author

Clause-② contract review — PR #15720 (card #15588), head c80045dd9

Tier: CONTRACT_REVIEW_TIER = 'claude-fable-5-1' (scripts/pm/dispatch-gates.mjs). Evidence: the dispatcher's attestation that this Agent call carried an explicit model: fable override, plus my own system-prompt identity claude-fable-5-1override + self-report, not an exact match against an auditable field (get_session deliberately not used; it describes the parent session).

Setup: dedicated worktree detached at c80045dd9 (merge-base 95d5cbb31), pnpm install --frozen-lockfile, full package closure built (71/71). No edits to any PR file, no push, no stash. Everything below is measured unless marked NOT MEASURED.

Verdict: PASS conditional on one wording fix (F1) — the three load-bearing claims are true, both key mutations reproduce, nothing breaks when #15587 lands; but one rider clause is false on a reachable population and produces exactly the self-silencing outcome the card exists to end.


1. Is every clause TRUE? — driven on a real engine, not just read

The dev pinned each clause against the in-process mechanism and said plainly it did not re-drive the card's HTTP measurements. I judged that insufficient for a message whose only content is claims about runtime behaviour, so I re-drove them on a real ObjectQL over @objectstack/driver-sql + better-sqlite3 :memory:, plugin-auth's own authIdentityObjects, through AuthManager.handleRequest, three human sys_user rows, zero sys_account, NODE_ENV=test:

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; open200; 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_only422 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 #2adminPromoted: 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 / ⚠️ shape-dependent — see N1.

⛔ 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.mjs EXIT_PREREQ = 3 → "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/"; check-type-check-coverage.mjs header: "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-loads exit 0 (103 entry points / 66 packages / 619 CJS files — the dev's exact numbers); check:type-check-debt exit 0 (--re-measure: 12 ledger entries, 140 raw errors, none above — identical); pnpm --filter @objectstack/plugin-auth typecheck exit 0.
  • Typecheck reasoning holds: tsc -p tsconfig.test.json --noEmit --listFiles lists 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's issuer column (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 plaintext password500 Invalid password hash (@better-auth/utils verifyPassword throws 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 email must be stored lowercase: hasPendingInvitationFor queries where: { email: target.toLowerCase() }. Measured: a Recovery@Corp.example row → 403 SELF_REGISTRATION_CLOSED for 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_account insert under single, so the holder is usually promoted at the sign-up itself and the next boot reports already_have_admin. Outcome identical.
  • N4 On walled tenancy postures the login works (audience invite_only does 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

Copy link
Copy Markdown
Collaborator Author

Rework pushed — head 885451e5d (was c80045dd9). Still a draft.

F1 accepted in full; the measurement is right and the clause was wrong. I had written the rider as a property of the carve-out when it is a property of one posture. The carve-out is an admission verdict, so the forced-verification mechanism I had pinned for remedy (b) applies to the invited login too — and the created row silences this report on the way past. An operator following the primary remedy on a widened posture got no login and turned the diagnostic off: exactly the transition the card exists to end, delivered by the remedy the card commissioned. Thank you for driving it rather than reading it.

The rider is now scoped, and carries the ordering you measured:

…admits that ONE creation under EVERY audience posture, so no door needs widening — but it is an ADMISSION verdict, not a verification bypass: under the default 'invite_only' posture no mail transport is needed either, whereas an 'open' or 'email_domain' posture forces email verification on the INVITED login too (see (b)), so close the posture back to 'invite_only' BEFORE that person registers.

All three copies (string literal, block comment, changeset) are aligned again.

Remedy (b) additionally rewritten, from #15587's dev: that fix does not make (b) work, it makes it fail honestly, and for an existing person it can never work (#14349, option A). My framing rested on forced verification — the story for a new address — while the operator reading this line is usually trying to recover an existing one. (b) now leads with that, in mechanism terms so no wire status is asserted for a surface still in flight:

OPENING THE AUDIENCE POSTURE — which cannot recover an EXISTING person at all: self-registration is a user-CREATION path, so it cannot hand a login to somebody whose sys_user row already exists, and NO posture changes that. Widening only ever admits a NEW address — and then …

M2 stayed green throughout — I did not weaken it, and did not need to.

The clause that was wrong is now the one that is pinned hardest

Five new pins for F1's territory and two for (b)'s, all mutation-checked:

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 (hasPendingInvitationFor lowercases 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). ⚠️ Two honesty notes in your spirit: 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

Copy link
Copy Markdown
Collaborator Author

Clause-② contract review — round 2, PR #15720 (card #15588), head 885451e5d

Tier: CONTRACT_REVIEW_TIER = 'claude-fable-5-1' (scripts/pm/dispatch-gates.mjs). Evidence: the dispatcher's attestation that this Agent call carried an explicit model: fable override, plus my own system-prompt identity claude-fable-5-1override + self-report, not an exact match against an auditable field (get_session not used).

Setup: the same dedicated worktree, moved to 885451e5d (three commits on the branch; merge-base still 95d5cbb31), dependency closure built. No edits to any PR file, no push, no stash. Round-1 findings (the three load-bearing claims, the original eight mutations at the first head, the two exit-3 gates, #15718) were not re-run, per instruction; everything below is what changed. Measured unless marked NOT MEASURED.

Verdict: PASS.

The scoped rider is true and its prescribed order is the one that works; the mechanism claim now carrying remedy (b) is true by construction of the sign-up route; M9 (the F1 replay), M11, M13 (both legs, with dist proof) and M14 reproduce; the coverage invariant holds as measured; both honesty notes check out; the N-items are handled and the one decline is reasonable.


1. The scoped rider — true, correctly ordered, aligned in all three copies ✅

Shipped wording (boot-sign-in-reachability.ts:285-289): "...admits that ONE creation under EVERY audience posture, so no door needs widening — but it is an ADMISSION verdict, not a verification bypass: under the default 'invite_only' posture no mail transport is needed either, whereas an 'open' or 'email_domain' posture forces email verification on the INVITED login too (see (b)), so close the posture back to 'invite_only' BEFORE that person registers."

  • Matches what I measured in round 1: invite_only + invitation → sign-up 200 → sign-in 200 with no transport; open/email_domain + invitation → sign-up 200 → 403 EMAIL_NOT_VERIFIED, report silenced.
  • The order is right and not a trap. Driven this round (H): register under open first → 403 EMAIL_NOT_VERIFIED; then close to invite_only and sign in → 200. So "BEFORE" is the safe prescription (no unverified account, no silenced report in between), and an operator who got it backwards can still recover by closing the posture afterwards.
  • No residue. Grepped all three copies for the old claim (no door is widened, no mail transport is involved, unscoped no mail transport is needed): none. String literal, block comment (:42-53) and changeset (.changeset/…:12) all carry the scoped form and the ordering.

2. Remedy (b)'s mechanism claim — true as stated ✅

"self-registration is a user-CREATION path, so it cannot hand a login to somebody whose 'sys_user' row already exists, and NO posture changes that."

Read against the installed better-auth 1.7.2 dist/api/routes/sign-up.mjs: findUserByEmail(normalizedEmail) runs at :200; if a user exists the route either returns the generic duplicate response (:163, when requireEmailVerification is on — the origin of #15587's 200) or throws USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL (:212). createUser — where the audience gate's validateUserInfo runs — is at :225, and linkAccount at :243 is reached only after a create. So no branch attaches a credential to an existing row, and the duplicate check sits before the posture gate ever runs — the posture cannot reach it. That holds on both sides of #15587: whatever that card does to the duplicate branch, it will not be "create a login for an existing row". Measured half: round 1, invite_only + invitation for a seeded address → 422, 0 accounts. NOT MEASURED end to end under open: my fixture hit AUTH_CONFIG_ERROR at permission-set resolution (no sys_permission_set rows in the harness), upstream of the duplicate check.

Nit (no message impact): the PR body says the refusal "sits downstream of" the posture; in better-auth it sits upstream (duplicate check before the gate). The block comment is direction-neutral ("cannot reach what refuses them") and the message asserts no direction. The real ordering strengthens the claim.

3. New pins and mutations — reproduced ✅

Harness as before (perl mutation proven by blob hash, vitest JSON reporter, restore by git checkout HEAD -- <abs path> proven by blob equality + empty diff). Baseline at this head: 47/47 green (40 + 7 new).

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: tsup rebuild (22 s) → marker present in 8 dist/ files (system/index.{js,mjs}, the browser/ 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 047/47 green. Worktree clean.

5. Honesty notes ✅

  • (a) timeout returns 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-debt was 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/objectstack in two throwaway worktrees at c80045dd9 and 885451e5d — 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 behind origin/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 person appears 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 that and Widening only ever are 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's where: { 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_only is 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 (audience invite_only does 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) ⚠️ N6 — this rests on the platform's default 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

@os-warren
os-warren marked this pull request as ready for review September 5, 2026 06:16
@os-warren
os-warren enabled auto-merge September 5, 2026 06:16

Copy link
Copy Markdown
Collaborator Author

PM note — one correction to this PR's own body, and one optional wording nit

Posted by the domain:services PM seat on landing. ⛔ Neither changes the diff; both are recorded so the description does not outlive the review that corrected it.

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: findUserByEmail runs at sign-up.mjs:200, while createUser — where the audience gate's validateUserInfo runs — is at :225, and linkAccount at :243 is reached only after a create. ⇒ The duplicate check happens before the posture gate is ever consulted.

⭐ 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 sys_user row. The shipped message and block comment are direction-neutral ("cannot reach what refuses them"), so nothing in the code or the text is wrong — only this description.

2. Optional, not held for (N6). The clause "under the default 'invite_only' posture no mail transport is needed either" is true on platform defaults and is pinned there, but it rests on the platform's default requireEmailVerification as well as on the posture. Measured in review: default posture plus an operator's own explicit emailAndPassword.requireEmailVerification: true → invited sign-up 200 → sign-in 403 EMAIL_NOT_VERIFIED → report silenced. That configuration is not pinnable and describes a deployment that has already committed to a transport, and if the platform default ever flipped the invite_only CONTROL pin goes red — so it fails safe. Adding "(with the default verification setting)" would close it; it was judged not worth holding the PR.

Both come from the round-2 review (comment 5549908244); the verdict there is PASS.


Generated by Claude Code

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/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

plugin-auth: both remedies in the no_sign_in_account_at_boot report are unexecutable as written — and one of them silences the report

1 participant