Skip to content

feat(plugin-auth): the open audience posture honours a deployment's email-verification opt-out (#20389) - #20406

Merged
hotlong merged 5 commits into
mainfrom
claude/issue-20389-open-posture-verification-optout
Sep 28, 2026
Merged

hotlong merged 5 commits into
mainfrom
claude/issue-20389-open-posture-verification-optout

Conversation

@hotlong

@hotlong hotlong commented Sep 28, 2026

Copy link
Copy Markdown
Contributor

Fixes #20389

Clause-②: yes

What this changes

Under audience.posture: 'open', an explicit emailAndPassword.requireEmailVerification: false that the deployment declares is now honoured instead of refused. Everything else keeps today's behaviour.

Declared by open + explicit false email_domain + explicit false
Stack config (the AuthManager constructor) honoured (was: boot refused) refused, same message
Host code calling AuthManager.applyConfigPatch() (default declarant) honoured (was: refused) refused, same message
OS_AUTH_REQUIRE_EMAIL_VERIFICATION=false (settings env override, source env) honoured (was: refused) refused, same message
A value stored through the auth settings console (any other source) refused, as today refused, same message

open with the value absent or true still forces verification on. invite_only is unchanged.

  • audience-posture.ts: assertAudienceConfig keeps the email_domain refusal byte-identical, and under open refuses an explicit false only when the console declared it. A new resolveEmailVerificationRequirement(posture, declared) is the one function both the better-auth wiring (createAuthInstance) and getPublicConfig() read, so the advertisement cannot disagree with the behaviour.
  • auth-manager.ts: tracks one bit, whether the deployment declared verification off. The constructor sets it. A patch whose requireEmailVerification comes from the deployment re-declares it, and a console patch leaves it as it stood. So the console can agree with the deployment's opt-out but never make one. applyConfigPatch gains an optional { requireEmailVerificationFrom: 'deployment' | 'console' }, which defaults to deployment.
  • auth-plugin.ts: the settings binding passes deployment when require_email_verification arrives with source env, and console otherwise. At kernel:ready, after the settings namespace is applied, the plugin logs one warn when the posture is open and the advertised requirement is false. The warning names the posture and the consequence: anyone can register an address they do not control, and an organization invitation sent to that address can then be accepted by that account.
  • Spec auth-config.zod.ts: the audience docblocks and the posture description now say what the code does. content/docs/references/system/auth-config.mdx is regenerated with gen:schema and gen:docs, not hand-edited. self-hosting.mdx gains "Email verification under each posture", and environment-variables.mdx describes the variable. The recovery clause in boot-sign-in-reachability.ts that must agree with that page gets the same qualifier.

Premise correction: the settings door carries the maintainer's variable

The dispatch's working assumption was that the settings path is runtime-only and should keep refusing. Measured, it is not runtime-only. In this framework OS_AUTH_REQUIRE_EMAIL_VERIFICATION is the settings env override for auth.require_email_verification, so it reaches the manager through bindAuthSettings and then applyConfigPatch, not through the constructor. The settings service reports it as source: 'env', locked.

On main at db74b169dc, I booted with a stack-config open posture, that env override set to false, and a sibling session_expiry_days: 3. The probe lives only in local rig logs. Results:

  • The env value was refused.
  • The sibling setting was dropped too: session.expiresIn read undefined. The only trace was warn "Auth: failed to apply auth settings: [audience] invalid audience configuration: posture 'open' … FORCES email verification on …".
  • The same boot logged the error "email verification is REQUIRED but NO email service is registered … or disable verification (OS_AUTH_REQUIRE_EMAIL_VERIFICATION=false)". That remedy was the very value being refused.

The cloud control plane mounts SettingsServicePlugin, so a host that maps the variable into stack config also receives it through this second door. If the patch path had kept refusing, every auth settings pass on such a host would have been dropped. So the switch is keyed on who declared the value, not on which method received it: the constructor and the env override are the deployment, and a stored console value is not. The fork, with options and a recommendation, is in the os-dev-report on the card.

In-place fix (same defect class, same file, same gates)

The no-mail-transport boot error in auth-plugin.ts prescribed OS_AUTH_REQUIRE_EMAIL_VERIFICATION=false under every posture. Under email_domain that value is refused, so the error now says verification cannot be turned off under that posture. It still offers the variable where it works. It is pinned by audience-posture-setting.test.ts ("…and under email_domain, where it is refused, the error does not offer it").

Tests

Pins flipped to the new behaviour, not deleted:

  • audience-posture.test.ts:
    • the old "refuses the open-posture-with-verification-off contradiction" became an open test: honoured from the deployment, refused from the console, with the new message's first sentence asserted;
    • an email_domain test asserts the refusal's message family for all three declarants;
    • the applyConfigPatch tail now drives the console declarant, and three new patch tests cover console agreement, opening the posture over a console-stored false, and email_domain from the deployment;
    • the constructor boots open + false and still refuses email_domain + false.
  • End of chain, a real better-auth pipeline over the memory engine: open + deployment false returns a sign-up with a session token and user.emailVerified: false, and the public config reports false. Its control, open with the value undeclared, still mints no session.
  • audience-posture-setting.test.ts (the settings channel):
    • the console-stored false under open stays refused. This pin is deliberately not flipped, because it is the console door;
    • the env-override false under open is honoured, with exactly one warning, and a second settings pass does not warn again;
    • the stack config and the env override together agree, and the sibling session_expiry_days still applies;
    • the env override false under email_domain is refused with the unchanged message;
    • two controls produce no warning: open with verification on, and invite_only with it off.

Ablations, one at a time. Each mutation went through scripts/ablation-replace.mjs, which checked the anchor count and blob on disk and restored to the HEAD blob with an empty git diff HEAD. Tests import src directly, so no build was involved.

  • A1: the console-door refusal replaced with if (false) turned 5 tests red, 66 green.
  • A2: the resolver's open branch forced to true turned 5 tests red, 66 green.
  • A3: the plugin's env-to-deployment mapping replaced with a constant console turned 1 test red (the env-only door), 70 green. The dual-door test stays green under A3 by design: the stack-config opt-out already stands, so the console-marked false is agreement.

Verification (final head e2d18233c9)

  • pnpm --filter @objectstack/plugin-auth test: 114 files, 2454 tests passed.
  • pnpm --filter @objectstack/plugin-auth typecheck: exit 0, including check:test-typecheck. The debt is held at 10 files, 94 errors and 23 signatures.
  • pnpm --filter @objectstack/plugin-auth build: exit 0. A full turbo run build over ./packages/* and ./packages/*/* had 71 of 71 tasks succeed at this head.
  • pnpm --filter @objectstack/spec build and typecheck: exit 0.
  • Spec tests:
    • The whole @objectstack/spec suite passed 17097 tests and failed 4, in 3 files.
    • Re-run alone, scoped-context.test.ts passed; its 2 failures were 60 s timeouts under load.
    • The other 2 still fail with packages/spec/src/system/auth-config.zod.ts restored to base bytes, so they are not this diff. They are build-schemas-check-mode "writes the generation stamp…", which reads mismatch, and the check-liveness "drill ceiling" test, which fails on a JSON string truncated at 65478 bytes. The restore was trap-guarded and hash-verified.
  • Gates: dispatch-gates.mjs --commands derived 113 from the merge base, and --ran reconciled 113, with 112 run, 1 NOT MEASURED and 0 unrun.
    • check:generated is NOT MEASURED. This host's pnpm 10.31.0 refuses the wrapper's inner pnpm -s SCRIPT spelling ("unexpected argument '-s'") for all 15 member gates, so the wrapper never reached a gate body. Each member gate ran directly as pnpm run check:NAME and exited 0. check:docs was the one stale artifact, fixed by regenerating.
    • check:merge-driver exits 1. Its inputs (scripts/, .githooks/, .gitattributes) are byte-identical to base, and node scripts/git-merge-regen.mjs --self-test fails identically on a detached base checkout, at the same "FIRING CONTROL" leg. It is a host fact, not this diff.
    • check:skill-examples, check:dual-build-cjs-loads and check:type-check-debt first refused on a missing dist. Each exited 0 after the build.
    • Every other derived gate exited 0.
  • Lint, as a measured narrowing: pnpm exec eslint --no-inline-config --format json over the diff's 7 .ts files reported 7 files, 0 errors and 0 warnings. The population is read from eslint.config.mjs: its **/*.{ts,…} blocks, while .md and .mdx are not linted. The config never enables type-aware linting (its own comment at lines 327–328), so this diff cannot move a verdict on an untouched file.
  • Declared: scripts/pm/os-verify-lock.sh ran in UNLOCKED (declared) mode for every build and test (no usable flock on this macOS host).
  • Declared narrowing: main was not merged in before opening. The five commits origin/main gained since base touch none of this diff's paths (git diff --stat base..origin/main over them is empty, and git merge-tree is clean). The PR merge ref and the queue validate the joint result.

Acceptance notes

  • The console help text still reads "Postures other than invitation-only force email verification on". It lives in packages/services/service-settings/src/manifests/auth.manifest.ts, the audience group and the audience_posture help, and in its en and translated bundles. From the console's side it stays true, because the console cannot make the opt-out. On a deployment with the env override set, the locked false row sits beside it. That file is outside this card's declared file surface, so it is left for a follow-up.
  • docs/qa/platform-checklist/areas/identity-auth.json still states "on a permitting posture, requireEmailVerification is FORCED on" as a coverage boundary. It is outside the file surface and noted only.
  • Invitation behaviour is deliberately unchanged. requireEmailVerificationOnInvitation: false is set unconditionally today. Under the default invite_only posture, where verification is off by default, the invitation carve-out already lets whoever registers an invited address accept that invitation. The open opt-out widens that window to addresses registered before an invitation exists. The warning and the docs name this consequence.

Generated by Claude Code

hotlong and others added 5 commits September 28, 2026 14:09
…under the open audience posture

Under posture `open`, an explicit `emailAndPassword.requireEmailVerification:
false` declared by the deployment (constructor config, host code, or the
OS_AUTH_REQUIRE_EMAIL_VERIFICATION env override) is honoured instead of
refused. `email_domain` keeps refusing it. A value only stored through the
settings console still cannot turn verification off under `open`.

The wiring and getPublicConfig() read one resolver, and the plugin logs one
boot warning naming the posture and the consequence.

Claude-Session: https://claude.ai/code/session_d8bf7d34-4f31-407d-ac85-6cb849eec4bf
Co-authored-by: Claude <noreply@anthropic.com>
… door; amend the spec docblock and docs

Flip the open-posture refusal pins to the new behaviour (constructor,
applyConfigPatch from the deployment, the env-override settings door), keep
the email_domain refusal asserted on its message family, and keep the
console-stored door refused. Add the end-of-chain sign-up that is signed in,
its forced-on control, and the once-per-boot warning.

Claude-Session: https://claude.ai/code/session_d8bf7d34-4f31-407d-ac85-6cb849eec4bf
Co-authored-by: Claude <noreply@anthropic.com>
…ation opt-out only where the posture honours it

Under email_domain the remedy "OS_AUTH_REQUIRE_EMAIL_VERIFICATION=false" is
refused, so the error names that instead of prescribing it.

Claude-Session: https://claude.ai/code/session_d8bf7d34-4f31-407d-ac85-6cb849eec4bf
Co-authored-by: Claude <noreply@anthropic.com>
@hotlong hotlong self-assigned this Sep 28, 2026
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation protocol:system tests tooling labels Sep 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/deployment/environment-variables.mdx (via email_domain (literal, a string literal in assertAudienceConfig; a string literal in resolveEmailVerificationRequirement; a string literal in resolveNoSignInAccountReport; a string literal in start))
  • content/docs/deployment/self-hosting.mdx (via email_domain (literal, a string literal in assertAudienceConfig; a string literal in resolveEmailVerificationRequirement; a string literal in resolveNoSignInAccountReport; a string literal in start), invite_only (literal, a string literal in assertAudienceConfig), requireEmailVerification (literal, a string literal in applyConfigPatch))
  • content/docs/kernel/contracts/auth-service.mdx (via AuthManager (symbol, a top-level class))
  • content/docs/kernel/services-checklist.mdx (via AuthManager (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx (via AuthManager (symbol, a top-level class))
  • content/docs/permissions/sso.mdx (via getPublicConfig (symbol, a method of class AuthManager), requireEmailVerification (literal, a string literal in applyConfigPatch))

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

  • content/docs/releases/v17/17-3.mdx (via email_domain (literal, a string literal in assertAudienceConfig; a string literal in resolveEmailVerificationRequirement; a string literal in resolveNoSignInAccountReport; a string literal in start), invite_only (literal, a string literal in assertAudienceConfig), requireEmailVerification (literal, a string literal in applyConfigPatch))
  • content/docs/releases/v17/index.mdx (via invite_only (literal, a string literal in assertAudienceConfig))

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
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 140 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 dcd3bceaa068fc3cfb589bd6e04cd0b89b660580 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json dcd3bceaa068fc3cfb589bd6e04cd0b89b660580

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

@hotlong

hotlong commented Sep 28, 2026

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e2d18233c99e2bfbd4067aea327801aa8d14afe5
Local-runs: none

① Derived judgments

Inputs: card #20389 body and its 3 comments (claim, os-dev-report, ACCEPT); PR #20406 body, 11-file list, and the net diff main...e2d18233c9 (merge-base db74b169dc, 11 files, +537/−72); the 39 check-runs on the head — 34 success, 5 skipped, 0 failure, 0 pending. Head files read for the hunks' context only: auth-plugin.ts, auth-manager.ts, audience-posture.ts, index.ts, and the four docs pages the drift bot named. Nothing built, run or re-run.

Accept-set changes (config entry, assertAudienceConfig, and both of its callers):

  1. open + explicit requireEmailVerification: false declared by the deployment (constructor config; host applyConfigPatch with the default declarant; settings binding with source env) — was refused, now accepted. RIGHT: ruling item 1, and the maintainer's verbatim instruction is an env-variable switch. The env override reaches the manager only through applySettings → applyConfigPatch (auth-plugin.ts 1488-1492 applies require_email_verification only when explicit; 1700-1708 maps source env to deployment), so "constructor only" would have left OS_AUTH_REQUIRE_EMAIL_VERIFICATION=false inert.
  2. open + explicit false stored through the settings console (any explicit non-env source) — still refused, with the new message posture 'open' FORCES email verification on unless the DEPLOYMENT turns it off …, which prescribes the env variable or the stack config. RIGHT: narrower than a literal reading of item 1, but inside its stated scope (the deployment's configuration) and its stated reason (a no-mail pre-production deployment); the console is the one runtime-mutable, agent-writable door and the ruling never named it. The refusal is loud and carries the remedy.
  3. email_domain + explicit false, from every declarant — refused; the message is byte-identical to main (the ${posture} interpolation and the four message fragments are unchanged, the fail prefix [audience] invalid audience configuration: at audience-posture.ts 238-240 is untouched). RIGHT: ruling item 2, and the acceptance's "same code and message family".
  4. invite_only — the resolver returns declared ?? false; entry validation never reaches the self-registration branch. No change. RIGHT.
  5. open + absent or true — the resolver returns true; wiring and advertisement stay forced on (pinned by a control). RIGHT: ruling item 1, second sentence.
  6. The declarant bit deploymentDeclaredVerificationOff in AuthManager: set by the constructor; re-declared only by a patch that carries the requireEmailVerification key from the deployment; a console patch leaves it as it stood. emailAndPassword is deep-merged in applyConfigPatch (auth-manager.ts 4177-4184), so a deployment patch that omits the key cannot strand the bit against a vanished value. A console-stored false under invite_only followed by a posture switch to open is refused (pinned). RIGHT.
  7. Wiring: createAuthInstance and getPublicConfig() both read resolveEmailVerificationRequirement(posture, declared); the wiring still emits nothing when nothing is declared under invite_only, so the pre-plugin-auth: under the open audience posture, honour an explicit requireEmailVerification: false (email_domain stays forced) #20389 better-auth options for that posture are unchanged. RIGHT: ruling item 3, second clause — the advertisement cannot disagree with the behaviour.
  8. Warning: one ctx.logger.warn(OPEN_POSTURE_VERIFICATION_OFF_WARNING) inside the kernel:ready hook (auth-plugin.ts 728-745), after the memoized ensureAuthSettingsBound, reading the same advertisement the login UI reads. The text names the posture and both consequences (an unowned address registered and signed in at once; an organization invitation to that address acceptable by that account). A repeated settings pass does not warn again (pinned). RIGHT: ruling item 3, first clause.
  9. No-mail-transport boot error: under email_domain it no longer prescribes OS_AUTH_REQUIRE_EMAIL_VERIFICATION=false, the value that posture refuses; elsewhere it still offers it. RIGHT: message text only, no email_domain semantics moved, inside the claimed file, pinned both ways.
  10. resolveNoSignInAccountReport recovery text gains "unless an 'open' deployment has itself declared it off". RIGHT: its docblock requires agreement with self-hosting.mdx, which the same diff amends.

Public-surface changes:

  1. @objectstack/plugin-auth: AuthManager.applyConfigPatch(patch, options?) gains an optional { requireEmailVerificationFrom?: 'deployment' | 'console' } defaulting to deployment — additive; every existing call keeps its meaning (host code is the deployment). assertAudienceConfig gains an optional third argument, and resolveEmailVerificationRequirement, VerificationDeclarant and OPEN_POSTURE_VERIFICATION_OFF_WARNING are new module exports; index.ts does not re-export audience-posture.js, so they stay package-internal. RIGHT, and the changeset names the one public widening.
  2. @objectstack/spec: the AudienceConfigSchema.posture .describe() text and two docblocks; no shape, default, enum or refinement moved. content/docs/references/system/auth-config.mdx is regenerated and both occurrences match the new describe string verbatim. RIGHT: ruling item 4.
  3. Docs: self-hosting.mdx (new "Email verification under each posture" section; the widening paragraph qualified) and environment-variables.mdx (the variable's row). A code search on main for the invariant's phrasing under content/docs returns exactly these two pages plus the generated reference — all amended. The four further pages the drift bot named (auth-service, services-checklist, authentication, sso) were read at the head and none states the invariant; the two release-owned pages are untouched. RIGHT.
  4. Tests: the open refusal pins are flipped, not deleted. audience-posture.test.ts: entry honoured from the deployment and refused from the console with the new first sentence asserted; the email_domain message family for three declarants; the constructor both ways; four applyConfigPatch cases; the resolver truth table; an end-of-chain sign-up over the memory engine that mints a session with user.emailVerified: false, plus its undeclared control that mints none. audience-posture-setting.test.ts: env false honoured with exactly one warning and none on a second pass; both doors agreeing while session_expiry_days still applies; email_domain + env false refused with the unchanged message; the boot-error remedy under both postures; two no-warning controls. The one retained refusal (open + console-stored false) asserts the NEW message, so it is flipped to the built behaviour rather than left as the old pin. RIGHT.
  5. Card acceptance, line by line: open + false boots, sign-up yields a session, public config reports false, warning once — pinned; open + absent/true forced — pinned; email_domain + false refused, same family — pinned; invite_only unchanged — pinned; docblock and docs amended — in the diff. All met. The out-of-scope line held: no cloud wiring, no email_domain or invite_only semantics moved.

Gates on the head: every derived family is a success check-run — Check Changeset (both triggers), Part-of PR must not also close its card, The card this PR closes must claim this branch, single-writer path, Lint & Repo Gates, Test Core 1-6, the five Type Check lanes, Build Core, Build Docs, Check Documentation Links, Spec property liveness, Dogfood Regression Gate 1-3 and Verify CLI, Temporal Conformance, Governed Surface Queue Guard, Flag docs affected. Skipped: Console Pin Gate, Packed-tarball smoke (opt-in), and the duplicate Auto Label / Check PR Size triggers — none is a verdict on this diff. mergeable_state is clean.

② Semver level

Changeset .changeset/20389-open-posture-verification-optout.md: @objectstack/plugin-auth: minor, @objectstack/spec: patch. Matches the diff: plugin-auth widens its accept-set (a config that refused the boot on main now boots) and widens one public signature, and refuses nothing it accepted before — minor, neither major nor patch. spec publishes description text only — patch. Clause-②: yes is line 3 of the PR body and is in the changeset, as the claim declared; Check Changeset is success on the head. RIGHT.

③ Boundary flags

Dev deviations:

  • Zone-2 item 3 falsified (the settings door carries the maintainer's variable) → built declarant-keyed (option B). Answered by the ACCEPT comment; judged RIGHT here independently, on the code read in ①.1-2 and the measured route of the env override.
  • One settings-channel pin retained as a refusal (console-stored false under open) — RIGHT under B; it asserts the new message.
  • In-place fix of the no-mail error text under email_domain — RIGHT (①.9).
  • Spec .describe() string, regenerated reference page, and the boot-sign-in-reachability.ts qualifier — RIGHT (①.10, ①.12), inside the claimed surfaces.
  • Process items (worktree tracking flag, gate derivation without path args, log location, writes as hotlong, main not merged before opening, changeset line alignment, report fields, commit trailers) — no contract effect. CI ran on the merge of the head into main (29219d95a5), which covers the unmerged main.

Dev open questions:

  • Q1, the console door: B (deployment-declared only), answered by the PM as accepted — judged RIGHT. A would open an agent-writable runtime door the ruling did not name; C was falsified by measurement.
  • Q2, the invitation consequence: A (a sentence in the warning, the docs and the changeset), answered — judged RIGHT. The exposure widens a pre-existing carve-out (requireEmailVerificationOnInvitation: false is unconditional today), and invitation acceptance is outside the ruling.

Out-of-scope findings:

  • bindAuthSettings applies the whole auth settings pass in one try, so any refused key silently drops its siblings at warn (class a, measured on main). Pre-existing; this PR removes the open + env trigger. ESCALATED by the PM to the maintainer because no fleet identity is available here to file a card. It still needs a carrier card.
  • service-settings auth manifest help text and its i18n bundles, and docs/qa/platform-checklist/areas/identity-auth.json knownGaps, still state the old invariant. Outside the claim's file surface, correctly stopped on. ESCALATED: a one-line follow-up card is needed so the console does not contradict an env-locked false row beside it.
  • macOS-host-only spec test failures and the pnpm -s refusal — host facts; CI on Linux is green. No action.

Observation, within the ruling, not escalated: the warning is boot-time as ruled. A deployment that declares false under invite_only (legal; the default posture) and later has its posture switched to open from the console will honour the standing opt-out (the bit is the deployment's) and advertise false, but will not warn until the next boot. The ruling asks for one boot-time warning, and the posture switch is the operator's own logged act.

Implemented-by: claude/issue-20389-open-posture-verification-optout
Reviewed-by: session_d8bf7d34-4f31-407d-ac85-6cb849eec4bf

VERDICT: PASS

@hotlong
hotlong marked this pull request as ready for review September 28, 2026 08:48
@hotlong
hotlong enabled auto-merge September 28, 2026 08:49
@hotlong
hotlong added this pull request to the merge queue Sep 28, 2026
Merged via the queue into main with commit 65352b7 Sep 28, 2026
49 of 50 checks passed
@hotlong
hotlong deleted the claude/issue-20389-open-posture-verification-optout branch September 28, 2026 09:13
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…-verification rule as shipped (objectstack-ai#20434)

Fixes objectstack-ai#20413

Clause-②: no

Round 2 of the card, location 4: the plugin-auth boot report. Round 1
(PR objectstack-ai#20421, landed as `24b70859`) covered locations 1–3. This round
completes the card. It changes text, comments and tests only. No
admission decision, verification default or accepted value moves.

## What changed

The `no_sign_in_account_at_boot` report tells an operator to recover a
locked-out deployment by inviting one address. Its advice said an
`'open'` or `'email_domain'` posture "forces email verification on the
INVITED login too". Later, in remedy (b), the same message said
verification is forced "unless an 'open' deployment has itself declared
it off". The first sentence stopped being true when `open` began
honouring a deployment's opt-out (`65352b7d`, objectstack-ai#20406), so the message
contradicted itself.

**The rule, re-derived from `packages/plugins/plugin-auth/src` at
`24b70859`:**

- `resolveEmailVerificationRequirement` (audience-posture.ts), the one
resolver the better-auth wiring and `getPublicConfig()` both read:
  - `email_domain` is always on;
  - `open` is on unless the declared value is `false`;
  - `invite_only` uses the declared value, off by default.
- `assertAudienceConfig` refuses an explicit `false` under
`email_domain` from any source. Under `open` it refuses a `false` whose
declarant is `console`.
- `applyConfigPatch` (auth-manager.ts) marks a patch as the deployment's
unless its caller passes `requireEmailVerificationFrom: 'console'`.
- The invitation carve-out in `decideAudienceAdmission` returns only
`admit` and `grantPermissionSet`, so it cannot exempt anyone from
verification. The only row born `emailVerified: true` is the
walled-owner operator stamp, and an invitation-admitted creation never
gets it.

**The message now:**

- states the rule once, in the invitation paragraph:
- the INVITED login meets the same email-verification rule as any
sign-up;
  - `email_domain` is always ON;
- `open` is ON unless the DEPLOYMENT declared it off
(`emailAndPassword.requireEmailVerification: false` or
`OS_AUTH_REQUIRE_EMAIL_VERIFICATION=false`), and a `false` stored only
through the settings console is refused there;
  - `invite_only` follows that declaration and is OFF by default.
- keeps the scoped no-mail-transport rider and the advice order ("close
the posture back to 'invite_only' BEFORE that person registers"). It
adds "and leave verification at its default OFF", because under
`invite_only` a declared `true` would still apply.
- in remedy (b), refers back to that rule ("wherever it is ON") instead
of restating it.

The advice still matches `content/docs/deployment/self-hosting.mdx`
("Email verification under each posture"), which the module docblock
names as this message's long form. That page already stated the rule
correctly.

**Files:**

- `packages/plugins/plugin-auth/src/boot-sign-in-reachability.ts`: the
remedy string, plus the file docblock's invitation bullet (the old
"third bullet below applies to the INVITED login too" sentence).
- `packages/plugins/plugin-auth/src/boot-sign-in-reachability.test.ts`:
- the pin at the old line 164 is flipped. It now asserts the new text's
substance: the invited-login rule, `email_domain` always ON, the `open`
conditional, the console refusal, and the rider's new wording.
  - two negative pins are added, one per old unconditional spelling.
- a new `objectstack-ai#20389` describe drives each clause against the manager: `open`
+ the deployment's `false` wires OFF; the same `false` from the console
is refused and stays ON, with the deployment patch as control;
`email_domain` + `false` refuses the boot, with a control; `invite_only`
is OFF by default and ON when declared.
- three titles and comments that repeated the old quantifier now say
"with nothing declared".
  - `managerWith` takes an optional deployment `emailAndPassword` block.
- `packages/plugins/plugin-auth/src/auth-manager.ts`: one comment near
the old line 2295 (the objectstack-ai#15587 uniqueness-refusal block), restated as "on
by default".
-
`packages/plugins/plugin-auth/src/signup-existing-address-refusal.test.ts`:
one docblock sentence (old line 30), restated the same way.
- `.changeset/20413-boot-report-verification-text.md`:
`@objectstack/plugin-auth` `patch`. The boot report prose ships.

Stale-phrase counts across `packages/plugins/plugin-auth/src`, BASE
`24b70859` → HEAD `7898a47d`: "INVITED login too" 2 → 0; "every posture
other than 'invite_only' ('open'," 1 → 0; "self-registration FORCES
`requireEmailVerification` on (see" 1 → 0; "self-registration-permitting
posture FORCES `requireEmailVerification`" 1 → 0; "third bullet below
applies to the INVITED login too" 1 → 0.

## Verification (all at HEAD `7898a47d`)

- **Build:** `pnpm --workspace-concurrency=2 --filter
'@objectstack/plugin-auth^...' build`, run through the verify lock:
`VERDICT command-exit 0`. Then `pnpm --filter @objectstack/plugin-auth
build`: check-dts-emitted 2/2.
- **Targeted tests:** `pnpm --filter @objectstack/plugin-auth exec
vitest run --maxWorkers=2 src/boot-sign-in-reachability.test.ts
src/signup-existing-address-refusal.test.ts` gave `Test Files 2 passed
(2)` / `Tests 59 passed (59)`.
- **Package tests:** `pnpm --filter @objectstack/plugin-auth exec vitest
run --maxWorkers=2` gave `Test Files 114 passed (114)` / `Tests 2458
passed (2458)`.
- **Typecheck:** `pnpm --filter @objectstack/plugin-auth run typecheck`
exited 0. It runs `tsc --noEmit`, the examples project, and
`check:test-typecheck`, which reported "OK … 10 file(s) / 94 error(s) …
held in test-typecheck-debt.json".
- **Reverse verification.** The fix was committed first. Each leg ran
through `scripts/ablation-replace.mjs` in WRAP mode, with a driver trap
that restores from HEAD by absolute path. The test imports the module by
relative path, so it reads `src/` and no rebuild was needed between
legs.
- Leg A put the old sentence back in place of the new one. The anchor
went 1 → 0 and the blob went `b0be40fa` → `d1b521c9`. Result: `Tests 1
failed | 50 passed (51)`, failing `[objectstack-ai#15588/F1] the no-mail-transport
rider is SCOPED to the default posture` with "expected '[auth]
no_sign_in_account_at_boot: th…' to contain 'under the default
\'invite_only\' pos…'".
- Leg B added the old sentence beside the new text. Result: the same
single test red, on the negative pin "not to match /an 'open' or
'email_domain' posture …/i".
- Leg C added the old remedy-(b) quantifier. Result: the same single
test red, on "not to match /every posture other than 'invite_onl…/i".
- After all three legs, the blob equals the HEAD blob (`b0be40fa`) and
`git diff HEAD` is empty. The direction observed was red, as expected.
- **Lint, narrowed to the 4 touched `.ts` files:** `eslint
--no-inline-config --format json` linted 4 files with 0 errors and 0
warnings. `eslint.config.mjs` never enables type-aware linting (no
`parserOptions.project`, no typed rules), so this diff cannot change a
verdict on an untouched file. The repo-wide run belongs to CI.
- **Derived gates:** `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` derived 64 commands over these 5
paths. `--ran` reconciled "64 derived, 62 run, 2 NOT-MEASURED, 0 UNRUN".
All 62 that ran exited 0.
- NOT MEASURED: `pnpm check:dual-build-cjs-loads` exited 3 with
PREREQUISITE NOT MET, because it needs every package's `dist/` and this
tree built only the plugin-auth closure. Declared narrowing, applied to
the one touched package: `node --check dist/index.js` exits 0, and
`require('./dist/index.js')` loads 143 exports. The new text is present
in `dist/index.js` and `dist/index.mjs` (1 each), and the old phrase in
neither (0 each).
- NOT MEASURED: `pnpm check:type-check-debt` exited 3 with PREREQUISITE
NOT MET, because `@objectstack/runtime`, `service-cluster` and
`service-job` have no built types here. Declared narrowing: the ledger's
DEBT keys are `cloud-connection`, `hono`, `observability` and
`spec-monorepo`, none of which depends on plugin-auth, and TEST_DEBT is
empty. The only non-comment source change is the content of one string
literal, so no type moves.
- `check:nul-bytes`: OK. Control-byte self-scan over the 5 files: no
hits.

## Acceptance notes

Sweep, at `24b70859`: lines naming email verification
(`email.?verification|requireEmailVerification|EMAIL_NOT_VERIFIED|REQUIRE_EMAIL_VERIFICATION`)
that also carry a forcing or quantifier word (`forc|other than
invite_only|every posture|permitting posture|widen`). That gave 51 lines
in 19 files, and every hit outside round 1's surface was read in
context. In the files this round owns, all hits are fixed.

**Stale or incomplete outside this round's surface. All are comments or
frozen history; none is edited here.**

- `packages/plugins/plugin-auth/src/audience-gate-test-support.ts:17`:
"`open` (and `email_domain`) force `requireEmailVerification` on". This
is a docblock explaining why fixtures use the invitation lane. It is
true for the undeclared fixture config but omits `open`'s opt-out. No
carrier.
-
`packages/plugins/plugin-auth/src/admin-remove-user-gate-ordering.test.ts:135`:
"`open` would force email verification on". The same shape, in a test
comment. No carrier.
- `packages/verify/src/harness.ts:778`: "`open` and `email_domain` force
`requireEmailVerification` on". The same shape, in a comment. No
carrier.
-
`packages/spec/src/migrations/entries/semantic/18.audience-posture-default-invite-only.ts:19`,
and its generated mirror
`packages/spec/src/migrations/registry.ts:6270`: "accepts forced email
verification". This is the v18 migration's reason text, accurate for
that release. It is `packages/spec` history, not live advice.

**Read and left, because each is correct:** `audience-posture.ts:221`
(it says "unless noted", then notes the `open` exception);
`auth-manager.ts:1639` (it carries the objectstack-ai#20389 exception); the runtime
log at `auth-manager.ts:4818` ("… when email verification is forced on"
is a sufficient condition, not a rule statement);
`packages/spec/src/system/auth-config.zod.ts` lines 408–419 and 588;
`content/docs/deployment/self-hosting.mdx:580` and its posture section;
`content/docs/deployment/environment-variables.mdx:84`; and the
`audience-posture*.test.ts` assertions, which pin the refusal messages
as they stand.

`auth-plugin.ts` has no hit, so nothing reaches the file objectstack-ai#20412 holds.

---

_Generated by [Claude
Code](https://claude.ai/code/session_017B6YKCGu8CTY2KBWgwaHAs)_

Co-authored-by: Claude <noreply@anthropic.com>
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 protocol:system size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

plugin-auth: under the open audience posture, honour an explicit requireEmailVerification: false (email_domain stays forced)

1 participant