fix(auth): an invitation reads the invitee's own sys_user.locale when the address already holds a row - #15119

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-14641-invitation-locale-rung
Sep 4, 2026
Merged

fix(auth): an invitation reads the invitee's own sys_user.locale when the address already holds a row#15119
os-sales merged 2 commits into
mainfrom
claude/issue-14641-invitation-locale-rung

Conversation

@claude

@claudeclaudeBot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Fixes#14641

What this changes

The four auth sends whose requester is the recipient gained a per-recipient language rung in #14762 (sys_user.locale, ruled on #13881). The two invitation sends did not, and the recorded reason was structural rather than an oversight: an invitee generally has no sys_user row until they accept, so there is no stored language to read — and the inviter'sAccept-Language is the wrong authority, since an English-speaking admin would then silently send English invitations to a Chinese-language workspace's new hires.

That reason covers only one of the two populations an invitation actually reaches. Both invitation sends now take a two-branch shape:

  1. the address / phone number already carries a sys_user row whose locale is set — an existing platform user invited into a second organization, or a re-invitation → that row's locale wins;
  2. a genuinely new invitee with no row → the deployment default stands, because their language is still truly unknown at invitation time. So does an invitee whose row exists but names no language: an unset column is not a choice.

The inviter direction stays rejected on both branches.#13881's ruling item 3 fixes the chain as recipient locale → deployment default; what opened here is the invitee's own column, never the inviter's header. That abstention is now pinned against a manager that has the top rung wired, which is a stronger pin than the old one against a manager with no rung at all.

The card's terminal state, as implemented

Terminal state C reads 「邀请邮件按被邀请人存储的语言选模板」. Taken literally that cannot hold for every invitee, because most invitees have no row to read. The two-branch shape above is the implementable reading of the ruled state — a clarification, not a re-opened option.

Branch 1 is reachable — measured, not assumed

This was the card's stop-and-report condition, so it was measured first.

  • Email. better-auth's create-invitation route rejects only an address that is already a member of this organization (USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION, plugins/organization/routes/crud-invites.mjs:124-127 in the installed 1.7.2). An existing account invited into another org reaches sendInvitationEmail normally, and so does the resend branch (:150). Accepting an invitation in fact requires a session whose email matches (:269), so an invitee who already holds an account is the mainstream flow, not an edge case.
  • SMS.sendPhoneInviteSms reaches a row by construction: its one in-repo caller, the identity import endpoint's invite policy, creates the account and only then sends the message.

⚠️What the SMS path yields today — stated precisely, because a changeset becomes release notes. The rung is wired there and reads the row whenever the row carries a locale, but admin-import-users.ts never writes locale (0 occurrences; positive control: sendInviteSms twice in the same file) and sys_user.locale declares no column default (positive control: defaultValue appears 15 times elsewhere in that object). So on the only in-repo caller the column is empty at send time and the invitation SMS still resolves to the deployment default — unchanged for that flow. What the rung buys there is that an out-of-repo caller, or a future import that populates locale, is read rather than ignored. The behaviour users see change today is on the invitation email.

Scope: both invitation surfaces, per the repo's own record

The card title names the invitation email, but three shipped in-repo comments assign the SMS invite rung to this card by number — auth-manager.ts (setDefaultSmsLocale, renderPhoneSmsBody), phone-sms-texts.ts:25, and the pin auth-manager.test.ts carried as the SMS INVITE path is untouched — its rung is #14641's. Same defect class, same gate family, same file. Leaving it would have left the card's own named surface half done.

Matching is exact, and that is safe here rather than merely tolerable

better-auth lowercases the invitee address on the invite route and the stored user.email on sign-up (api/routes/sign-up.mjs:166), so both sides of the predicate are already in the same case. email and phone_number are both unique: true in the user table sys_user is backed by (@better-auth/coredb/get-tables.mjs). An address that resolves no row lands on the deployment default — the documented floor, not a failure — and, as everywhere else on this ladder, a failing recipient read never blocks a send.

Pins

Both branches are pinned on both surfaces, and every locale pin asserts in both directions — the marker for the locale that should have won present, the marker for the other absent — with the two tags swapped in a second case so neither can be the tag that always wins.

The SMS pins assert on genuinely different rendered text (账号已开通 vs Sign in with this phone number, the two built-in invite bodies).

Branch 2's default is shown to come from the default path, not from branch 1 silently resolving nothing. Both outcomes are otherwise identical from the payload, so each branch-2 pin drives one engine, one table, two addresses: it answers zh-CN for the address that carries a row and nothing for the one that does not, in the same test. That separates "the read ran and found nothing" from "the read never ran" / "this engine answers nothing". The read predicate is asserted separately (where, fields, context.isSystem), including that the address read is the invitee's and not the inviter's — the inviter is given a row with a different language, and it must not be reached at all.

Also re-pinned: the #2766 placeholder-address refusal still happens before any recipient read, now that a read sits on this path.

Ablation

Both rungs reverted (emailLocaleArg(undefined, storedLocale)emailLocaleArg(); the SMS storedLocale argument dropped) while keeping both reads, so the ablation isolates the rung rather than the lookup.

  • Predicted: 9 red — 6 in auth-email-locale.test.ts, 3 in auth-manager.test.ts; with the inviter-direction pin and the read-predicate pins staying green, because they measure the abstention and the read, not the rung.
  • Measured: 9 red, Tests 9 failed | 323 passed (332) — the same nine, test for test. No divergence.

The mutation was confirmed on disk before measuring (injected marker count 2, the removed spellings at 0 and 1 — the OTP site legitimately keeps its own storedLocale argument — and the blob hash differing from HEAD). The restore leg was proven the same way, not by the trap firing: git diff HEAD empty, git hash-object byte-identical to the HEAD blob, and zero markers left. No rebuild was needed — the suites import the package's own src/, not its dist/.

Verification

  • pnpm --filter @objectstack/plugin-auth test93 files / 1974 tests passed.
  • pnpm --filter @objectstack/plugin-auth run typecheck — green, including check:test-typecheck (test-typecheck debt ledger held: 10 files / 94 errors / 23 pinned signatures, unchanged). ⚠️ Worth stating because it would otherwise read as covered when it is not: the package's maintsc --noEmit program excludes*.test.ts (--listFiles → 0 hits for both edited test files). It is check:test-typecheck, under tsconfig.test.json, that compiles them — --listFiles confirms both are in that program.
  • pnpm --filter @objectstack/plugin-email exec vitest run src/auth-templates-locales.test.ts — 34 passed; it owns the template-rows half and asserts no invitation locale rule.
  • Derived gate family (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, which takes the change set from the merge base itself): 74 commands, 72 green, plus check-adr-anchors green (always-runs tail, excluded from the derived family).
  • All of the above ran on d23d9664ef, which is this branch's head.

Gates answering NOT MEASURED (exit 3), and whether this diff can move them

Neither is a finding, and neither is a pass:

  • check:dual-build-cjs-loads — exit 3, PREREQUISITE NOT MET: it reads built output and 41 packages have no dist/ in this worktree. This diff cannot move it — it adds no export and edits no package.json. CI builds the repo and measures it there.
  • check-test-completeness — exit 3 by construction: it needs a saved turbo run test log, and the derived family names it with no argument. Its own output says to record it as NOT MEASURED.

check:skill-examples first exited 1 for the same prerequisite reason (packages/client-react/dist held no declarations — a prerequisite wearing a finding's exit code, not a red). Building client-react and client converted it into a real reading: 257 prose examples type-check across 3 surfaces.

ESLint — a declared narrowing, not a skip

Repo-scale pnpm lint is CI's run. Locally this is a narrowing with its three pieces of evidence:

  1. Population read from ESLint itself, not guessed: run over all 6 changed paths, ESLint reports the .md and .mdx as File ignored because no matching configuration was supplied — so its population among these changes is the 4 TypeScript files.
  2. Counts read from --format json: 6 results, 0 errors, the only 2 messages being those two "ignored" notices. The 4 linted files are clean.
  3. Config invariance for untouched files: eslint.config.mjs states, with its own recorded positive control (lines 326-335), that this repo "never enables type-aware linting (no parserOptions.project, no typed @typescript-eslint rules) for ANY file, test or not". Every rule is therefore per-file syntactic, and this diff cannot move a verdict on a file it did not touch.

The .md/.mdx are covered instead by the docs gates in the derived family — check:corpus-claim-drift, check:doc-authoring, check:doc-anchors, check:docs-single-h1, check:docs-audit-scope, check:doc-frontmatter, check:docs-section-name — all green.

Docs

content/docs/permissions/authentication.mdx said "The invitation SMS reads the deployment default alone." This change makes that sentence false, so it is corrected in this PR: both SMS bodies now resolve the same way, with a note that for the invitation the account normally does exist because identity import creates it before sending.

Swept content/docs/** for any other statement of the invitation locale rule. Zero further hits, with a positive control: the term invitation is live in the corpus (16 occurrences in authentication.mdx alone, hits across 10 pages), so the zero is a reading rather than a broken search. No shipped page states the invitation email locale rule — the auth email ladder is undocumented as a whole, so nothing there is falsified.

content/docs/releases/v17.mdx mentions invitations but states no locale rule for them, and is release-owned in any case — not touched.

Clause ②: YESneeds:contract-review applied, and deliberately not cleared

This diff makes a falsifiable contract-semantics claim rather than an operator-list or spelling change. It extends the #13881 / #14788-ruled resolution ladder onto a send the ruling record had explicitly carved out, and it does so on a two-branch reading that no ruling states verbatim — the reading is a clarification this lane authored to make the ruled terminal state implementable at all. The accept set of "which locale an invitation renders in" changes for a real population (every invitee who already holds an account).

Against that: no packages/spec edit, no new public export, no signature change, and the changeset is a patch. The carrier is applied for the semantics claim, not for a surface widening. Per the dispatch, this lane does not clear it — the seat clears it after review.

Changeset

.changeset/invitation-invitee-stored-locale.md@objectstack/plugin-auth: patch.

Patch round 2 — Clause ② contract review PASSED

The review was adversarial and mutation-based: it wired the inviter direction and confirmed the abstention pin fails (2 red, exactly as it should). It confirmed branch 1 reachable on both surfaces, the locale discrimination genuine (present-AND-absent, tags swapped), scope clean (deliverPhoneOtp, the four #15107 sends and phoneSmsLocaleChain all untouched), the docs replacement accurate including the no-row case, and no stale shipped statement left behind.

Two wording items followed, both landed in 914eec5c80 — no behaviour, assertion or fixture change:

  1. The SMS-branch overstatement above, corrected here and in the changeset and the three code comments that carried it (sendPhoneInviteSms, setDefaultSmsLocale, phone-sms-texts.ts). content/docs/** deliberately untouched — the review found the shipped sentence literally accurate as written.
  2. Test provenance. The five SMS invitation pins were nested inside describe('#14762 …'), so their reporter path credited the wrong card. They now sit in their own sibling describe naming finding(plugin-auth): 邀请邮件的语言仍取部署默认——等用户级语言列落地后给邀请单独一梯级(#14319 裁 A-now/C-later 的追踪) #14641. A move only: no re-indentation, and no expect / await / const line in the diff. Verified with vitest list — all five now read … > #14641 — the invitation SMS reads the invitee's own locale > …, and the #14762 block retains exactly its 7 OTP tests.

Re-verified on 914eec5c80: 332/332 on the two suites (matching the review's baseline exactly), typecheck green with the test-typecheck debt ledger unchanged (10 files / 94 errors / 23 pinned signatures), and the changeset / comment-mask / census / keyed-text / nul-byte ratchet gates all green on the committed head.

The review also recorded one limit that is not this PR's to fix: on the invitation surface, "branch 2 took the default path" and "branch 1 resolved to nothing" are observationally identical, and only #14762's existing pin distinguishes them — the #14641 block alone cannot, in principle.

needs:contract-review stays applied: the review passed, and the seat clears the carrier as the last step before arming.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8


Generated by Claude Code

…en the address already holds a row
The two invitation sends kept the deployment rung while the other four auth
sends gained a per-recipient one in #14762. The recorded reason — an invitee
has no `sys_user` row until acceptance — covers only one of the two
populations an invitation reaches, so both sends now take a two-branch shape:
1. an address / phone number that ALREADY carries a row (an existing user
invited into a second organization, a re-invitation, or an imported
phone-only account) uses that row's `locale`;
2. a genuinely new invitee with no row keeps the deployment default.
The inviter direction stays rejected on both branches, now pinned against a
manager that HAS the top rung wired rather than one with no rung at all.
Reachability of branch 1 is measured, not assumed: better-auth's
`create-invitation` rejects only an address already a member of THIS org
(`routes/crud-invites.mjs`, installed 1.7.2), and the SMS invite's one in-repo
caller creates the account before it sends.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@github-actions

github-actionsBot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 3 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/phone-sms-texts.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • 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))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/phone-sms-texts.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 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 f594e70d796cbdd8e751c30105ede566acfec584packageMentionDocs.

Which tree this was computed on

This run read content/docs from 44226537f30f7cc46ce5f82834776a55082a0570 — the merge of head 914eec5c802f51d741b1d4f60ecc221f87228834 into base f594e70d796cbdd8e751c30105ede566acfec584, 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 44226537f30f7cc46ce5f82834776a55082a0570 && git checkout 44226537f30f7cc46ce5f82834776a55082a0570
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f594e70d796cbdd8e751c30105ede566acfec584 914eec5c802f51d741b1d4f60ecc221f87228834 && git checkout -B drift-repro f594e70d796cbdd8e751c30105ede566acfec584 && git merge --no-ff 914eec5c802f51d741b1d4f60ecc221f87228834
node scripts/docs-audit/affected-docs.mjs --json f594e70d796cbdd8e751c30105ede566acfec584

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

… pins their own #14641 describe
Two wording items from the Clause-2 contract review, which passed. No
behaviour, assertion or fixture changes.
1. The changeset claimed an imported phone-only account's `locale` wins on the
SMS invite path. Re-measured: `admin-import-users.ts` contains 0
occurrences of `locale` (positive control: `sendInviteSms` twice in the
same file) and `sys_user.locale` declares no column default (positive
control: `defaultValue` appears 15 times elsewhere in that object). So on
the only in-repo caller the row exists but its column is empty at send
time, and that flow still resolves to the deployment default — the
pre-change behaviour. The rung is wired and answers for an out-of-repo
caller or a future import that populates the column; a changeset becomes
release notes, so it now says exactly that. The same overstatement is
corrected in the three code comments carrying it (`sendPhoneInviteSms`,
`setDefaultSmsLocale`, `phone-sms-texts.ts`).
2. The five SMS invitation pins were nested inside `describe('#14762 ...')`,
so their reporter path credited the wrong card. They now sit in their own
sibling describe naming #14641. Move only — no re-indentation, and no
`expect`/`await`/`const` line in the diff.
`content/docs/**` deliberately untouched: the review found the shipped
sentence literally accurate as written.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@hotlongClaude

Copy link
Copy Markdown
Contributor

Director audit (summon 13, session_01WXyGTWPbbreqXow7Z2pZCk), head 914eec5c8: read the diff and the in-lane review record in the body; concur with PASS. The two-branch reading is the only implementable form of terminal state C; the inviter direction stays refused and is now pinned against a manager with the rung wired; the SMS-invite scope follows the tree's own comments assigning it to this card; the docs sentence and the changeset are accurate as amended in round 2. The release action stays with the services seat, whose in-seat review owns the landing. If the head is green and still unlanded at this seat's next check-in, the stall clause applies and this seat lands it on the seat's PASS.


Generated by Claude Code

@os-sales
os-sales marked this pull request as ready for review September 4, 2026 01:37
@claude

claudeBot commented Sep 4, 2026

Copy link
Copy Markdown
ContributorAuthor

Landing — contract review PASSED, patch round 2 verified, carriers cleared, armed

domain:services execution seat, session session_01AUF1NoViznQK32gqpK8wS8.

CI on head 914eec5c802f: all checks complete — 32 green, 2 skipped, 0 failures, 0 incomplete, read at job level with the latest run per check name. All six Test Core shards passed individually; the aggregator is not a substitute for reading them.

Landing order observed: carriers cleared first (both needs:contract-review — on this PR and on card #14641 — each a read-modify-write with a comparative read-back, both MATCH), then undraft with draft=false confirmed on read-back, then auto-merge armed at 01:37:23Z. ⛔ One re-arm maximum, unspent.

What patch round 2 corrected, and what it found that nobody ordered

The Clause-② contract review (PASS, verdict adopted at 14641#issuecomment-5534181333) found the changeset claiming "an imported phone-only account — the row's locale wins". That is false for the only in-repo caller, re-measured by this seat before ordering the fix: admin-import-users.ts contains 0 occurrences of locale (positive control: sendInviteSms twice in the same file), and sys_user.locale declares no column default. A changeset becomes release notes, so it would have shipped a claim users do not get.

The dev extended the fix past the two places it was given. The same overstatement sat in two sibling comments it had authored the round before (setDefaultSmsLocale, phone-sms-texts.ts) and in the PR body — which is what reviewers actually read. Its reasoning: leaving an identical false claim next door defeats the correction. Verified here — purest form and imported phone-only now return 0 in the PR body and in both source files (control: sendPhoneInviteSms still resolves, 1 hit).

The test move was proved, not asserted.vitest list places all five SMS pins under the new #14641 describe, and the #14762 block retains exactly its 7 OTP tests — 7 + 5 = the 12 that were nested before, so nothing was captured or dropped. A pass count cannot see that; the reporter path can. Suite count 332/332, matching the review's independent baseline exactly, and the diff filtered to expect( / await / const lines is empty for that file.

No ablation re-run, deliberately and stated: the round changes no executable line, so round 1's result (predicted 9 red, measured 9, test-for-test) still describes this tree — and the review re-derived the same conclusion independently by mutation, wiring the inviter direction and getting exactly the 2 red it should.

Recorded so it does not evaporate

On the invitation surface, "branch 2 took the default path" and "branch 1 resolved to nothing" are observationally identical — no request rung sits between them. Only #14762's existing pin distinguishes them; the #14641 block alone cannot, and cannot in principle. This is a limit of the surface, not a defect in the pins, and it is carried in the PR body as well as here.

On MERGED, pm:dispatched comes off card #14641.


Generated by Claude Code

@os-sales
os-sales added this pull request to the merge queueSep 4, 2026
Merged via the queue into main with commit f074616Sep 4, 2026
42 checks passed
@os-sales
os-sales deleted the claude/issue-14641-invitation-locale-rung branch September 4, 2026 02:04
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

3 participants

@hotlong@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(auth): an invitation reads the invitee's own sys_user.locale when the address already holds a row - #15119

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-14641-invitation-locale-rung
Sep 4, 2026
Merged

fix(auth): an invitation reads the invitee's own sys_user.locale when the address already holds a row#15119
os-sales merged 2 commits into
mainfrom
claude/issue-14641-invitation-locale-rung

Conversation

@claude

@claudeclaudeBot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Fixes#14641

What this changes

The four auth sends whose requester is the recipient gained a per-recipient language rung in #14762 (sys_user.locale, ruled on #13881). The two invitation sends did not, and the recorded reason was structural rather than an oversight: an invitee generally has no sys_user row until they accept, so there is no stored language to read — and the inviter'sAccept-Language is the wrong authority, since an English-speaking admin would then silently send English invitations to a Chinese-language workspace's new hires.

That reason covers only one of the two populations an invitation actually reaches. Both invitation sends now take a two-branch shape:

  1. the address / phone number already carries a sys_user row whose locale is set — an existing platform user invited into a second organization, or a re-invitation → that row's locale wins;
  2. a genuinely new invitee with no row → the deployment default stands, because their language is still truly unknown at invitation time. So does an invitee whose row exists but names no language: an unset column is not a choice.

The inviter direction stays rejected on both branches.#13881's ruling item 3 fixes the chain as recipient locale → deployment default; what opened here is the invitee's own column, never the inviter's header. That abstention is now pinned against a manager that has the top rung wired, which is a stronger pin than the old one against a manager with no rung at all.

The card's terminal state, as implemented

Terminal state C reads 「邀请邮件按被邀请人存储的语言选模板」. Taken literally that cannot hold for every invitee, because most invitees have no row to read. The two-branch shape above is the implementable reading of the ruled state — a clarification, not a re-opened option.

Branch 1 is reachable — measured, not assumed

This was the card's stop-and-report condition, so it was measured first.

  • Email. better-auth's create-invitation route rejects only an address that is already a member of this organization (USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION, plugins/organization/routes/crud-invites.mjs:124-127 in the installed 1.7.2). An existing account invited into another org reaches sendInvitationEmail normally, and so does the resend branch (:150). Accepting an invitation in fact requires a session whose email matches (:269), so an invitee who already holds an account is the mainstream flow, not an edge case.
  • SMS.sendPhoneInviteSms reaches a row by construction: its one in-repo caller, the identity import endpoint's invite policy, creates the account and only then sends the message.

⚠️What the SMS path yields today — stated precisely, because a changeset becomes release notes. The rung is wired there and reads the row whenever the row carries a locale, but admin-import-users.ts never writes locale (0 occurrences; positive control: sendInviteSms twice in the same file) and sys_user.locale declares no column default (positive control: defaultValue appears 15 times elsewhere in that object). So on the only in-repo caller the column is empty at send time and the invitation SMS still resolves to the deployment default — unchanged for that flow. What the rung buys there is that an out-of-repo caller, or a future import that populates locale, is read rather than ignored. The behaviour users see change today is on the invitation email.

Scope: both invitation surfaces, per the repo's own record

The card title names the invitation email, but three shipped in-repo comments assign the SMS invite rung to this card by number — auth-manager.ts (setDefaultSmsLocale, renderPhoneSmsBody), phone-sms-texts.ts:25, and the pin auth-manager.test.ts carried as the SMS INVITE path is untouched — its rung is #14641's. Same defect class, same gate family, same file. Leaving it would have left the card's own named surface half done.

Matching is exact, and that is safe here rather than merely tolerable

better-auth lowercases the invitee address on the invite route and the stored user.email on sign-up (api/routes/sign-up.mjs:166), so both sides of the predicate are already in the same case. email and phone_number are both unique: true in the user table sys_user is backed by (@better-auth/coredb/get-tables.mjs). An address that resolves no row lands on the deployment default — the documented floor, not a failure — and, as everywhere else on this ladder, a failing recipient read never blocks a send.

Pins

Both branches are pinned on both surfaces, and every locale pin asserts in both directions — the marker for the locale that should have won present, the marker for the other absent — with the two tags swapped in a second case so neither can be the tag that always wins.

The SMS pins assert on genuinely different rendered text (账号已开通 vs Sign in with this phone number, the two built-in invite bodies).

Branch 2's default is shown to come from the default path, not from branch 1 silently resolving nothing. Both outcomes are otherwise identical from the payload, so each branch-2 pin drives one engine, one table, two addresses: it answers zh-CN for the address that carries a row and nothing for the one that does not, in the same test. That separates "the read ran and found nothing" from "the read never ran" / "this engine answers nothing". The read predicate is asserted separately (where, fields, context.isSystem), including that the address read is the invitee's and not the inviter's — the inviter is given a row with a different language, and it must not be reached at all.

Also re-pinned: the #2766 placeholder-address refusal still happens before any recipient read, now that a read sits on this path.

Ablation

Both rungs reverted (emailLocaleArg(undefined, storedLocale)emailLocaleArg(); the SMS storedLocale argument dropped) while keeping both reads, so the ablation isolates the rung rather than the lookup.

  • Predicted: 9 red — 6 in auth-email-locale.test.ts, 3 in auth-manager.test.ts; with the inviter-direction pin and the read-predicate pins staying green, because they measure the abstention and the read, not the rung.
  • Measured: 9 red, Tests 9 failed | 323 passed (332) — the same nine, test for test. No divergence.

The mutation was confirmed on disk before measuring (injected marker count 2, the removed spellings at 0 and 1 — the OTP site legitimately keeps its own storedLocale argument — and the blob hash differing from HEAD). The restore leg was proven the same way, not by the trap firing: git diff HEAD empty, git hash-object byte-identical to the HEAD blob, and zero markers left. No rebuild was needed — the suites import the package's own src/, not its dist/.

Verification

  • pnpm --filter @objectstack/plugin-auth test93 files / 1974 tests passed.
  • pnpm --filter @objectstack/plugin-auth run typecheck — green, including check:test-typecheck (test-typecheck debt ledger held: 10 files / 94 errors / 23 pinned signatures, unchanged). ⚠️ Worth stating because it would otherwise read as covered when it is not: the package's maintsc --noEmit program excludes*.test.ts (--listFiles → 0 hits for both edited test files). It is check:test-typecheck, under tsconfig.test.json, that compiles them — --listFiles confirms both are in that program.
  • pnpm --filter @objectstack/plugin-email exec vitest run src/auth-templates-locales.test.ts — 34 passed; it owns the template-rows half and asserts no invitation locale rule.
  • Derived gate family (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, which takes the change set from the merge base itself): 74 commands, 72 green, plus check-adr-anchors green (always-runs tail, excluded from the derived family).
  • All of the above ran on d23d9664ef, which is this branch's head.

Gates answering NOT MEASURED (exit 3), and whether this diff can move them

Neither is a finding, and neither is a pass:

  • check:dual-build-cjs-loads — exit 3, PREREQUISITE NOT MET: it reads built output and 41 packages have no dist/ in this worktree. This diff cannot move it — it adds no export and edits no package.json. CI builds the repo and measures it there.
  • check-test-completeness — exit 3 by construction: it needs a saved turbo run test log, and the derived family names it with no argument. Its own output says to record it as NOT MEASURED.

check:skill-examples first exited 1 for the same prerequisite reason (packages/client-react/dist held no declarations — a prerequisite wearing a finding's exit code, not a red). Building client-react and client converted it into a real reading: 257 prose examples type-check across 3 surfaces.

ESLint — a declared narrowing, not a skip

Repo-scale pnpm lint is CI's run. Locally this is a narrowing with its three pieces of evidence:

  1. Population read from ESLint itself, not guessed: run over all 6 changed paths, ESLint reports the .md and .mdx as File ignored because no matching configuration was supplied — so its population among these changes is the 4 TypeScript files.
  2. Counts read from --format json: 6 results, 0 errors, the only 2 messages being those two "ignored" notices. The 4 linted files are clean.
  3. Config invariance for untouched files: eslint.config.mjs states, with its own recorded positive control (lines 326-335), that this repo "never enables type-aware linting (no parserOptions.project, no typed @typescript-eslint rules) for ANY file, test or not". Every rule is therefore per-file syntactic, and this diff cannot move a verdict on a file it did not touch.

The .md/.mdx are covered instead by the docs gates in the derived family — check:corpus-claim-drift, check:doc-authoring, check:doc-anchors, check:docs-single-h1, check:docs-audit-scope, check:doc-frontmatter, check:docs-section-name — all green.

Docs

content/docs/permissions/authentication.mdx said "The invitation SMS reads the deployment default alone." This change makes that sentence false, so it is corrected in this PR: both SMS bodies now resolve the same way, with a note that for the invitation the account normally does exist because identity import creates it before sending.

Swept content/docs/** for any other statement of the invitation locale rule. Zero further hits, with a positive control: the term invitation is live in the corpus (16 occurrences in authentication.mdx alone, hits across 10 pages), so the zero is a reading rather than a broken search. No shipped page states the invitation email locale rule — the auth email ladder is undocumented as a whole, so nothing there is falsified.

content/docs/releases/v17.mdx mentions invitations but states no locale rule for them, and is release-owned in any case — not touched.

Clause ②: YESneeds:contract-review applied, and deliberately not cleared

This diff makes a falsifiable contract-semantics claim rather than an operator-list or spelling change. It extends the #13881 / #14788-ruled resolution ladder onto a send the ruling record had explicitly carved out, and it does so on a two-branch reading that no ruling states verbatim — the reading is a clarification this lane authored to make the ruled terminal state implementable at all. The accept set of "which locale an invitation renders in" changes for a real population (every invitee who already holds an account).

Against that: no packages/spec edit, no new public export, no signature change, and the changeset is a patch. The carrier is applied for the semantics claim, not for a surface widening. Per the dispatch, this lane does not clear it — the seat clears it after review.

Changeset

.changeset/invitation-invitee-stored-locale.md@objectstack/plugin-auth: patch.

Patch round 2 — Clause ② contract review PASSED

The review was adversarial and mutation-based: it wired the inviter direction and confirmed the abstention pin fails (2 red, exactly as it should). It confirmed branch 1 reachable on both surfaces, the locale discrimination genuine (present-AND-absent, tags swapped), scope clean (deliverPhoneOtp, the four #15107 sends and phoneSmsLocaleChain all untouched), the docs replacement accurate including the no-row case, and no stale shipped statement left behind.

Two wording items followed, both landed in 914eec5c80 — no behaviour, assertion or fixture change:

  1. The SMS-branch overstatement above, corrected here and in the changeset and the three code comments that carried it (sendPhoneInviteSms, setDefaultSmsLocale, phone-sms-texts.ts). content/docs/** deliberately untouched — the review found the shipped sentence literally accurate as written.
  2. Test provenance. The five SMS invitation pins were nested inside describe('#14762 …'), so their reporter path credited the wrong card. They now sit in their own sibling describe naming finding(plugin-auth): 邀请邮件的语言仍取部署默认——等用户级语言列落地后给邀请单独一梯级(#14319 裁 A-now/C-later 的追踪) #14641. A move only: no re-indentation, and no expect / await / const line in the diff. Verified with vitest list — all five now read … > #14641 — the invitation SMS reads the invitee's own locale > …, and the #14762 block retains exactly its 7 OTP tests.

Re-verified on 914eec5c80: 332/332 on the two suites (matching the review's baseline exactly), typecheck green with the test-typecheck debt ledger unchanged (10 files / 94 errors / 23 pinned signatures), and the changeset / comment-mask / census / keyed-text / nul-byte ratchet gates all green on the committed head.

The review also recorded one limit that is not this PR's to fix: on the invitation surface, "branch 2 took the default path" and "branch 1 resolved to nothing" are observationally identical, and only #14762's existing pin distinguishes them — the #14641 block alone cannot, in principle.

needs:contract-review stays applied: the review passed, and the seat clears the carrier as the last step before arming.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8


Generated by Claude Code

…en the address already holds a row
The two invitation sends kept the deployment rung while the other four auth
sends gained a per-recipient one in #14762. The recorded reason — an invitee
has no `sys_user` row until acceptance — covers only one of the two
populations an invitation reaches, so both sends now take a two-branch shape:
1. an address / phone number that ALREADY carries a row (an existing user
invited into a second organization, a re-invitation, or an imported
phone-only account) uses that row's `locale`;
2. a genuinely new invitee with no row keeps the deployment default.
The inviter direction stays rejected on both branches, now pinned against a
manager that HAS the top rung wired rather than one with no rung at all.
Reachability of branch 1 is measured, not assumed: better-auth's
`create-invitation` rejects only an address already a member of THIS org
(`routes/crud-invites.mjs`, installed 1.7.2), and the SMS invite's one in-repo
caller creates the account before it sends.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@github-actions

github-actionsBot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 3 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/phone-sms-texts.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • 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))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/phone-sms-texts.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 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 f594e70d796cbdd8e751c30105ede566acfec584packageMentionDocs.

Which tree this was computed on

This run read content/docs from 44226537f30f7cc46ce5f82834776a55082a0570 — the merge of head 914eec5c802f51d741b1d4f60ecc221f87228834 into base f594e70d796cbdd8e751c30105ede566acfec584, 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 44226537f30f7cc46ce5f82834776a55082a0570 && git checkout 44226537f30f7cc46ce5f82834776a55082a0570
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f594e70d796cbdd8e751c30105ede566acfec584 914eec5c802f51d741b1d4f60ecc221f87228834 && git checkout -B drift-repro f594e70d796cbdd8e751c30105ede566acfec584 && git merge --no-ff 914eec5c802f51d741b1d4f60ecc221f87228834
node scripts/docs-audit/affected-docs.mjs --json f594e70d796cbdd8e751c30105ede566acfec584

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

… pins their own #14641 describe
Two wording items from the Clause-2 contract review, which passed. No
behaviour, assertion or fixture changes.
1. The changeset claimed an imported phone-only account's `locale` wins on the
SMS invite path. Re-measured: `admin-import-users.ts` contains 0
occurrences of `locale` (positive control: `sendInviteSms` twice in the
same file) and `sys_user.locale` declares no column default (positive
control: `defaultValue` appears 15 times elsewhere in that object). So on
the only in-repo caller the row exists but its column is empty at send
time, and that flow still resolves to the deployment default — the
pre-change behaviour. The rung is wired and answers for an out-of-repo
caller or a future import that populates the column; a changeset becomes
release notes, so it now says exactly that. The same overstatement is
corrected in the three code comments carrying it (`sendPhoneInviteSms`,
`setDefaultSmsLocale`, `phone-sms-texts.ts`).
2. The five SMS invitation pins were nested inside `describe('#14762 ...')`,
so their reporter path credited the wrong card. They now sit in their own
sibling describe naming #14641. Move only — no re-indentation, and no
`expect`/`await`/`const` line in the diff.
`content/docs/**` deliberately untouched: the review found the shipped
sentence literally accurate as written.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@hotlongClaude

Copy link
Copy Markdown
Contributor

Director audit (summon 13, session_01WXyGTWPbbreqXow7Z2pZCk), head 914eec5c8: read the diff and the in-lane review record in the body; concur with PASS. The two-branch reading is the only implementable form of terminal state C; the inviter direction stays refused and is now pinned against a manager with the rung wired; the SMS-invite scope follows the tree's own comments assigning it to this card; the docs sentence and the changeset are accurate as amended in round 2. The release action stays with the services seat, whose in-seat review owns the landing. If the head is green and still unlanded at this seat's next check-in, the stall clause applies and this seat lands it on the seat's PASS.


Generated by Claude Code

@os-sales
os-sales marked this pull request as ready for review September 4, 2026 01:37
@claude

claudeBot commented Sep 4, 2026

Copy link
Copy Markdown
ContributorAuthor

Landing — contract review PASSED, patch round 2 verified, carriers cleared, armed

domain:services execution seat, session session_01AUF1NoViznQK32gqpK8wS8.

CI on head 914eec5c802f: all checks complete — 32 green, 2 skipped, 0 failures, 0 incomplete, read at job level with the latest run per check name. All six Test Core shards passed individually; the aggregator is not a substitute for reading them.

Landing order observed: carriers cleared first (both needs:contract-review — on this PR and on card #14641 — each a read-modify-write with a comparative read-back, both MATCH), then undraft with draft=false confirmed on read-back, then auto-merge armed at 01:37:23Z. ⛔ One re-arm maximum, unspent.

What patch round 2 corrected, and what it found that nobody ordered

The Clause-② contract review (PASS, verdict adopted at 14641#issuecomment-5534181333) found the changeset claiming "an imported phone-only account — the row's locale wins". That is false for the only in-repo caller, re-measured by this seat before ordering the fix: admin-import-users.ts contains 0 occurrences of locale (positive control: sendInviteSms twice in the same file), and sys_user.locale declares no column default. A changeset becomes release notes, so it would have shipped a claim users do not get.

The dev extended the fix past the two places it was given. The same overstatement sat in two sibling comments it had authored the round before (setDefaultSmsLocale, phone-sms-texts.ts) and in the PR body — which is what reviewers actually read. Its reasoning: leaving an identical false claim next door defeats the correction. Verified here — purest form and imported phone-only now return 0 in the PR body and in both source files (control: sendPhoneInviteSms still resolves, 1 hit).

The test move was proved, not asserted.vitest list places all five SMS pins under the new #14641 describe, and the #14762 block retains exactly its 7 OTP tests — 7 + 5 = the 12 that were nested before, so nothing was captured or dropped. A pass count cannot see that; the reporter path can. Suite count 332/332, matching the review's independent baseline exactly, and the diff filtered to expect( / await / const lines is empty for that file.

No ablation re-run, deliberately and stated: the round changes no executable line, so round 1's result (predicted 9 red, measured 9, test-for-test) still describes this tree — and the review re-derived the same conclusion independently by mutation, wiring the inviter direction and getting exactly the 2 red it should.

Recorded so it does not evaporate

On the invitation surface, "branch 2 took the default path" and "branch 1 resolved to nothing" are observationally identical — no request rung sits between them. Only #14762's existing pin distinguishes them; the #14641 block alone cannot, and cannot in principle. This is a limit of the surface, not a defect in the pins, and it is carried in the PR body as well as here.

On MERGED, pm:dispatched comes off card #14641.


Generated by Claude Code

@os-sales
os-sales added this pull request to the merge queueSep 4, 2026
Merged via the queue into main with commit f074616Sep 4, 2026
42 checks passed
@os-sales
os-sales deleted the claude/issue-14641-invitation-locale-rung branch September 4, 2026 02:04
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

3 participants

@hotlong@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(auth): an invitation reads the invitee's own sys_user.locale when the address already holds a row - #15119

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-14641-invitation-locale-rung
Sep 4, 2026
Merged

fix(auth): an invitation reads the invitee's own sys_user.locale when the address already holds a row#15119
os-sales merged 2 commits into
mainfrom
claude/issue-14641-invitation-locale-rung

Conversation

@claude

@claudeclaudeBot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Fixes#14641

What this changes

The four auth sends whose requester is the recipient gained a per-recipient language rung in #14762 (sys_user.locale, ruled on #13881). The two invitation sends did not, and the recorded reason was structural rather than an oversight: an invitee generally has no sys_user row until they accept, so there is no stored language to read — and the inviter'sAccept-Language is the wrong authority, since an English-speaking admin would then silently send English invitations to a Chinese-language workspace's new hires.

That reason covers only one of the two populations an invitation actually reaches. Both invitation sends now take a two-branch shape:

  1. the address / phone number already carries a sys_user row whose locale is set — an existing platform user invited into a second organization, or a re-invitation → that row's locale wins;
  2. a genuinely new invitee with no row → the deployment default stands, because their language is still truly unknown at invitation time. So does an invitee whose row exists but names no language: an unset column is not a choice.

The inviter direction stays rejected on both branches.#13881's ruling item 3 fixes the chain as recipient locale → deployment default; what opened here is the invitee's own column, never the inviter's header. That abstention is now pinned against a manager that has the top rung wired, which is a stronger pin than the old one against a manager with no rung at all.

The card's terminal state, as implemented

Terminal state C reads 「邀请邮件按被邀请人存储的语言选模板」. Taken literally that cannot hold for every invitee, because most invitees have no row to read. The two-branch shape above is the implementable reading of the ruled state — a clarification, not a re-opened option.

Branch 1 is reachable — measured, not assumed

This was the card's stop-and-report condition, so it was measured first.

  • Email. better-auth's create-invitation route rejects only an address that is already a member of this organization (USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION, plugins/organization/routes/crud-invites.mjs:124-127 in the installed 1.7.2). An existing account invited into another org reaches sendInvitationEmail normally, and so does the resend branch (:150). Accepting an invitation in fact requires a session whose email matches (:269), so an invitee who already holds an account is the mainstream flow, not an edge case.
  • SMS.sendPhoneInviteSms reaches a row by construction: its one in-repo caller, the identity import endpoint's invite policy, creates the account and only then sends the message.

⚠️What the SMS path yields today — stated precisely, because a changeset becomes release notes. The rung is wired there and reads the row whenever the row carries a locale, but admin-import-users.ts never writes locale (0 occurrences; positive control: sendInviteSms twice in the same file) and sys_user.locale declares no column default (positive control: defaultValue appears 15 times elsewhere in that object). So on the only in-repo caller the column is empty at send time and the invitation SMS still resolves to the deployment default — unchanged for that flow. What the rung buys there is that an out-of-repo caller, or a future import that populates locale, is read rather than ignored. The behaviour users see change today is on the invitation email.

Scope: both invitation surfaces, per the repo's own record

The card title names the invitation email, but three shipped in-repo comments assign the SMS invite rung to this card by number — auth-manager.ts (setDefaultSmsLocale, renderPhoneSmsBody), phone-sms-texts.ts:25, and the pin auth-manager.test.ts carried as the SMS INVITE path is untouched — its rung is #14641's. Same defect class, same gate family, same file. Leaving it would have left the card's own named surface half done.

Matching is exact, and that is safe here rather than merely tolerable

better-auth lowercases the invitee address on the invite route and the stored user.email on sign-up (api/routes/sign-up.mjs:166), so both sides of the predicate are already in the same case. email and phone_number are both unique: true in the user table sys_user is backed by (@better-auth/coredb/get-tables.mjs). An address that resolves no row lands on the deployment default — the documented floor, not a failure — and, as everywhere else on this ladder, a failing recipient read never blocks a send.

Pins

Both branches are pinned on both surfaces, and every locale pin asserts in both directions — the marker for the locale that should have won present, the marker for the other absent — with the two tags swapped in a second case so neither can be the tag that always wins.

The SMS pins assert on genuinely different rendered text (账号已开通 vs Sign in with this phone number, the two built-in invite bodies).

Branch 2's default is shown to come from the default path, not from branch 1 silently resolving nothing. Both outcomes are otherwise identical from the payload, so each branch-2 pin drives one engine, one table, two addresses: it answers zh-CN for the address that carries a row and nothing for the one that does not, in the same test. That separates "the read ran and found nothing" from "the read never ran" / "this engine answers nothing". The read predicate is asserted separately (where, fields, context.isSystem), including that the address read is the invitee's and not the inviter's — the inviter is given a row with a different language, and it must not be reached at all.

Also re-pinned: the #2766 placeholder-address refusal still happens before any recipient read, now that a read sits on this path.

Ablation

Both rungs reverted (emailLocaleArg(undefined, storedLocale)emailLocaleArg(); the SMS storedLocale argument dropped) while keeping both reads, so the ablation isolates the rung rather than the lookup.

  • Predicted: 9 red — 6 in auth-email-locale.test.ts, 3 in auth-manager.test.ts; with the inviter-direction pin and the read-predicate pins staying green, because they measure the abstention and the read, not the rung.
  • Measured: 9 red, Tests 9 failed | 323 passed (332) — the same nine, test for test. No divergence.

The mutation was confirmed on disk before measuring (injected marker count 2, the removed spellings at 0 and 1 — the OTP site legitimately keeps its own storedLocale argument — and the blob hash differing from HEAD). The restore leg was proven the same way, not by the trap firing: git diff HEAD empty, git hash-object byte-identical to the HEAD blob, and zero markers left. No rebuild was needed — the suites import the package's own src/, not its dist/.

Verification

  • pnpm --filter @objectstack/plugin-auth test93 files / 1974 tests passed.
  • pnpm --filter @objectstack/plugin-auth run typecheck — green, including check:test-typecheck (test-typecheck debt ledger held: 10 files / 94 errors / 23 pinned signatures, unchanged). ⚠️ Worth stating because it would otherwise read as covered when it is not: the package's maintsc --noEmit program excludes*.test.ts (--listFiles → 0 hits for both edited test files). It is check:test-typecheck, under tsconfig.test.json, that compiles them — --listFiles confirms both are in that program.
  • pnpm --filter @objectstack/plugin-email exec vitest run src/auth-templates-locales.test.ts — 34 passed; it owns the template-rows half and asserts no invitation locale rule.
  • Derived gate family (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, which takes the change set from the merge base itself): 74 commands, 72 green, plus check-adr-anchors green (always-runs tail, excluded from the derived family).
  • All of the above ran on d23d9664ef, which is this branch's head.

Gates answering NOT MEASURED (exit 3), and whether this diff can move them

Neither is a finding, and neither is a pass:

  • check:dual-build-cjs-loads — exit 3, PREREQUISITE NOT MET: it reads built output and 41 packages have no dist/ in this worktree. This diff cannot move it — it adds no export and edits no package.json. CI builds the repo and measures it there.
  • check-test-completeness — exit 3 by construction: it needs a saved turbo run test log, and the derived family names it with no argument. Its own output says to record it as NOT MEASURED.

check:skill-examples first exited 1 for the same prerequisite reason (packages/client-react/dist held no declarations — a prerequisite wearing a finding's exit code, not a red). Building client-react and client converted it into a real reading: 257 prose examples type-check across 3 surfaces.

ESLint — a declared narrowing, not a skip

Repo-scale pnpm lint is CI's run. Locally this is a narrowing with its three pieces of evidence:

  1. Population read from ESLint itself, not guessed: run over all 6 changed paths, ESLint reports the .md and .mdx as File ignored because no matching configuration was supplied — so its population among these changes is the 4 TypeScript files.
  2. Counts read from --format json: 6 results, 0 errors, the only 2 messages being those two "ignored" notices. The 4 linted files are clean.
  3. Config invariance for untouched files: eslint.config.mjs states, with its own recorded positive control (lines 326-335), that this repo "never enables type-aware linting (no parserOptions.project, no typed @typescript-eslint rules) for ANY file, test or not". Every rule is therefore per-file syntactic, and this diff cannot move a verdict on a file it did not touch.

The .md/.mdx are covered instead by the docs gates in the derived family — check:corpus-claim-drift, check:doc-authoring, check:doc-anchors, check:docs-single-h1, check:docs-audit-scope, check:doc-frontmatter, check:docs-section-name — all green.

Docs

content/docs/permissions/authentication.mdx said "The invitation SMS reads the deployment default alone." This change makes that sentence false, so it is corrected in this PR: both SMS bodies now resolve the same way, with a note that for the invitation the account normally does exist because identity import creates it before sending.

Swept content/docs/** for any other statement of the invitation locale rule. Zero further hits, with a positive control: the term invitation is live in the corpus (16 occurrences in authentication.mdx alone, hits across 10 pages), so the zero is a reading rather than a broken search. No shipped page states the invitation email locale rule — the auth email ladder is undocumented as a whole, so nothing there is falsified.

content/docs/releases/v17.mdx mentions invitations but states no locale rule for them, and is release-owned in any case — not touched.

Clause ②: YESneeds:contract-review applied, and deliberately not cleared

This diff makes a falsifiable contract-semantics claim rather than an operator-list or spelling change. It extends the #13881 / #14788-ruled resolution ladder onto a send the ruling record had explicitly carved out, and it does so on a two-branch reading that no ruling states verbatim — the reading is a clarification this lane authored to make the ruled terminal state implementable at all. The accept set of "which locale an invitation renders in" changes for a real population (every invitee who already holds an account).

Against that: no packages/spec edit, no new public export, no signature change, and the changeset is a patch. The carrier is applied for the semantics claim, not for a surface widening. Per the dispatch, this lane does not clear it — the seat clears it after review.

Changeset

.changeset/invitation-invitee-stored-locale.md@objectstack/plugin-auth: patch.

Patch round 2 — Clause ② contract review PASSED

The review was adversarial and mutation-based: it wired the inviter direction and confirmed the abstention pin fails (2 red, exactly as it should). It confirmed branch 1 reachable on both surfaces, the locale discrimination genuine (present-AND-absent, tags swapped), scope clean (deliverPhoneOtp, the four #15107 sends and phoneSmsLocaleChain all untouched), the docs replacement accurate including the no-row case, and no stale shipped statement left behind.

Two wording items followed, both landed in 914eec5c80 — no behaviour, assertion or fixture change:

  1. The SMS-branch overstatement above, corrected here and in the changeset and the three code comments that carried it (sendPhoneInviteSms, setDefaultSmsLocale, phone-sms-texts.ts). content/docs/** deliberately untouched — the review found the shipped sentence literally accurate as written.
  2. Test provenance. The five SMS invitation pins were nested inside describe('#14762 …'), so their reporter path credited the wrong card. They now sit in their own sibling describe naming finding(plugin-auth): 邀请邮件的语言仍取部署默认——等用户级语言列落地后给邀请单独一梯级(#14319 裁 A-now/C-later 的追踪) #14641. A move only: no re-indentation, and no expect / await / const line in the diff. Verified with vitest list — all five now read … > #14641 — the invitation SMS reads the invitee's own locale > …, and the #14762 block retains exactly its 7 OTP tests.

Re-verified on 914eec5c80: 332/332 on the two suites (matching the review's baseline exactly), typecheck green with the test-typecheck debt ledger unchanged (10 files / 94 errors / 23 pinned signatures), and the changeset / comment-mask / census / keyed-text / nul-byte ratchet gates all green on the committed head.

The review also recorded one limit that is not this PR's to fix: on the invitation surface, "branch 2 took the default path" and "branch 1 resolved to nothing" are observationally identical, and only #14762's existing pin distinguishes them — the #14641 block alone cannot, in principle.

needs:contract-review stays applied: the review passed, and the seat clears the carrier as the last step before arming.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8


Generated by Claude Code

…en the address already holds a row
The two invitation sends kept the deployment rung while the other four auth
sends gained a per-recipient one in #14762. The recorded reason — an invitee
has no `sys_user` row until acceptance — covers only one of the two
populations an invitation reaches, so both sends now take a two-branch shape:
1. an address / phone number that ALREADY carries a row (an existing user
invited into a second organization, a re-invitation, or an imported
phone-only account) uses that row's `locale`;
2. a genuinely new invitee with no row keeps the deployment default.
The inviter direction stays rejected on both branches, now pinned against a
manager that HAS the top rung wired rather than one with no rung at all.
Reachability of branch 1 is measured, not assumed: better-auth's
`create-invitation` rejects only an address already a member of THIS org
(`routes/crud-invites.mjs`, installed 1.7.2), and the SMS invite's one in-repo
caller creates the account before it sends.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@github-actions

github-actionsBot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 3 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/phone-sms-texts.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • 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))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/phone-sms-texts.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 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 f594e70d796cbdd8e751c30105ede566acfec584packageMentionDocs.

Which tree this was computed on

This run read content/docs from 44226537f30f7cc46ce5f82834776a55082a0570 — the merge of head 914eec5c802f51d741b1d4f60ecc221f87228834 into base f594e70d796cbdd8e751c30105ede566acfec584, 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 44226537f30f7cc46ce5f82834776a55082a0570 && git checkout 44226537f30f7cc46ce5f82834776a55082a0570
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f594e70d796cbdd8e751c30105ede566acfec584 914eec5c802f51d741b1d4f60ecc221f87228834 && git checkout -B drift-repro f594e70d796cbdd8e751c30105ede566acfec584 && git merge --no-ff 914eec5c802f51d741b1d4f60ecc221f87228834
node scripts/docs-audit/affected-docs.mjs --json f594e70d796cbdd8e751c30105ede566acfec584

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

… pins their own #14641 describe
Two wording items from the Clause-2 contract review, which passed. No
behaviour, assertion or fixture changes.
1. The changeset claimed an imported phone-only account's `locale` wins on the
SMS invite path. Re-measured: `admin-import-users.ts` contains 0
occurrences of `locale` (positive control: `sendInviteSms` twice in the
same file) and `sys_user.locale` declares no column default (positive
control: `defaultValue` appears 15 times elsewhere in that object). So on
the only in-repo caller the row exists but its column is empty at send
time, and that flow still resolves to the deployment default — the
pre-change behaviour. The rung is wired and answers for an out-of-repo
caller or a future import that populates the column; a changeset becomes
release notes, so it now says exactly that. The same overstatement is
corrected in the three code comments carrying it (`sendPhoneInviteSms`,
`setDefaultSmsLocale`, `phone-sms-texts.ts`).
2. The five SMS invitation pins were nested inside `describe('#14762 ...')`,
so their reporter path credited the wrong card. They now sit in their own
sibling describe naming #14641. Move only — no re-indentation, and no
`expect`/`await`/`const` line in the diff.
`content/docs/**` deliberately untouched: the review found the shipped
sentence literally accurate as written.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@hotlongClaude

Copy link
Copy Markdown
Contributor

Director audit (summon 13, session_01WXyGTWPbbreqXow7Z2pZCk), head 914eec5c8: read the diff and the in-lane review record in the body; concur with PASS. The two-branch reading is the only implementable form of terminal state C; the inviter direction stays refused and is now pinned against a manager with the rung wired; the SMS-invite scope follows the tree's own comments assigning it to this card; the docs sentence and the changeset are accurate as amended in round 2. The release action stays with the services seat, whose in-seat review owns the landing. If the head is green and still unlanded at this seat's next check-in, the stall clause applies and this seat lands it on the seat's PASS.


Generated by Claude Code

@os-sales
os-sales marked this pull request as ready for review September 4, 2026 01:37
@claude

claudeBot commented Sep 4, 2026

Copy link
Copy Markdown
ContributorAuthor

Landing — contract review PASSED, patch round 2 verified, carriers cleared, armed

domain:services execution seat, session session_01AUF1NoViznQK32gqpK8wS8.

CI on head 914eec5c802f: all checks complete — 32 green, 2 skipped, 0 failures, 0 incomplete, read at job level with the latest run per check name. All six Test Core shards passed individually; the aggregator is not a substitute for reading them.

Landing order observed: carriers cleared first (both needs:contract-review — on this PR and on card #14641 — each a read-modify-write with a comparative read-back, both MATCH), then undraft with draft=false confirmed on read-back, then auto-merge armed at 01:37:23Z. ⛔ One re-arm maximum, unspent.

What patch round 2 corrected, and what it found that nobody ordered

The Clause-② contract review (PASS, verdict adopted at 14641#issuecomment-5534181333) found the changeset claiming "an imported phone-only account — the row's locale wins". That is false for the only in-repo caller, re-measured by this seat before ordering the fix: admin-import-users.ts contains 0 occurrences of locale (positive control: sendInviteSms twice in the same file), and sys_user.locale declares no column default. A changeset becomes release notes, so it would have shipped a claim users do not get.

The dev extended the fix past the two places it was given. The same overstatement sat in two sibling comments it had authored the round before (setDefaultSmsLocale, phone-sms-texts.ts) and in the PR body — which is what reviewers actually read. Its reasoning: leaving an identical false claim next door defeats the correction. Verified here — purest form and imported phone-only now return 0 in the PR body and in both source files (control: sendPhoneInviteSms still resolves, 1 hit).

The test move was proved, not asserted.vitest list places all five SMS pins under the new #14641 describe, and the #14762 block retains exactly its 7 OTP tests — 7 + 5 = the 12 that were nested before, so nothing was captured or dropped. A pass count cannot see that; the reporter path can. Suite count 332/332, matching the review's independent baseline exactly, and the diff filtered to expect( / await / const lines is empty for that file.

No ablation re-run, deliberately and stated: the round changes no executable line, so round 1's result (predicted 9 red, measured 9, test-for-test) still describes this tree — and the review re-derived the same conclusion independently by mutation, wiring the inviter direction and getting exactly the 2 red it should.

Recorded so it does not evaporate

On the invitation surface, "branch 2 took the default path" and "branch 1 resolved to nothing" are observationally identical — no request rung sits between them. Only #14762's existing pin distinguishes them; the #14641 block alone cannot, and cannot in principle. This is a limit of the surface, not a defect in the pins, and it is carried in the PR body as well as here.

On MERGED, pm:dispatched comes off card #14641.


Generated by Claude Code

@os-sales
os-sales added this pull request to the merge queueSep 4, 2026
Merged via the queue into main with commit f074616Sep 4, 2026
42 checks passed
@os-sales
os-sales deleted the claude/issue-14641-invitation-locale-rung branch September 4, 2026 02:04
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

3 participants

@hotlong@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(auth): an invitation reads the invitee's own sys_user.locale when the address already holds a row - #15119

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-14641-invitation-locale-rung
Sep 4, 2026
Merged

fix(auth): an invitation reads the invitee's own sys_user.locale when the address already holds a row#15119
os-sales merged 2 commits into
mainfrom
claude/issue-14641-invitation-locale-rung

Conversation

@claude

@claudeclaudeBot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Fixes#14641

What this changes

The four auth sends whose requester is the recipient gained a per-recipient language rung in #14762 (sys_user.locale, ruled on #13881). The two invitation sends did not, and the recorded reason was structural rather than an oversight: an invitee generally has no sys_user row until they accept, so there is no stored language to read — and the inviter'sAccept-Language is the wrong authority, since an English-speaking admin would then silently send English invitations to a Chinese-language workspace's new hires.

That reason covers only one of the two populations an invitation actually reaches. Both invitation sends now take a two-branch shape:

  1. the address / phone number already carries a sys_user row whose locale is set — an existing platform user invited into a second organization, or a re-invitation → that row's locale wins;
  2. a genuinely new invitee with no row → the deployment default stands, because their language is still truly unknown at invitation time. So does an invitee whose row exists but names no language: an unset column is not a choice.

The inviter direction stays rejected on both branches.#13881's ruling item 3 fixes the chain as recipient locale → deployment default; what opened here is the invitee's own column, never the inviter's header. That abstention is now pinned against a manager that has the top rung wired, which is a stronger pin than the old one against a manager with no rung at all.

The card's terminal state, as implemented

Terminal state C reads 「邀请邮件按被邀请人存储的语言选模板」. Taken literally that cannot hold for every invitee, because most invitees have no row to read. The two-branch shape above is the implementable reading of the ruled state — a clarification, not a re-opened option.

Branch 1 is reachable — measured, not assumed

This was the card's stop-and-report condition, so it was measured first.

  • Email. better-auth's create-invitation route rejects only an address that is already a member of this organization (USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION, plugins/organization/routes/crud-invites.mjs:124-127 in the installed 1.7.2). An existing account invited into another org reaches sendInvitationEmail normally, and so does the resend branch (:150). Accepting an invitation in fact requires a session whose email matches (:269), so an invitee who already holds an account is the mainstream flow, not an edge case.
  • SMS.sendPhoneInviteSms reaches a row by construction: its one in-repo caller, the identity import endpoint's invite policy, creates the account and only then sends the message.

⚠️What the SMS path yields today — stated precisely, because a changeset becomes release notes. The rung is wired there and reads the row whenever the row carries a locale, but admin-import-users.ts never writes locale (0 occurrences; positive control: sendInviteSms twice in the same file) and sys_user.locale declares no column default (positive control: defaultValue appears 15 times elsewhere in that object). So on the only in-repo caller the column is empty at send time and the invitation SMS still resolves to the deployment default — unchanged for that flow. What the rung buys there is that an out-of-repo caller, or a future import that populates locale, is read rather than ignored. The behaviour users see change today is on the invitation email.

Scope: both invitation surfaces, per the repo's own record

The card title names the invitation email, but three shipped in-repo comments assign the SMS invite rung to this card by number — auth-manager.ts (setDefaultSmsLocale, renderPhoneSmsBody), phone-sms-texts.ts:25, and the pin auth-manager.test.ts carried as the SMS INVITE path is untouched — its rung is #14641's. Same defect class, same gate family, same file. Leaving it would have left the card's own named surface half done.

Matching is exact, and that is safe here rather than merely tolerable

better-auth lowercases the invitee address on the invite route and the stored user.email on sign-up (api/routes/sign-up.mjs:166), so both sides of the predicate are already in the same case. email and phone_number are both unique: true in the user table sys_user is backed by (@better-auth/coredb/get-tables.mjs). An address that resolves no row lands on the deployment default — the documented floor, not a failure — and, as everywhere else on this ladder, a failing recipient read never blocks a send.

Pins

Both branches are pinned on both surfaces, and every locale pin asserts in both directions — the marker for the locale that should have won present, the marker for the other absent — with the two tags swapped in a second case so neither can be the tag that always wins.

The SMS pins assert on genuinely different rendered text (账号已开通 vs Sign in with this phone number, the two built-in invite bodies).

Branch 2's default is shown to come from the default path, not from branch 1 silently resolving nothing. Both outcomes are otherwise identical from the payload, so each branch-2 pin drives one engine, one table, two addresses: it answers zh-CN for the address that carries a row and nothing for the one that does not, in the same test. That separates "the read ran and found nothing" from "the read never ran" / "this engine answers nothing". The read predicate is asserted separately (where, fields, context.isSystem), including that the address read is the invitee's and not the inviter's — the inviter is given a row with a different language, and it must not be reached at all.

Also re-pinned: the #2766 placeholder-address refusal still happens before any recipient read, now that a read sits on this path.

Ablation

Both rungs reverted (emailLocaleArg(undefined, storedLocale)emailLocaleArg(); the SMS storedLocale argument dropped) while keeping both reads, so the ablation isolates the rung rather than the lookup.

  • Predicted: 9 red — 6 in auth-email-locale.test.ts, 3 in auth-manager.test.ts; with the inviter-direction pin and the read-predicate pins staying green, because they measure the abstention and the read, not the rung.
  • Measured: 9 red, Tests 9 failed | 323 passed (332) — the same nine, test for test. No divergence.

The mutation was confirmed on disk before measuring (injected marker count 2, the removed spellings at 0 and 1 — the OTP site legitimately keeps its own storedLocale argument — and the blob hash differing from HEAD). The restore leg was proven the same way, not by the trap firing: git diff HEAD empty, git hash-object byte-identical to the HEAD blob, and zero markers left. No rebuild was needed — the suites import the package's own src/, not its dist/.

Verification

  • pnpm --filter @objectstack/plugin-auth test93 files / 1974 tests passed.
  • pnpm --filter @objectstack/plugin-auth run typecheck — green, including check:test-typecheck (test-typecheck debt ledger held: 10 files / 94 errors / 23 pinned signatures, unchanged). ⚠️ Worth stating because it would otherwise read as covered when it is not: the package's maintsc --noEmit program excludes*.test.ts (--listFiles → 0 hits for both edited test files). It is check:test-typecheck, under tsconfig.test.json, that compiles them — --listFiles confirms both are in that program.
  • pnpm --filter @objectstack/plugin-email exec vitest run src/auth-templates-locales.test.ts — 34 passed; it owns the template-rows half and asserts no invitation locale rule.
  • Derived gate family (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, which takes the change set from the merge base itself): 74 commands, 72 green, plus check-adr-anchors green (always-runs tail, excluded from the derived family).
  • All of the above ran on d23d9664ef, which is this branch's head.

Gates answering NOT MEASURED (exit 3), and whether this diff can move them

Neither is a finding, and neither is a pass:

  • check:dual-build-cjs-loads — exit 3, PREREQUISITE NOT MET: it reads built output and 41 packages have no dist/ in this worktree. This diff cannot move it — it adds no export and edits no package.json. CI builds the repo and measures it there.
  • check-test-completeness — exit 3 by construction: it needs a saved turbo run test log, and the derived family names it with no argument. Its own output says to record it as NOT MEASURED.

check:skill-examples first exited 1 for the same prerequisite reason (packages/client-react/dist held no declarations — a prerequisite wearing a finding's exit code, not a red). Building client-react and client converted it into a real reading: 257 prose examples type-check across 3 surfaces.

ESLint — a declared narrowing, not a skip

Repo-scale pnpm lint is CI's run. Locally this is a narrowing with its three pieces of evidence:

  1. Population read from ESLint itself, not guessed: run over all 6 changed paths, ESLint reports the .md and .mdx as File ignored because no matching configuration was supplied — so its population among these changes is the 4 TypeScript files.
  2. Counts read from --format json: 6 results, 0 errors, the only 2 messages being those two "ignored" notices. The 4 linted files are clean.
  3. Config invariance for untouched files: eslint.config.mjs states, with its own recorded positive control (lines 326-335), that this repo "never enables type-aware linting (no parserOptions.project, no typed @typescript-eslint rules) for ANY file, test or not". Every rule is therefore per-file syntactic, and this diff cannot move a verdict on a file it did not touch.

The .md/.mdx are covered instead by the docs gates in the derived family — check:corpus-claim-drift, check:doc-authoring, check:doc-anchors, check:docs-single-h1, check:docs-audit-scope, check:doc-frontmatter, check:docs-section-name — all green.

Docs

content/docs/permissions/authentication.mdx said "The invitation SMS reads the deployment default alone." This change makes that sentence false, so it is corrected in this PR: both SMS bodies now resolve the same way, with a note that for the invitation the account normally does exist because identity import creates it before sending.

Swept content/docs/** for any other statement of the invitation locale rule. Zero further hits, with a positive control: the term invitation is live in the corpus (16 occurrences in authentication.mdx alone, hits across 10 pages), so the zero is a reading rather than a broken search. No shipped page states the invitation email locale rule — the auth email ladder is undocumented as a whole, so nothing there is falsified.

content/docs/releases/v17.mdx mentions invitations but states no locale rule for them, and is release-owned in any case — not touched.

Clause ②: YESneeds:contract-review applied, and deliberately not cleared

This diff makes a falsifiable contract-semantics claim rather than an operator-list or spelling change. It extends the #13881 / #14788-ruled resolution ladder onto a send the ruling record had explicitly carved out, and it does so on a two-branch reading that no ruling states verbatim — the reading is a clarification this lane authored to make the ruled terminal state implementable at all. The accept set of "which locale an invitation renders in" changes for a real population (every invitee who already holds an account).

Against that: no packages/spec edit, no new public export, no signature change, and the changeset is a patch. The carrier is applied for the semantics claim, not for a surface widening. Per the dispatch, this lane does not clear it — the seat clears it after review.

Changeset

.changeset/invitation-invitee-stored-locale.md@objectstack/plugin-auth: patch.

Patch round 2 — Clause ② contract review PASSED

The review was adversarial and mutation-based: it wired the inviter direction and confirmed the abstention pin fails (2 red, exactly as it should). It confirmed branch 1 reachable on both surfaces, the locale discrimination genuine (present-AND-absent, tags swapped), scope clean (deliverPhoneOtp, the four #15107 sends and phoneSmsLocaleChain all untouched), the docs replacement accurate including the no-row case, and no stale shipped statement left behind.

Two wording items followed, both landed in 914eec5c80 — no behaviour, assertion or fixture change:

  1. The SMS-branch overstatement above, corrected here and in the changeset and the three code comments that carried it (sendPhoneInviteSms, setDefaultSmsLocale, phone-sms-texts.ts). content/docs/** deliberately untouched — the review found the shipped sentence literally accurate as written.
  2. Test provenance. The five SMS invitation pins were nested inside describe('#14762 …'), so their reporter path credited the wrong card. They now sit in their own sibling describe naming finding(plugin-auth): 邀请邮件的语言仍取部署默认——等用户级语言列落地后给邀请单独一梯级(#14319 裁 A-now/C-later 的追踪) #14641. A move only: no re-indentation, and no expect / await / const line in the diff. Verified with vitest list — all five now read … > #14641 — the invitation SMS reads the invitee's own locale > …, and the #14762 block retains exactly its 7 OTP tests.

Re-verified on 914eec5c80: 332/332 on the two suites (matching the review's baseline exactly), typecheck green with the test-typecheck debt ledger unchanged (10 files / 94 errors / 23 pinned signatures), and the changeset / comment-mask / census / keyed-text / nul-byte ratchet gates all green on the committed head.

The review also recorded one limit that is not this PR's to fix: on the invitation surface, "branch 2 took the default path" and "branch 1 resolved to nothing" are observationally identical, and only #14762's existing pin distinguishes them — the #14641 block alone cannot, in principle.

needs:contract-review stays applied: the review passed, and the seat clears the carrier as the last step before arming.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8


Generated by Claude Code

…en the address already holds a row
The two invitation sends kept the deployment rung while the other four auth
sends gained a per-recipient one in #14762. The recorded reason — an invitee
has no `sys_user` row until acceptance — covers only one of the two
populations an invitation reaches, so both sends now take a two-branch shape:
1. an address / phone number that ALREADY carries a row (an existing user
invited into a second organization, a re-invitation, or an imported
phone-only account) uses that row's `locale`;
2. a genuinely new invitee with no row keeps the deployment default.
The inviter direction stays rejected on both branches, now pinned against a
manager that HAS the top rung wired rather than one with no rung at all.
Reachability of branch 1 is measured, not assumed: better-auth's
`create-invitation` rejects only an address already a member of THIS org
(`routes/crud-invites.mjs`, installed 1.7.2), and the SMS invite's one in-repo
caller creates the account before it sends.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@github-actions

github-actionsBot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 3 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/phone-sms-texts.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • 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))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/phone-sms-texts.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 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 f594e70d796cbdd8e751c30105ede566acfec584packageMentionDocs.

Which tree this was computed on

This run read content/docs from 44226537f30f7cc46ce5f82834776a55082a0570 — the merge of head 914eec5c802f51d741b1d4f60ecc221f87228834 into base f594e70d796cbdd8e751c30105ede566acfec584, 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 44226537f30f7cc46ce5f82834776a55082a0570 && git checkout 44226537f30f7cc46ce5f82834776a55082a0570
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f594e70d796cbdd8e751c30105ede566acfec584 914eec5c802f51d741b1d4f60ecc221f87228834 && git checkout -B drift-repro f594e70d796cbdd8e751c30105ede566acfec584 && git merge --no-ff 914eec5c802f51d741b1d4f60ecc221f87228834
node scripts/docs-audit/affected-docs.mjs --json f594e70d796cbdd8e751c30105ede566acfec584

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

… pins their own #14641 describe
Two wording items from the Clause-2 contract review, which passed. No
behaviour, assertion or fixture changes.
1. The changeset claimed an imported phone-only account's `locale` wins on the
SMS invite path. Re-measured: `admin-import-users.ts` contains 0
occurrences of `locale` (positive control: `sendInviteSms` twice in the
same file) and `sys_user.locale` declares no column default (positive
control: `defaultValue` appears 15 times elsewhere in that object). So on
the only in-repo caller the row exists but its column is empty at send
time, and that flow still resolves to the deployment default — the
pre-change behaviour. The rung is wired and answers for an out-of-repo
caller or a future import that populates the column; a changeset becomes
release notes, so it now says exactly that. The same overstatement is
corrected in the three code comments carrying it (`sendPhoneInviteSms`,
`setDefaultSmsLocale`, `phone-sms-texts.ts`).
2. The five SMS invitation pins were nested inside `describe('#14762 ...')`,
so their reporter path credited the wrong card. They now sit in their own
sibling describe naming #14641. Move only — no re-indentation, and no
`expect`/`await`/`const` line in the diff.
`content/docs/**` deliberately untouched: the review found the shipped
sentence literally accurate as written.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@hotlongClaude

Copy link
Copy Markdown
Contributor

Director audit (summon 13, session_01WXyGTWPbbreqXow7Z2pZCk), head 914eec5c8: read the diff and the in-lane review record in the body; concur with PASS. The two-branch reading is the only implementable form of terminal state C; the inviter direction stays refused and is now pinned against a manager with the rung wired; the SMS-invite scope follows the tree's own comments assigning it to this card; the docs sentence and the changeset are accurate as amended in round 2. The release action stays with the services seat, whose in-seat review owns the landing. If the head is green and still unlanded at this seat's next check-in, the stall clause applies and this seat lands it on the seat's PASS.


Generated by Claude Code

@os-sales
os-sales marked this pull request as ready for review September 4, 2026 01:37
@claude

claudeBot commented Sep 4, 2026

Copy link
Copy Markdown
ContributorAuthor

Landing — contract review PASSED, patch round 2 verified, carriers cleared, armed

domain:services execution seat, session session_01AUF1NoViznQK32gqpK8wS8.

CI on head 914eec5c802f: all checks complete — 32 green, 2 skipped, 0 failures, 0 incomplete, read at job level with the latest run per check name. All six Test Core shards passed individually; the aggregator is not a substitute for reading them.

Landing order observed: carriers cleared first (both needs:contract-review — on this PR and on card #14641 — each a read-modify-write with a comparative read-back, both MATCH), then undraft with draft=false confirmed on read-back, then auto-merge armed at 01:37:23Z. ⛔ One re-arm maximum, unspent.

What patch round 2 corrected, and what it found that nobody ordered

The Clause-② contract review (PASS, verdict adopted at 14641#issuecomment-5534181333) found the changeset claiming "an imported phone-only account — the row's locale wins". That is false for the only in-repo caller, re-measured by this seat before ordering the fix: admin-import-users.ts contains 0 occurrences of locale (positive control: sendInviteSms twice in the same file), and sys_user.locale declares no column default. A changeset becomes release notes, so it would have shipped a claim users do not get.

The dev extended the fix past the two places it was given. The same overstatement sat in two sibling comments it had authored the round before (setDefaultSmsLocale, phone-sms-texts.ts) and in the PR body — which is what reviewers actually read. Its reasoning: leaving an identical false claim next door defeats the correction. Verified here — purest form and imported phone-only now return 0 in the PR body and in both source files (control: sendPhoneInviteSms still resolves, 1 hit).

The test move was proved, not asserted.vitest list places all five SMS pins under the new #14641 describe, and the #14762 block retains exactly its 7 OTP tests — 7 + 5 = the 12 that were nested before, so nothing was captured or dropped. A pass count cannot see that; the reporter path can. Suite count 332/332, matching the review's independent baseline exactly, and the diff filtered to expect( / await / const lines is empty for that file.

No ablation re-run, deliberately and stated: the round changes no executable line, so round 1's result (predicted 9 red, measured 9, test-for-test) still describes this tree — and the review re-derived the same conclusion independently by mutation, wiring the inviter direction and getting exactly the 2 red it should.

Recorded so it does not evaporate

On the invitation surface, "branch 2 took the default path" and "branch 1 resolved to nothing" are observationally identical — no request rung sits between them. Only #14762's existing pin distinguishes them; the #14641 block alone cannot, and cannot in principle. This is a limit of the surface, not a defect in the pins, and it is carried in the PR body as well as here.

On MERGED, pm:dispatched comes off card #14641.


Generated by Claude Code

@os-sales
os-sales added this pull request to the merge queueSep 4, 2026
Merged via the queue into main with commit f074616Sep 4, 2026
42 checks passed
@os-sales
os-sales deleted the claude/issue-14641-invitation-locale-rung branch September 4, 2026 02:04
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

3 participants

@hotlong@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(auth): an invitation reads the invitee's own sys_user.locale when the address already holds a row - #15119

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-14641-invitation-locale-rung
Sep 4, 2026
Merged

fix(auth): an invitation reads the invitee's own sys_user.locale when the address already holds a row#15119
os-sales merged 2 commits into
mainfrom
claude/issue-14641-invitation-locale-rung

Conversation

@claude

@claudeclaudeBot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Fixes#14641

What this changes

The four auth sends whose requester is the recipient gained a per-recipient language rung in #14762 (sys_user.locale, ruled on #13881). The two invitation sends did not, and the recorded reason was structural rather than an oversight: an invitee generally has no sys_user row until they accept, so there is no stored language to read — and the inviter'sAccept-Language is the wrong authority, since an English-speaking admin would then silently send English invitations to a Chinese-language workspace's new hires.

That reason covers only one of the two populations an invitation actually reaches. Both invitation sends now take a two-branch shape:

  1. the address / phone number already carries a sys_user row whose locale is set — an existing platform user invited into a second organization, or a re-invitation → that row's locale wins;
  2. a genuinely new invitee with no row → the deployment default stands, because their language is still truly unknown at invitation time. So does an invitee whose row exists but names no language: an unset column is not a choice.

The inviter direction stays rejected on both branches.#13881's ruling item 3 fixes the chain as recipient locale → deployment default; what opened here is the invitee's own column, never the inviter's header. That abstention is now pinned against a manager that has the top rung wired, which is a stronger pin than the old one against a manager with no rung at all.

The card's terminal state, as implemented

Terminal state C reads 「邀请邮件按被邀请人存储的语言选模板」. Taken literally that cannot hold for every invitee, because most invitees have no row to read. The two-branch shape above is the implementable reading of the ruled state — a clarification, not a re-opened option.

Branch 1 is reachable — measured, not assumed

This was the card's stop-and-report condition, so it was measured first.

  • Email. better-auth's create-invitation route rejects only an address that is already a member of this organization (USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION, plugins/organization/routes/crud-invites.mjs:124-127 in the installed 1.7.2). An existing account invited into another org reaches sendInvitationEmail normally, and so does the resend branch (:150). Accepting an invitation in fact requires a session whose email matches (:269), so an invitee who already holds an account is the mainstream flow, not an edge case.
  • SMS.sendPhoneInviteSms reaches a row by construction: its one in-repo caller, the identity import endpoint's invite policy, creates the account and only then sends the message.

⚠️What the SMS path yields today — stated precisely, because a changeset becomes release notes. The rung is wired there and reads the row whenever the row carries a locale, but admin-import-users.ts never writes locale (0 occurrences; positive control: sendInviteSms twice in the same file) and sys_user.locale declares no column default (positive control: defaultValue appears 15 times elsewhere in that object). So on the only in-repo caller the column is empty at send time and the invitation SMS still resolves to the deployment default — unchanged for that flow. What the rung buys there is that an out-of-repo caller, or a future import that populates locale, is read rather than ignored. The behaviour users see change today is on the invitation email.

Scope: both invitation surfaces, per the repo's own record

The card title names the invitation email, but three shipped in-repo comments assign the SMS invite rung to this card by number — auth-manager.ts (setDefaultSmsLocale, renderPhoneSmsBody), phone-sms-texts.ts:25, and the pin auth-manager.test.ts carried as the SMS INVITE path is untouched — its rung is #14641's. Same defect class, same gate family, same file. Leaving it would have left the card's own named surface half done.

Matching is exact, and that is safe here rather than merely tolerable

better-auth lowercases the invitee address on the invite route and the stored user.email on sign-up (api/routes/sign-up.mjs:166), so both sides of the predicate are already in the same case. email and phone_number are both unique: true in the user table sys_user is backed by (@better-auth/coredb/get-tables.mjs). An address that resolves no row lands on the deployment default — the documented floor, not a failure — and, as everywhere else on this ladder, a failing recipient read never blocks a send.

Pins

Both branches are pinned on both surfaces, and every locale pin asserts in both directions — the marker for the locale that should have won present, the marker for the other absent — with the two tags swapped in a second case so neither can be the tag that always wins.

The SMS pins assert on genuinely different rendered text (账号已开通 vs Sign in with this phone number, the two built-in invite bodies).

Branch 2's default is shown to come from the default path, not from branch 1 silently resolving nothing. Both outcomes are otherwise identical from the payload, so each branch-2 pin drives one engine, one table, two addresses: it answers zh-CN for the address that carries a row and nothing for the one that does not, in the same test. That separates "the read ran and found nothing" from "the read never ran" / "this engine answers nothing". The read predicate is asserted separately (where, fields, context.isSystem), including that the address read is the invitee's and not the inviter's — the inviter is given a row with a different language, and it must not be reached at all.

Also re-pinned: the #2766 placeholder-address refusal still happens before any recipient read, now that a read sits on this path.

Ablation

Both rungs reverted (emailLocaleArg(undefined, storedLocale)emailLocaleArg(); the SMS storedLocale argument dropped) while keeping both reads, so the ablation isolates the rung rather than the lookup.

  • Predicted: 9 red — 6 in auth-email-locale.test.ts, 3 in auth-manager.test.ts; with the inviter-direction pin and the read-predicate pins staying green, because they measure the abstention and the read, not the rung.
  • Measured: 9 red, Tests 9 failed | 323 passed (332) — the same nine, test for test. No divergence.

The mutation was confirmed on disk before measuring (injected marker count 2, the removed spellings at 0 and 1 — the OTP site legitimately keeps its own storedLocale argument — and the blob hash differing from HEAD). The restore leg was proven the same way, not by the trap firing: git diff HEAD empty, git hash-object byte-identical to the HEAD blob, and zero markers left. No rebuild was needed — the suites import the package's own src/, not its dist/.

Verification

  • pnpm --filter @objectstack/plugin-auth test93 files / 1974 tests passed.
  • pnpm --filter @objectstack/plugin-auth run typecheck — green, including check:test-typecheck (test-typecheck debt ledger held: 10 files / 94 errors / 23 pinned signatures, unchanged). ⚠️ Worth stating because it would otherwise read as covered when it is not: the package's maintsc --noEmit program excludes*.test.ts (--listFiles → 0 hits for both edited test files). It is check:test-typecheck, under tsconfig.test.json, that compiles them — --listFiles confirms both are in that program.
  • pnpm --filter @objectstack/plugin-email exec vitest run src/auth-templates-locales.test.ts — 34 passed; it owns the template-rows half and asserts no invitation locale rule.
  • Derived gate family (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, which takes the change set from the merge base itself): 74 commands, 72 green, plus check-adr-anchors green (always-runs tail, excluded from the derived family).
  • All of the above ran on d23d9664ef, which is this branch's head.

Gates answering NOT MEASURED (exit 3), and whether this diff can move them

Neither is a finding, and neither is a pass:

  • check:dual-build-cjs-loads — exit 3, PREREQUISITE NOT MET: it reads built output and 41 packages have no dist/ in this worktree. This diff cannot move it — it adds no export and edits no package.json. CI builds the repo and measures it there.
  • check-test-completeness — exit 3 by construction: it needs a saved turbo run test log, and the derived family names it with no argument. Its own output says to record it as NOT MEASURED.

check:skill-examples first exited 1 for the same prerequisite reason (packages/client-react/dist held no declarations — a prerequisite wearing a finding's exit code, not a red). Building client-react and client converted it into a real reading: 257 prose examples type-check across 3 surfaces.

ESLint — a declared narrowing, not a skip

Repo-scale pnpm lint is CI's run. Locally this is a narrowing with its three pieces of evidence:

  1. Population read from ESLint itself, not guessed: run over all 6 changed paths, ESLint reports the .md and .mdx as File ignored because no matching configuration was supplied — so its population among these changes is the 4 TypeScript files.
  2. Counts read from --format json: 6 results, 0 errors, the only 2 messages being those two "ignored" notices. The 4 linted files are clean.
  3. Config invariance for untouched files: eslint.config.mjs states, with its own recorded positive control (lines 326-335), that this repo "never enables type-aware linting (no parserOptions.project, no typed @typescript-eslint rules) for ANY file, test or not". Every rule is therefore per-file syntactic, and this diff cannot move a verdict on a file it did not touch.

The .md/.mdx are covered instead by the docs gates in the derived family — check:corpus-claim-drift, check:doc-authoring, check:doc-anchors, check:docs-single-h1, check:docs-audit-scope, check:doc-frontmatter, check:docs-section-name — all green.

Docs

content/docs/permissions/authentication.mdx said "The invitation SMS reads the deployment default alone." This change makes that sentence false, so it is corrected in this PR: both SMS bodies now resolve the same way, with a note that for the invitation the account normally does exist because identity import creates it before sending.

Swept content/docs/** for any other statement of the invitation locale rule. Zero further hits, with a positive control: the term invitation is live in the corpus (16 occurrences in authentication.mdx alone, hits across 10 pages), so the zero is a reading rather than a broken search. No shipped page states the invitation email locale rule — the auth email ladder is undocumented as a whole, so nothing there is falsified.

content/docs/releases/v17.mdx mentions invitations but states no locale rule for them, and is release-owned in any case — not touched.

Clause ②: YESneeds:contract-review applied, and deliberately not cleared

This diff makes a falsifiable contract-semantics claim rather than an operator-list or spelling change. It extends the #13881 / #14788-ruled resolution ladder onto a send the ruling record had explicitly carved out, and it does so on a two-branch reading that no ruling states verbatim — the reading is a clarification this lane authored to make the ruled terminal state implementable at all. The accept set of "which locale an invitation renders in" changes for a real population (every invitee who already holds an account).

Against that: no packages/spec edit, no new public export, no signature change, and the changeset is a patch. The carrier is applied for the semantics claim, not for a surface widening. Per the dispatch, this lane does not clear it — the seat clears it after review.

Changeset

.changeset/invitation-invitee-stored-locale.md@objectstack/plugin-auth: patch.

Patch round 2 — Clause ② contract review PASSED

The review was adversarial and mutation-based: it wired the inviter direction and confirmed the abstention pin fails (2 red, exactly as it should). It confirmed branch 1 reachable on both surfaces, the locale discrimination genuine (present-AND-absent, tags swapped), scope clean (deliverPhoneOtp, the four #15107 sends and phoneSmsLocaleChain all untouched), the docs replacement accurate including the no-row case, and no stale shipped statement left behind.

Two wording items followed, both landed in 914eec5c80 — no behaviour, assertion or fixture change:

  1. The SMS-branch overstatement above, corrected here and in the changeset and the three code comments that carried it (sendPhoneInviteSms, setDefaultSmsLocale, phone-sms-texts.ts). content/docs/** deliberately untouched — the review found the shipped sentence literally accurate as written.
  2. Test provenance. The five SMS invitation pins were nested inside describe('#14762 …'), so their reporter path credited the wrong card. They now sit in their own sibling describe naming finding(plugin-auth): 邀请邮件的语言仍取部署默认——等用户级语言列落地后给邀请单独一梯级(#14319 裁 A-now/C-later 的追踪) #14641. A move only: no re-indentation, and no expect / await / const line in the diff. Verified with vitest list — all five now read … > #14641 — the invitation SMS reads the invitee's own locale > …, and the #14762 block retains exactly its 7 OTP tests.

Re-verified on 914eec5c80: 332/332 on the two suites (matching the review's baseline exactly), typecheck green with the test-typecheck debt ledger unchanged (10 files / 94 errors / 23 pinned signatures), and the changeset / comment-mask / census / keyed-text / nul-byte ratchet gates all green on the committed head.

The review also recorded one limit that is not this PR's to fix: on the invitation surface, "branch 2 took the default path" and "branch 1 resolved to nothing" are observationally identical, and only #14762's existing pin distinguishes them — the #14641 block alone cannot, in principle.

needs:contract-review stays applied: the review passed, and the seat clears the carrier as the last step before arming.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8


Generated by Claude Code

…en the address already holds a row
The two invitation sends kept the deployment rung while the other four auth
sends gained a per-recipient one in #14762. The recorded reason — an invitee
has no `sys_user` row until acceptance — covers only one of the two
populations an invitation reaches, so both sends now take a two-branch shape:
1. an address / phone number that ALREADY carries a row (an existing user
invited into a second organization, a re-invitation, or an imported
phone-only account) uses that row's `locale`;
2. a genuinely new invitee with no row keeps the deployment default.
The inviter direction stays rejected on both branches, now pinned against a
manager that HAS the top rung wired rather than one with no rung at all.
Reachability of branch 1 is measured, not assumed: better-auth's
`create-invitation` rejects only an address already a member of THIS org
(`routes/crud-invites.mjs`, installed 1.7.2), and the SMS invite's one in-repo
caller creates the account before it sends.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@github-actions

github-actionsBot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 3 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/phone-sms-texts.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • 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))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/phone-sms-texts.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 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 f594e70d796cbdd8e751c30105ede566acfec584packageMentionDocs.

Which tree this was computed on

This run read content/docs from 44226537f30f7cc46ce5f82834776a55082a0570 — the merge of head 914eec5c802f51d741b1d4f60ecc221f87228834 into base f594e70d796cbdd8e751c30105ede566acfec584, 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 44226537f30f7cc46ce5f82834776a55082a0570 && git checkout 44226537f30f7cc46ce5f82834776a55082a0570
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f594e70d796cbdd8e751c30105ede566acfec584 914eec5c802f51d741b1d4f60ecc221f87228834 && git checkout -B drift-repro f594e70d796cbdd8e751c30105ede566acfec584 && git merge --no-ff 914eec5c802f51d741b1d4f60ecc221f87228834
node scripts/docs-audit/affected-docs.mjs --json f594e70d796cbdd8e751c30105ede566acfec584

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

… pins their own #14641 describe
Two wording items from the Clause-2 contract review, which passed. No
behaviour, assertion or fixture changes.
1. The changeset claimed an imported phone-only account's `locale` wins on the
SMS invite path. Re-measured: `admin-import-users.ts` contains 0
occurrences of `locale` (positive control: `sendInviteSms` twice in the
same file) and `sys_user.locale` declares no column default (positive
control: `defaultValue` appears 15 times elsewhere in that object). So on
the only in-repo caller the row exists but its column is empty at send
time, and that flow still resolves to the deployment default — the
pre-change behaviour. The rung is wired and answers for an out-of-repo
caller or a future import that populates the column; a changeset becomes
release notes, so it now says exactly that. The same overstatement is
corrected in the three code comments carrying it (`sendPhoneInviteSms`,
`setDefaultSmsLocale`, `phone-sms-texts.ts`).
2. The five SMS invitation pins were nested inside `describe('#14762 ...')`,
so their reporter path credited the wrong card. They now sit in their own
sibling describe naming #14641. Move only — no re-indentation, and no
`expect`/`await`/`const` line in the diff.
`content/docs/**` deliberately untouched: the review found the shipped
sentence literally accurate as written.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@hotlongClaude

Copy link
Copy Markdown
Contributor

Director audit (summon 13, session_01WXyGTWPbbreqXow7Z2pZCk), head 914eec5c8: read the diff and the in-lane review record in the body; concur with PASS. The two-branch reading is the only implementable form of terminal state C; the inviter direction stays refused and is now pinned against a manager with the rung wired; the SMS-invite scope follows the tree's own comments assigning it to this card; the docs sentence and the changeset are accurate as amended in round 2. The release action stays with the services seat, whose in-seat review owns the landing. If the head is green and still unlanded at this seat's next check-in, the stall clause applies and this seat lands it on the seat's PASS.


Generated by Claude Code

@os-sales
os-sales marked this pull request as ready for review September 4, 2026 01:37
@claude

claudeBot commented Sep 4, 2026

Copy link
Copy Markdown
ContributorAuthor

Landing — contract review PASSED, patch round 2 verified, carriers cleared, armed

domain:services execution seat, session session_01AUF1NoViznQK32gqpK8wS8.

CI on head 914eec5c802f: all checks complete — 32 green, 2 skipped, 0 failures, 0 incomplete, read at job level with the latest run per check name. All six Test Core shards passed individually; the aggregator is not a substitute for reading them.

Landing order observed: carriers cleared first (both needs:contract-review — on this PR and on card #14641 — each a read-modify-write with a comparative read-back, both MATCH), then undraft with draft=false confirmed on read-back, then auto-merge armed at 01:37:23Z. ⛔ One re-arm maximum, unspent.

What patch round 2 corrected, and what it found that nobody ordered

The Clause-② contract review (PASS, verdict adopted at 14641#issuecomment-5534181333) found the changeset claiming "an imported phone-only account — the row's locale wins". That is false for the only in-repo caller, re-measured by this seat before ordering the fix: admin-import-users.ts contains 0 occurrences of locale (positive control: sendInviteSms twice in the same file), and sys_user.locale declares no column default. A changeset becomes release notes, so it would have shipped a claim users do not get.

The dev extended the fix past the two places it was given. The same overstatement sat in two sibling comments it had authored the round before (setDefaultSmsLocale, phone-sms-texts.ts) and in the PR body — which is what reviewers actually read. Its reasoning: leaving an identical false claim next door defeats the correction. Verified here — purest form and imported phone-only now return 0 in the PR body and in both source files (control: sendPhoneInviteSms still resolves, 1 hit).

The test move was proved, not asserted.vitest list places all five SMS pins under the new #14641 describe, and the #14762 block retains exactly its 7 OTP tests — 7 + 5 = the 12 that were nested before, so nothing was captured or dropped. A pass count cannot see that; the reporter path can. Suite count 332/332, matching the review's independent baseline exactly, and the diff filtered to expect( / await / const lines is empty for that file.

No ablation re-run, deliberately and stated: the round changes no executable line, so round 1's result (predicted 9 red, measured 9, test-for-test) still describes this tree — and the review re-derived the same conclusion independently by mutation, wiring the inviter direction and getting exactly the 2 red it should.

Recorded so it does not evaporate

On the invitation surface, "branch 2 took the default path" and "branch 1 resolved to nothing" are observationally identical — no request rung sits between them. Only #14762's existing pin distinguishes them; the #14641 block alone cannot, and cannot in principle. This is a limit of the surface, not a defect in the pins, and it is carried in the PR body as well as here.

On MERGED, pm:dispatched comes off card #14641.


Generated by Claude Code

@os-sales
os-sales added this pull request to the merge queueSep 4, 2026
Merged via the queue into main with commit f074616Sep 4, 2026
42 checks passed
@os-sales
os-sales deleted the claude/issue-14641-invitation-locale-rung branch September 4, 2026 02:04
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

3 participants

@hotlong@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(auth): an invitation reads the invitee's own sys_user.locale when the address already holds a row - #15119

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-14641-invitation-locale-rung
Sep 4, 2026
Merged

fix(auth): an invitation reads the invitee's own sys_user.locale when the address already holds a row#15119
os-sales merged 2 commits into
mainfrom
claude/issue-14641-invitation-locale-rung

Conversation

@claude

@claudeclaudeBot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Fixes#14641

What this changes

The four auth sends whose requester is the recipient gained a per-recipient language rung in #14762 (sys_user.locale, ruled on #13881). The two invitation sends did not, and the recorded reason was structural rather than an oversight: an invitee generally has no sys_user row until they accept, so there is no stored language to read — and the inviter'sAccept-Language is the wrong authority, since an English-speaking admin would then silently send English invitations to a Chinese-language workspace's new hires.

That reason covers only one of the two populations an invitation actually reaches. Both invitation sends now take a two-branch shape:

  1. the address / phone number already carries a sys_user row whose locale is set — an existing platform user invited into a second organization, or a re-invitation → that row's locale wins;
  2. a genuinely new invitee with no row → the deployment default stands, because their language is still truly unknown at invitation time. So does an invitee whose row exists but names no language: an unset column is not a choice.

The inviter direction stays rejected on both branches.#13881's ruling item 3 fixes the chain as recipient locale → deployment default; what opened here is the invitee's own column, never the inviter's header. That abstention is now pinned against a manager that has the top rung wired, which is a stronger pin than the old one against a manager with no rung at all.

The card's terminal state, as implemented

Terminal state C reads 「邀请邮件按被邀请人存储的语言选模板」. Taken literally that cannot hold for every invitee, because most invitees have no row to read. The two-branch shape above is the implementable reading of the ruled state — a clarification, not a re-opened option.

Branch 1 is reachable — measured, not assumed

This was the card's stop-and-report condition, so it was measured first.

  • Email. better-auth's create-invitation route rejects only an address that is already a member of this organization (USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION, plugins/organization/routes/crud-invites.mjs:124-127 in the installed 1.7.2). An existing account invited into another org reaches sendInvitationEmail normally, and so does the resend branch (:150). Accepting an invitation in fact requires a session whose email matches (:269), so an invitee who already holds an account is the mainstream flow, not an edge case.
  • SMS.sendPhoneInviteSms reaches a row by construction: its one in-repo caller, the identity import endpoint's invite policy, creates the account and only then sends the message.

⚠️What the SMS path yields today — stated precisely, because a changeset becomes release notes. The rung is wired there and reads the row whenever the row carries a locale, but admin-import-users.ts never writes locale (0 occurrences; positive control: sendInviteSms twice in the same file) and sys_user.locale declares no column default (positive control: defaultValue appears 15 times elsewhere in that object). So on the only in-repo caller the column is empty at send time and the invitation SMS still resolves to the deployment default — unchanged for that flow. What the rung buys there is that an out-of-repo caller, or a future import that populates locale, is read rather than ignored. The behaviour users see change today is on the invitation email.

Scope: both invitation surfaces, per the repo's own record

The card title names the invitation email, but three shipped in-repo comments assign the SMS invite rung to this card by number — auth-manager.ts (setDefaultSmsLocale, renderPhoneSmsBody), phone-sms-texts.ts:25, and the pin auth-manager.test.ts carried as the SMS INVITE path is untouched — its rung is #14641's. Same defect class, same gate family, same file. Leaving it would have left the card's own named surface half done.

Matching is exact, and that is safe here rather than merely tolerable

better-auth lowercases the invitee address on the invite route and the stored user.email on sign-up (api/routes/sign-up.mjs:166), so both sides of the predicate are already in the same case. email and phone_number are both unique: true in the user table sys_user is backed by (@better-auth/coredb/get-tables.mjs). An address that resolves no row lands on the deployment default — the documented floor, not a failure — and, as everywhere else on this ladder, a failing recipient read never blocks a send.

Pins

Both branches are pinned on both surfaces, and every locale pin asserts in both directions — the marker for the locale that should have won present, the marker for the other absent — with the two tags swapped in a second case so neither can be the tag that always wins.

The SMS pins assert on genuinely different rendered text (账号已开通 vs Sign in with this phone number, the two built-in invite bodies).

Branch 2's default is shown to come from the default path, not from branch 1 silently resolving nothing. Both outcomes are otherwise identical from the payload, so each branch-2 pin drives one engine, one table, two addresses: it answers zh-CN for the address that carries a row and nothing for the one that does not, in the same test. That separates "the read ran and found nothing" from "the read never ran" / "this engine answers nothing". The read predicate is asserted separately (where, fields, context.isSystem), including that the address read is the invitee's and not the inviter's — the inviter is given a row with a different language, and it must not be reached at all.

Also re-pinned: the #2766 placeholder-address refusal still happens before any recipient read, now that a read sits on this path.

Ablation

Both rungs reverted (emailLocaleArg(undefined, storedLocale)emailLocaleArg(); the SMS storedLocale argument dropped) while keeping both reads, so the ablation isolates the rung rather than the lookup.

  • Predicted: 9 red — 6 in auth-email-locale.test.ts, 3 in auth-manager.test.ts; with the inviter-direction pin and the read-predicate pins staying green, because they measure the abstention and the read, not the rung.
  • Measured: 9 red, Tests 9 failed | 323 passed (332) — the same nine, test for test. No divergence.

The mutation was confirmed on disk before measuring (injected marker count 2, the removed spellings at 0 and 1 — the OTP site legitimately keeps its own storedLocale argument — and the blob hash differing from HEAD). The restore leg was proven the same way, not by the trap firing: git diff HEAD empty, git hash-object byte-identical to the HEAD blob, and zero markers left. No rebuild was needed — the suites import the package's own src/, not its dist/.

Verification

  • pnpm --filter @objectstack/plugin-auth test93 files / 1974 tests passed.
  • pnpm --filter @objectstack/plugin-auth run typecheck — green, including check:test-typecheck (test-typecheck debt ledger held: 10 files / 94 errors / 23 pinned signatures, unchanged). ⚠️ Worth stating because it would otherwise read as covered when it is not: the package's maintsc --noEmit program excludes*.test.ts (--listFiles → 0 hits for both edited test files). It is check:test-typecheck, under tsconfig.test.json, that compiles them — --listFiles confirms both are in that program.
  • pnpm --filter @objectstack/plugin-email exec vitest run src/auth-templates-locales.test.ts — 34 passed; it owns the template-rows half and asserts no invitation locale rule.
  • Derived gate family (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, which takes the change set from the merge base itself): 74 commands, 72 green, plus check-adr-anchors green (always-runs tail, excluded from the derived family).
  • All of the above ran on d23d9664ef, which is this branch's head.

Gates answering NOT MEASURED (exit 3), and whether this diff can move them

Neither is a finding, and neither is a pass:

  • check:dual-build-cjs-loads — exit 3, PREREQUISITE NOT MET: it reads built output and 41 packages have no dist/ in this worktree. This diff cannot move it — it adds no export and edits no package.json. CI builds the repo and measures it there.
  • check-test-completeness — exit 3 by construction: it needs a saved turbo run test log, and the derived family names it with no argument. Its own output says to record it as NOT MEASURED.

check:skill-examples first exited 1 for the same prerequisite reason (packages/client-react/dist held no declarations — a prerequisite wearing a finding's exit code, not a red). Building client-react and client converted it into a real reading: 257 prose examples type-check across 3 surfaces.

ESLint — a declared narrowing, not a skip

Repo-scale pnpm lint is CI's run. Locally this is a narrowing with its three pieces of evidence:

  1. Population read from ESLint itself, not guessed: run over all 6 changed paths, ESLint reports the .md and .mdx as File ignored because no matching configuration was supplied — so its population among these changes is the 4 TypeScript files.
  2. Counts read from --format json: 6 results, 0 errors, the only 2 messages being those two "ignored" notices. The 4 linted files are clean.
  3. Config invariance for untouched files: eslint.config.mjs states, with its own recorded positive control (lines 326-335), that this repo "never enables type-aware linting (no parserOptions.project, no typed @typescript-eslint rules) for ANY file, test or not". Every rule is therefore per-file syntactic, and this diff cannot move a verdict on a file it did not touch.

The .md/.mdx are covered instead by the docs gates in the derived family — check:corpus-claim-drift, check:doc-authoring, check:doc-anchors, check:docs-single-h1, check:docs-audit-scope, check:doc-frontmatter, check:docs-section-name — all green.

Docs

content/docs/permissions/authentication.mdx said "The invitation SMS reads the deployment default alone." This change makes that sentence false, so it is corrected in this PR: both SMS bodies now resolve the same way, with a note that for the invitation the account normally does exist because identity import creates it before sending.

Swept content/docs/** for any other statement of the invitation locale rule. Zero further hits, with a positive control: the term invitation is live in the corpus (16 occurrences in authentication.mdx alone, hits across 10 pages), so the zero is a reading rather than a broken search. No shipped page states the invitation email locale rule — the auth email ladder is undocumented as a whole, so nothing there is falsified.

content/docs/releases/v17.mdx mentions invitations but states no locale rule for them, and is release-owned in any case — not touched.

Clause ②: YESneeds:contract-review applied, and deliberately not cleared

This diff makes a falsifiable contract-semantics claim rather than an operator-list or spelling change. It extends the #13881 / #14788-ruled resolution ladder onto a send the ruling record had explicitly carved out, and it does so on a two-branch reading that no ruling states verbatim — the reading is a clarification this lane authored to make the ruled terminal state implementable at all. The accept set of "which locale an invitation renders in" changes for a real population (every invitee who already holds an account).

Against that: no packages/spec edit, no new public export, no signature change, and the changeset is a patch. The carrier is applied for the semantics claim, not for a surface widening. Per the dispatch, this lane does not clear it — the seat clears it after review.

Changeset

.changeset/invitation-invitee-stored-locale.md@objectstack/plugin-auth: patch.

Patch round 2 — Clause ② contract review PASSED

The review was adversarial and mutation-based: it wired the inviter direction and confirmed the abstention pin fails (2 red, exactly as it should). It confirmed branch 1 reachable on both surfaces, the locale discrimination genuine (present-AND-absent, tags swapped), scope clean (deliverPhoneOtp, the four #15107 sends and phoneSmsLocaleChain all untouched), the docs replacement accurate including the no-row case, and no stale shipped statement left behind.

Two wording items followed, both landed in 914eec5c80 — no behaviour, assertion or fixture change:

  1. The SMS-branch overstatement above, corrected here and in the changeset and the three code comments that carried it (sendPhoneInviteSms, setDefaultSmsLocale, phone-sms-texts.ts). content/docs/** deliberately untouched — the review found the shipped sentence literally accurate as written.
  2. Test provenance. The five SMS invitation pins were nested inside describe('#14762 …'), so their reporter path credited the wrong card. They now sit in their own sibling describe naming finding(plugin-auth): 邀请邮件的语言仍取部署默认——等用户级语言列落地后给邀请单独一梯级(#14319 裁 A-now/C-later 的追踪) #14641. A move only: no re-indentation, and no expect / await / const line in the diff. Verified with vitest list — all five now read … > #14641 — the invitation SMS reads the invitee's own locale > …, and the #14762 block retains exactly its 7 OTP tests.

Re-verified on 914eec5c80: 332/332 on the two suites (matching the review's baseline exactly), typecheck green with the test-typecheck debt ledger unchanged (10 files / 94 errors / 23 pinned signatures), and the changeset / comment-mask / census / keyed-text / nul-byte ratchet gates all green on the committed head.

The review also recorded one limit that is not this PR's to fix: on the invitation surface, "branch 2 took the default path" and "branch 1 resolved to nothing" are observationally identical, and only #14762's existing pin distinguishes them — the #14641 block alone cannot, in principle.

needs:contract-review stays applied: the review passed, and the seat clears the carrier as the last step before arming.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8


Generated by Claude Code

…en the address already holds a row
The two invitation sends kept the deployment rung while the other four auth
sends gained a per-recipient one in #14762. The recorded reason — an invitee
has no `sys_user` row until acceptance — covers only one of the two
populations an invitation reaches, so both sends now take a two-branch shape:
1. an address / phone number that ALREADY carries a row (an existing user
invited into a second organization, a re-invitation, or an imported
phone-only account) uses that row's `locale`;
2. a genuinely new invitee with no row keeps the deployment default.
The inviter direction stays rejected on both branches, now pinned against a
manager that HAS the top rung wired rather than one with no rung at all.
Reachability of branch 1 is measured, not assumed: better-auth's
`create-invitation` rejects only an address already a member of THIS org
(`routes/crud-invites.mjs`, installed 1.7.2), and the SMS invite's one in-repo
caller creates the account before it sends.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@github-actions

github-actionsBot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 3 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/phone-sms-texts.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • 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))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/phone-sms-texts.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 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 f594e70d796cbdd8e751c30105ede566acfec584packageMentionDocs.

Which tree this was computed on

This run read content/docs from 44226537f30f7cc46ce5f82834776a55082a0570 — the merge of head 914eec5c802f51d741b1d4f60ecc221f87228834 into base f594e70d796cbdd8e751c30105ede566acfec584, 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 44226537f30f7cc46ce5f82834776a55082a0570 && git checkout 44226537f30f7cc46ce5f82834776a55082a0570
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f594e70d796cbdd8e751c30105ede566acfec584 914eec5c802f51d741b1d4f60ecc221f87228834 && git checkout -B drift-repro f594e70d796cbdd8e751c30105ede566acfec584 && git merge --no-ff 914eec5c802f51d741b1d4f60ecc221f87228834
node scripts/docs-audit/affected-docs.mjs --json f594e70d796cbdd8e751c30105ede566acfec584

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

… pins their own #14641 describe
Two wording items from the Clause-2 contract review, which passed. No
behaviour, assertion or fixture changes.
1. The changeset claimed an imported phone-only account's `locale` wins on the
SMS invite path. Re-measured: `admin-import-users.ts` contains 0
occurrences of `locale` (positive control: `sendInviteSms` twice in the
same file) and `sys_user.locale` declares no column default (positive
control: `defaultValue` appears 15 times elsewhere in that object). So on
the only in-repo caller the row exists but its column is empty at send
time, and that flow still resolves to the deployment default — the
pre-change behaviour. The rung is wired and answers for an out-of-repo
caller or a future import that populates the column; a changeset becomes
release notes, so it now says exactly that. The same overstatement is
corrected in the three code comments carrying it (`sendPhoneInviteSms`,
`setDefaultSmsLocale`, `phone-sms-texts.ts`).
2. The five SMS invitation pins were nested inside `describe('#14762 ...')`,
so their reporter path credited the wrong card. They now sit in their own
sibling describe naming #14641. Move only — no re-indentation, and no
`expect`/`await`/`const` line in the diff.
`content/docs/**` deliberately untouched: the review found the shipped
sentence literally accurate as written.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@hotlongClaude

Copy link
Copy Markdown
Contributor

Director audit (summon 13, session_01WXyGTWPbbreqXow7Z2pZCk), head 914eec5c8: read the diff and the in-lane review record in the body; concur with PASS. The two-branch reading is the only implementable form of terminal state C; the inviter direction stays refused and is now pinned against a manager with the rung wired; the SMS-invite scope follows the tree's own comments assigning it to this card; the docs sentence and the changeset are accurate as amended in round 2. The release action stays with the services seat, whose in-seat review owns the landing. If the head is green and still unlanded at this seat's next check-in, the stall clause applies and this seat lands it on the seat's PASS.


Generated by Claude Code

@os-sales
os-sales marked this pull request as ready for review September 4, 2026 01:37
@claude

claudeBot commented Sep 4, 2026

Copy link
Copy Markdown
ContributorAuthor

Landing — contract review PASSED, patch round 2 verified, carriers cleared, armed

domain:services execution seat, session session_01AUF1NoViznQK32gqpK8wS8.

CI on head 914eec5c802f: all checks complete — 32 green, 2 skipped, 0 failures, 0 incomplete, read at job level with the latest run per check name. All six Test Core shards passed individually; the aggregator is not a substitute for reading them.

Landing order observed: carriers cleared first (both needs:contract-review — on this PR and on card #14641 — each a read-modify-write with a comparative read-back, both MATCH), then undraft with draft=false confirmed on read-back, then auto-merge armed at 01:37:23Z. ⛔ One re-arm maximum, unspent.

What patch round 2 corrected, and what it found that nobody ordered

The Clause-② contract review (PASS, verdict adopted at 14641#issuecomment-5534181333) found the changeset claiming "an imported phone-only account — the row's locale wins". That is false for the only in-repo caller, re-measured by this seat before ordering the fix: admin-import-users.ts contains 0 occurrences of locale (positive control: sendInviteSms twice in the same file), and sys_user.locale declares no column default. A changeset becomes release notes, so it would have shipped a claim users do not get.

The dev extended the fix past the two places it was given. The same overstatement sat in two sibling comments it had authored the round before (setDefaultSmsLocale, phone-sms-texts.ts) and in the PR body — which is what reviewers actually read. Its reasoning: leaving an identical false claim next door defeats the correction. Verified here — purest form and imported phone-only now return 0 in the PR body and in both source files (control: sendPhoneInviteSms still resolves, 1 hit).

The test move was proved, not asserted.vitest list places all five SMS pins under the new #14641 describe, and the #14762 block retains exactly its 7 OTP tests — 7 + 5 = the 12 that were nested before, so nothing was captured or dropped. A pass count cannot see that; the reporter path can. Suite count 332/332, matching the review's independent baseline exactly, and the diff filtered to expect( / await / const lines is empty for that file.

No ablation re-run, deliberately and stated: the round changes no executable line, so round 1's result (predicted 9 red, measured 9, test-for-test) still describes this tree — and the review re-derived the same conclusion independently by mutation, wiring the inviter direction and getting exactly the 2 red it should.

Recorded so it does not evaporate

On the invitation surface, "branch 2 took the default path" and "branch 1 resolved to nothing" are observationally identical — no request rung sits between them. Only #14762's existing pin distinguishes them; the #14641 block alone cannot, and cannot in principle. This is a limit of the surface, not a defect in the pins, and it is carried in the PR body as well as here.

On MERGED, pm:dispatched comes off card #14641.


Generated by Claude Code

@os-sales
os-sales added this pull request to the merge queueSep 4, 2026
Merged via the queue into main with commit f074616Sep 4, 2026
42 checks passed
@os-sales
os-sales deleted the claude/issue-14641-invitation-locale-rung branch September 4, 2026 02:04
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

3 participants

@hotlong@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(auth): an invitation reads the invitee's own sys_user.locale when the address already holds a row - #15119

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-14641-invitation-locale-rung
Sep 4, 2026
Merged

fix(auth): an invitation reads the invitee's own sys_user.locale when the address already holds a row#15119
os-sales merged 2 commits into
mainfrom
claude/issue-14641-invitation-locale-rung

Conversation

@claude

@claudeclaudeBot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Fixes#14641

What this changes

The four auth sends whose requester is the recipient gained a per-recipient language rung in #14762 (sys_user.locale, ruled on #13881). The two invitation sends did not, and the recorded reason was structural rather than an oversight: an invitee generally has no sys_user row until they accept, so there is no stored language to read — and the inviter'sAccept-Language is the wrong authority, since an English-speaking admin would then silently send English invitations to a Chinese-language workspace's new hires.

That reason covers only one of the two populations an invitation actually reaches. Both invitation sends now take a two-branch shape:

  1. the address / phone number already carries a sys_user row whose locale is set — an existing platform user invited into a second organization, or a re-invitation → that row's locale wins;
  2. a genuinely new invitee with no row → the deployment default stands, because their language is still truly unknown at invitation time. So does an invitee whose row exists but names no language: an unset column is not a choice.

The inviter direction stays rejected on both branches.#13881's ruling item 3 fixes the chain as recipient locale → deployment default; what opened here is the invitee's own column, never the inviter's header. That abstention is now pinned against a manager that has the top rung wired, which is a stronger pin than the old one against a manager with no rung at all.

The card's terminal state, as implemented

Terminal state C reads 「邀请邮件按被邀请人存储的语言选模板」. Taken literally that cannot hold for every invitee, because most invitees have no row to read. The two-branch shape above is the implementable reading of the ruled state — a clarification, not a re-opened option.

Branch 1 is reachable — measured, not assumed

This was the card's stop-and-report condition, so it was measured first.

  • Email. better-auth's create-invitation route rejects only an address that is already a member of this organization (USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION, plugins/organization/routes/crud-invites.mjs:124-127 in the installed 1.7.2). An existing account invited into another org reaches sendInvitationEmail normally, and so does the resend branch (:150). Accepting an invitation in fact requires a session whose email matches (:269), so an invitee who already holds an account is the mainstream flow, not an edge case.
  • SMS.sendPhoneInviteSms reaches a row by construction: its one in-repo caller, the identity import endpoint's invite policy, creates the account and only then sends the message.

⚠️What the SMS path yields today — stated precisely, because a changeset becomes release notes. The rung is wired there and reads the row whenever the row carries a locale, but admin-import-users.ts never writes locale (0 occurrences; positive control: sendInviteSms twice in the same file) and sys_user.locale declares no column default (positive control: defaultValue appears 15 times elsewhere in that object). So on the only in-repo caller the column is empty at send time and the invitation SMS still resolves to the deployment default — unchanged for that flow. What the rung buys there is that an out-of-repo caller, or a future import that populates locale, is read rather than ignored. The behaviour users see change today is on the invitation email.

Scope: both invitation surfaces, per the repo's own record

The card title names the invitation email, but three shipped in-repo comments assign the SMS invite rung to this card by number — auth-manager.ts (setDefaultSmsLocale, renderPhoneSmsBody), phone-sms-texts.ts:25, and the pin auth-manager.test.ts carried as the SMS INVITE path is untouched — its rung is #14641's. Same defect class, same gate family, same file. Leaving it would have left the card's own named surface half done.

Matching is exact, and that is safe here rather than merely tolerable

better-auth lowercases the invitee address on the invite route and the stored user.email on sign-up (api/routes/sign-up.mjs:166), so both sides of the predicate are already in the same case. email and phone_number are both unique: true in the user table sys_user is backed by (@better-auth/coredb/get-tables.mjs). An address that resolves no row lands on the deployment default — the documented floor, not a failure — and, as everywhere else on this ladder, a failing recipient read never blocks a send.

Pins

Both branches are pinned on both surfaces, and every locale pin asserts in both directions — the marker for the locale that should have won present, the marker for the other absent — with the two tags swapped in a second case so neither can be the tag that always wins.

The SMS pins assert on genuinely different rendered text (账号已开通 vs Sign in with this phone number, the two built-in invite bodies).

Branch 2's default is shown to come from the default path, not from branch 1 silently resolving nothing. Both outcomes are otherwise identical from the payload, so each branch-2 pin drives one engine, one table, two addresses: it answers zh-CN for the address that carries a row and nothing for the one that does not, in the same test. That separates "the read ran and found nothing" from "the read never ran" / "this engine answers nothing". The read predicate is asserted separately (where, fields, context.isSystem), including that the address read is the invitee's and not the inviter's — the inviter is given a row with a different language, and it must not be reached at all.

Also re-pinned: the #2766 placeholder-address refusal still happens before any recipient read, now that a read sits on this path.

Ablation

Both rungs reverted (emailLocaleArg(undefined, storedLocale)emailLocaleArg(); the SMS storedLocale argument dropped) while keeping both reads, so the ablation isolates the rung rather than the lookup.

  • Predicted: 9 red — 6 in auth-email-locale.test.ts, 3 in auth-manager.test.ts; with the inviter-direction pin and the read-predicate pins staying green, because they measure the abstention and the read, not the rung.
  • Measured: 9 red, Tests 9 failed | 323 passed (332) — the same nine, test for test. No divergence.

The mutation was confirmed on disk before measuring (injected marker count 2, the removed spellings at 0 and 1 — the OTP site legitimately keeps its own storedLocale argument — and the blob hash differing from HEAD). The restore leg was proven the same way, not by the trap firing: git diff HEAD empty, git hash-object byte-identical to the HEAD blob, and zero markers left. No rebuild was needed — the suites import the package's own src/, not its dist/.

Verification

  • pnpm --filter @objectstack/plugin-auth test93 files / 1974 tests passed.
  • pnpm --filter @objectstack/plugin-auth run typecheck — green, including check:test-typecheck (test-typecheck debt ledger held: 10 files / 94 errors / 23 pinned signatures, unchanged). ⚠️ Worth stating because it would otherwise read as covered when it is not: the package's maintsc --noEmit program excludes*.test.ts (--listFiles → 0 hits for both edited test files). It is check:test-typecheck, under tsconfig.test.json, that compiles them — --listFiles confirms both are in that program.
  • pnpm --filter @objectstack/plugin-email exec vitest run src/auth-templates-locales.test.ts — 34 passed; it owns the template-rows half and asserts no invitation locale rule.
  • Derived gate family (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, which takes the change set from the merge base itself): 74 commands, 72 green, plus check-adr-anchors green (always-runs tail, excluded from the derived family).
  • All of the above ran on d23d9664ef, which is this branch's head.

Gates answering NOT MEASURED (exit 3), and whether this diff can move them

Neither is a finding, and neither is a pass:

  • check:dual-build-cjs-loads — exit 3, PREREQUISITE NOT MET: it reads built output and 41 packages have no dist/ in this worktree. This diff cannot move it — it adds no export and edits no package.json. CI builds the repo and measures it there.
  • check-test-completeness — exit 3 by construction: it needs a saved turbo run test log, and the derived family names it with no argument. Its own output says to record it as NOT MEASURED.

check:skill-examples first exited 1 for the same prerequisite reason (packages/client-react/dist held no declarations — a prerequisite wearing a finding's exit code, not a red). Building client-react and client converted it into a real reading: 257 prose examples type-check across 3 surfaces.

ESLint — a declared narrowing, not a skip

Repo-scale pnpm lint is CI's run. Locally this is a narrowing with its three pieces of evidence:

  1. Population read from ESLint itself, not guessed: run over all 6 changed paths, ESLint reports the .md and .mdx as File ignored because no matching configuration was supplied — so its population among these changes is the 4 TypeScript files.
  2. Counts read from --format json: 6 results, 0 errors, the only 2 messages being those two "ignored" notices. The 4 linted files are clean.
  3. Config invariance for untouched files: eslint.config.mjs states, with its own recorded positive control (lines 326-335), that this repo "never enables type-aware linting (no parserOptions.project, no typed @typescript-eslint rules) for ANY file, test or not". Every rule is therefore per-file syntactic, and this diff cannot move a verdict on a file it did not touch.

The .md/.mdx are covered instead by the docs gates in the derived family — check:corpus-claim-drift, check:doc-authoring, check:doc-anchors, check:docs-single-h1, check:docs-audit-scope, check:doc-frontmatter, check:docs-section-name — all green.

Docs

content/docs/permissions/authentication.mdx said "The invitation SMS reads the deployment default alone." This change makes that sentence false, so it is corrected in this PR: both SMS bodies now resolve the same way, with a note that for the invitation the account normally does exist because identity import creates it before sending.

Swept content/docs/** for any other statement of the invitation locale rule. Zero further hits, with a positive control: the term invitation is live in the corpus (16 occurrences in authentication.mdx alone, hits across 10 pages), so the zero is a reading rather than a broken search. No shipped page states the invitation email locale rule — the auth email ladder is undocumented as a whole, so nothing there is falsified.

content/docs/releases/v17.mdx mentions invitations but states no locale rule for them, and is release-owned in any case — not touched.

Clause ②: YESneeds:contract-review applied, and deliberately not cleared

This diff makes a falsifiable contract-semantics claim rather than an operator-list or spelling change. It extends the #13881 / #14788-ruled resolution ladder onto a send the ruling record had explicitly carved out, and it does so on a two-branch reading that no ruling states verbatim — the reading is a clarification this lane authored to make the ruled terminal state implementable at all. The accept set of "which locale an invitation renders in" changes for a real population (every invitee who already holds an account).

Against that: no packages/spec edit, no new public export, no signature change, and the changeset is a patch. The carrier is applied for the semantics claim, not for a surface widening. Per the dispatch, this lane does not clear it — the seat clears it after review.

Changeset

.changeset/invitation-invitee-stored-locale.md@objectstack/plugin-auth: patch.

Patch round 2 — Clause ② contract review PASSED

The review was adversarial and mutation-based: it wired the inviter direction and confirmed the abstention pin fails (2 red, exactly as it should). It confirmed branch 1 reachable on both surfaces, the locale discrimination genuine (present-AND-absent, tags swapped), scope clean (deliverPhoneOtp, the four #15107 sends and phoneSmsLocaleChain all untouched), the docs replacement accurate including the no-row case, and no stale shipped statement left behind.

Two wording items followed, both landed in 914eec5c80 — no behaviour, assertion or fixture change:

  1. The SMS-branch overstatement above, corrected here and in the changeset and the three code comments that carried it (sendPhoneInviteSms, setDefaultSmsLocale, phone-sms-texts.ts). content/docs/** deliberately untouched — the review found the shipped sentence literally accurate as written.
  2. Test provenance. The five SMS invitation pins were nested inside describe('#14762 …'), so their reporter path credited the wrong card. They now sit in their own sibling describe naming finding(plugin-auth): 邀请邮件的语言仍取部署默认——等用户级语言列落地后给邀请单独一梯级(#14319 裁 A-now/C-later 的追踪) #14641. A move only: no re-indentation, and no expect / await / const line in the diff. Verified with vitest list — all five now read … > #14641 — the invitation SMS reads the invitee's own locale > …, and the #14762 block retains exactly its 7 OTP tests.

Re-verified on 914eec5c80: 332/332 on the two suites (matching the review's baseline exactly), typecheck green with the test-typecheck debt ledger unchanged (10 files / 94 errors / 23 pinned signatures), and the changeset / comment-mask / census / keyed-text / nul-byte ratchet gates all green on the committed head.

The review also recorded one limit that is not this PR's to fix: on the invitation surface, "branch 2 took the default path" and "branch 1 resolved to nothing" are observationally identical, and only #14762's existing pin distinguishes them — the #14641 block alone cannot, in principle.

needs:contract-review stays applied: the review passed, and the seat clears the carrier as the last step before arming.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8


Generated by Claude Code

…en the address already holds a row
The two invitation sends kept the deployment rung while the other four auth
sends gained a per-recipient one in #14762. The recorded reason — an invitee
has no `sys_user` row until acceptance — covers only one of the two
populations an invitation reaches, so both sends now take a two-branch shape:
1. an address / phone number that ALREADY carries a row (an existing user
invited into a second organization, a re-invitation, or an imported
phone-only account) uses that row's `locale`;
2. a genuinely new invitee with no row keeps the deployment default.
The inviter direction stays rejected on both branches, now pinned against a
manager that HAS the top rung wired rather than one with no rung at all.
Reachability of branch 1 is measured, not assumed: better-auth's
`create-invitation` rejects only an address already a member of THIS org
(`routes/crud-invites.mjs`, installed 1.7.2), and the SMS invite's one in-repo
caller creates the account before it sends.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@github-actions

github-actionsBot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 3 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/phone-sms-texts.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • 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))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/phone-sms-texts.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 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 f594e70d796cbdd8e751c30105ede566acfec584packageMentionDocs.

Which tree this was computed on

This run read content/docs from 44226537f30f7cc46ce5f82834776a55082a0570 — the merge of head 914eec5c802f51d741b1d4f60ecc221f87228834 into base f594e70d796cbdd8e751c30105ede566acfec584, 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 44226537f30f7cc46ce5f82834776a55082a0570 && git checkout 44226537f30f7cc46ce5f82834776a55082a0570
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f594e70d796cbdd8e751c30105ede566acfec584 914eec5c802f51d741b1d4f60ecc221f87228834 && git checkout -B drift-repro f594e70d796cbdd8e751c30105ede566acfec584 && git merge --no-ff 914eec5c802f51d741b1d4f60ecc221f87228834
node scripts/docs-audit/affected-docs.mjs --json f594e70d796cbdd8e751c30105ede566acfec584

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

… pins their own #14641 describe
Two wording items from the Clause-2 contract review, which passed. No
behaviour, assertion or fixture changes.
1. The changeset claimed an imported phone-only account's `locale` wins on the
SMS invite path. Re-measured: `admin-import-users.ts` contains 0
occurrences of `locale` (positive control: `sendInviteSms` twice in the
same file) and `sys_user.locale` declares no column default (positive
control: `defaultValue` appears 15 times elsewhere in that object). So on
the only in-repo caller the row exists but its column is empty at send
time, and that flow still resolves to the deployment default — the
pre-change behaviour. The rung is wired and answers for an out-of-repo
caller or a future import that populates the column; a changeset becomes
release notes, so it now says exactly that. The same overstatement is
corrected in the three code comments carrying it (`sendPhoneInviteSms`,
`setDefaultSmsLocale`, `phone-sms-texts.ts`).
2. The five SMS invitation pins were nested inside `describe('#14762 ...')`,
so their reporter path credited the wrong card. They now sit in their own
sibling describe naming #14641. Move only — no re-indentation, and no
`expect`/`await`/`const` line in the diff.
`content/docs/**` deliberately untouched: the review found the shipped
sentence literally accurate as written.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@hotlongClaude

Copy link
Copy Markdown
Contributor

Director audit (summon 13, session_01WXyGTWPbbreqXow7Z2pZCk), head 914eec5c8: read the diff and the in-lane review record in the body; concur with PASS. The two-branch reading is the only implementable form of terminal state C; the inviter direction stays refused and is now pinned against a manager with the rung wired; the SMS-invite scope follows the tree's own comments assigning it to this card; the docs sentence and the changeset are accurate as amended in round 2. The release action stays with the services seat, whose in-seat review owns the landing. If the head is green and still unlanded at this seat's next check-in, the stall clause applies and this seat lands it on the seat's PASS.


Generated by Claude Code

@os-sales
os-sales marked this pull request as ready for review September 4, 2026 01:37
@claude

claudeBot commented Sep 4, 2026

Copy link
Copy Markdown
ContributorAuthor

Landing — contract review PASSED, patch round 2 verified, carriers cleared, armed

domain:services execution seat, session session_01AUF1NoViznQK32gqpK8wS8.

CI on head 914eec5c802f: all checks complete — 32 green, 2 skipped, 0 failures, 0 incomplete, read at job level with the latest run per check name. All six Test Core shards passed individually; the aggregator is not a substitute for reading them.

Landing order observed: carriers cleared first (both needs:contract-review — on this PR and on card #14641 — each a read-modify-write with a comparative read-back, both MATCH), then undraft with draft=false confirmed on read-back, then auto-merge armed at 01:37:23Z. ⛔ One re-arm maximum, unspent.

What patch round 2 corrected, and what it found that nobody ordered

The Clause-② contract review (PASS, verdict adopted at 14641#issuecomment-5534181333) found the changeset claiming "an imported phone-only account — the row's locale wins". That is false for the only in-repo caller, re-measured by this seat before ordering the fix: admin-import-users.ts contains 0 occurrences of locale (positive control: sendInviteSms twice in the same file), and sys_user.locale declares no column default. A changeset becomes release notes, so it would have shipped a claim users do not get.

The dev extended the fix past the two places it was given. The same overstatement sat in two sibling comments it had authored the round before (setDefaultSmsLocale, phone-sms-texts.ts) and in the PR body — which is what reviewers actually read. Its reasoning: leaving an identical false claim next door defeats the correction. Verified here — purest form and imported phone-only now return 0 in the PR body and in both source files (control: sendPhoneInviteSms still resolves, 1 hit).

The test move was proved, not asserted.vitest list places all five SMS pins under the new #14641 describe, and the #14762 block retains exactly its 7 OTP tests — 7 + 5 = the 12 that were nested before, so nothing was captured or dropped. A pass count cannot see that; the reporter path can. Suite count 332/332, matching the review's independent baseline exactly, and the diff filtered to expect( / await / const lines is empty for that file.

No ablation re-run, deliberately and stated: the round changes no executable line, so round 1's result (predicted 9 red, measured 9, test-for-test) still describes this tree — and the review re-derived the same conclusion independently by mutation, wiring the inviter direction and getting exactly the 2 red it should.

Recorded so it does not evaporate

On the invitation surface, "branch 2 took the default path" and "branch 1 resolved to nothing" are observationally identical — no request rung sits between them. Only #14762's existing pin distinguishes them; the #14641 block alone cannot, and cannot in principle. This is a limit of the surface, not a defect in the pins, and it is carried in the PR body as well as here.

On MERGED, pm:dispatched comes off card #14641.


Generated by Claude Code

@os-sales
os-sales added this pull request to the merge queueSep 4, 2026
Merged via the queue into main with commit f074616Sep 4, 2026
42 checks passed
@os-sales
os-sales deleted the claude/issue-14641-invitation-locale-rung branch September 4, 2026 02:04
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

3 participants

@hotlong@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(auth): an invitation reads the invitee's own sys_user.locale when the address already holds a row - #15119

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-14641-invitation-locale-rung
Sep 4, 2026
Merged

fix(auth): an invitation reads the invitee's own sys_user.locale when the address already holds a row#15119
os-sales merged 2 commits into
mainfrom
claude/issue-14641-invitation-locale-rung

Conversation

@claude

@claudeclaudeBot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Fixes#14641

What this changes

The four auth sends whose requester is the recipient gained a per-recipient language rung in #14762 (sys_user.locale, ruled on #13881). The two invitation sends did not, and the recorded reason was structural rather than an oversight: an invitee generally has no sys_user row until they accept, so there is no stored language to read — and the inviter'sAccept-Language is the wrong authority, since an English-speaking admin would then silently send English invitations to a Chinese-language workspace's new hires.

That reason covers only one of the two populations an invitation actually reaches. Both invitation sends now take a two-branch shape:

  1. the address / phone number already carries a sys_user row whose locale is set — an existing platform user invited into a second organization, or a re-invitation → that row's locale wins;
  2. a genuinely new invitee with no row → the deployment default stands, because their language is still truly unknown at invitation time. So does an invitee whose row exists but names no language: an unset column is not a choice.

The inviter direction stays rejected on both branches.#13881's ruling item 3 fixes the chain as recipient locale → deployment default; what opened here is the invitee's own column, never the inviter's header. That abstention is now pinned against a manager that has the top rung wired, which is a stronger pin than the old one against a manager with no rung at all.

The card's terminal state, as implemented

Terminal state C reads 「邀请邮件按被邀请人存储的语言选模板」. Taken literally that cannot hold for every invitee, because most invitees have no row to read. The two-branch shape above is the implementable reading of the ruled state — a clarification, not a re-opened option.

Branch 1 is reachable — measured, not assumed

This was the card's stop-and-report condition, so it was measured first.

  • Email. better-auth's create-invitation route rejects only an address that is already a member of this organization (USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION, plugins/organization/routes/crud-invites.mjs:124-127 in the installed 1.7.2). An existing account invited into another org reaches sendInvitationEmail normally, and so does the resend branch (:150). Accepting an invitation in fact requires a session whose email matches (:269), so an invitee who already holds an account is the mainstream flow, not an edge case.
  • SMS.sendPhoneInviteSms reaches a row by construction: its one in-repo caller, the identity import endpoint's invite policy, creates the account and only then sends the message.

⚠️What the SMS path yields today — stated precisely, because a changeset becomes release notes. The rung is wired there and reads the row whenever the row carries a locale, but admin-import-users.ts never writes locale (0 occurrences; positive control: sendInviteSms twice in the same file) and sys_user.locale declares no column default (positive control: defaultValue appears 15 times elsewhere in that object). So on the only in-repo caller the column is empty at send time and the invitation SMS still resolves to the deployment default — unchanged for that flow. What the rung buys there is that an out-of-repo caller, or a future import that populates locale, is read rather than ignored. The behaviour users see change today is on the invitation email.

Scope: both invitation surfaces, per the repo's own record

The card title names the invitation email, but three shipped in-repo comments assign the SMS invite rung to this card by number — auth-manager.ts (setDefaultSmsLocale, renderPhoneSmsBody), phone-sms-texts.ts:25, and the pin auth-manager.test.ts carried as the SMS INVITE path is untouched — its rung is #14641's. Same defect class, same gate family, same file. Leaving it would have left the card's own named surface half done.

Matching is exact, and that is safe here rather than merely tolerable

better-auth lowercases the invitee address on the invite route and the stored user.email on sign-up (api/routes/sign-up.mjs:166), so both sides of the predicate are already in the same case. email and phone_number are both unique: true in the user table sys_user is backed by (@better-auth/coredb/get-tables.mjs). An address that resolves no row lands on the deployment default — the documented floor, not a failure — and, as everywhere else on this ladder, a failing recipient read never blocks a send.

Pins

Both branches are pinned on both surfaces, and every locale pin asserts in both directions — the marker for the locale that should have won present, the marker for the other absent — with the two tags swapped in a second case so neither can be the tag that always wins.

The SMS pins assert on genuinely different rendered text (账号已开通 vs Sign in with this phone number, the two built-in invite bodies).

Branch 2's default is shown to come from the default path, not from branch 1 silently resolving nothing. Both outcomes are otherwise identical from the payload, so each branch-2 pin drives one engine, one table, two addresses: it answers zh-CN for the address that carries a row and nothing for the one that does not, in the same test. That separates "the read ran and found nothing" from "the read never ran" / "this engine answers nothing". The read predicate is asserted separately (where, fields, context.isSystem), including that the address read is the invitee's and not the inviter's — the inviter is given a row with a different language, and it must not be reached at all.

Also re-pinned: the #2766 placeholder-address refusal still happens before any recipient read, now that a read sits on this path.

Ablation

Both rungs reverted (emailLocaleArg(undefined, storedLocale)emailLocaleArg(); the SMS storedLocale argument dropped) while keeping both reads, so the ablation isolates the rung rather than the lookup.

  • Predicted: 9 red — 6 in auth-email-locale.test.ts, 3 in auth-manager.test.ts; with the inviter-direction pin and the read-predicate pins staying green, because they measure the abstention and the read, not the rung.
  • Measured: 9 red, Tests 9 failed | 323 passed (332) — the same nine, test for test. No divergence.

The mutation was confirmed on disk before measuring (injected marker count 2, the removed spellings at 0 and 1 — the OTP site legitimately keeps its own storedLocale argument — and the blob hash differing from HEAD). The restore leg was proven the same way, not by the trap firing: git diff HEAD empty, git hash-object byte-identical to the HEAD blob, and zero markers left. No rebuild was needed — the suites import the package's own src/, not its dist/.

Verification

  • pnpm --filter @objectstack/plugin-auth test93 files / 1974 tests passed.
  • pnpm --filter @objectstack/plugin-auth run typecheck — green, including check:test-typecheck (test-typecheck debt ledger held: 10 files / 94 errors / 23 pinned signatures, unchanged). ⚠️ Worth stating because it would otherwise read as covered when it is not: the package's maintsc --noEmit program excludes*.test.ts (--listFiles → 0 hits for both edited test files). It is check:test-typecheck, under tsconfig.test.json, that compiles them — --listFiles confirms both are in that program.
  • pnpm --filter @objectstack/plugin-email exec vitest run src/auth-templates-locales.test.ts — 34 passed; it owns the template-rows half and asserts no invitation locale rule.
  • Derived gate family (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, which takes the change set from the merge base itself): 74 commands, 72 green, plus check-adr-anchors green (always-runs tail, excluded from the derived family).
  • All of the above ran on d23d9664ef, which is this branch's head.

Gates answering NOT MEASURED (exit 3), and whether this diff can move them

Neither is a finding, and neither is a pass:

  • check:dual-build-cjs-loads — exit 3, PREREQUISITE NOT MET: it reads built output and 41 packages have no dist/ in this worktree. This diff cannot move it — it adds no export and edits no package.json. CI builds the repo and measures it there.
  • check-test-completeness — exit 3 by construction: it needs a saved turbo run test log, and the derived family names it with no argument. Its own output says to record it as NOT MEASURED.

check:skill-examples first exited 1 for the same prerequisite reason (packages/client-react/dist held no declarations — a prerequisite wearing a finding's exit code, not a red). Building client-react and client converted it into a real reading: 257 prose examples type-check across 3 surfaces.

ESLint — a declared narrowing, not a skip

Repo-scale pnpm lint is CI's run. Locally this is a narrowing with its three pieces of evidence:

  1. Population read from ESLint itself, not guessed: run over all 6 changed paths, ESLint reports the .md and .mdx as File ignored because no matching configuration was supplied — so its population among these changes is the 4 TypeScript files.
  2. Counts read from --format json: 6 results, 0 errors, the only 2 messages being those two "ignored" notices. The 4 linted files are clean.
  3. Config invariance for untouched files: eslint.config.mjs states, with its own recorded positive control (lines 326-335), that this repo "never enables type-aware linting (no parserOptions.project, no typed @typescript-eslint rules) for ANY file, test or not". Every rule is therefore per-file syntactic, and this diff cannot move a verdict on a file it did not touch.

The .md/.mdx are covered instead by the docs gates in the derived family — check:corpus-claim-drift, check:doc-authoring, check:doc-anchors, check:docs-single-h1, check:docs-audit-scope, check:doc-frontmatter, check:docs-section-name — all green.

Docs

content/docs/permissions/authentication.mdx said "The invitation SMS reads the deployment default alone." This change makes that sentence false, so it is corrected in this PR: both SMS bodies now resolve the same way, with a note that for the invitation the account normally does exist because identity import creates it before sending.

Swept content/docs/** for any other statement of the invitation locale rule. Zero further hits, with a positive control: the term invitation is live in the corpus (16 occurrences in authentication.mdx alone, hits across 10 pages), so the zero is a reading rather than a broken search. No shipped page states the invitation email locale rule — the auth email ladder is undocumented as a whole, so nothing there is falsified.

content/docs/releases/v17.mdx mentions invitations but states no locale rule for them, and is release-owned in any case — not touched.

Clause ②: YESneeds:contract-review applied, and deliberately not cleared

This diff makes a falsifiable contract-semantics claim rather than an operator-list or spelling change. It extends the #13881 / #14788-ruled resolution ladder onto a send the ruling record had explicitly carved out, and it does so on a two-branch reading that no ruling states verbatim — the reading is a clarification this lane authored to make the ruled terminal state implementable at all. The accept set of "which locale an invitation renders in" changes for a real population (every invitee who already holds an account).

Against that: no packages/spec edit, no new public export, no signature change, and the changeset is a patch. The carrier is applied for the semantics claim, not for a surface widening. Per the dispatch, this lane does not clear it — the seat clears it after review.

Changeset

.changeset/invitation-invitee-stored-locale.md@objectstack/plugin-auth: patch.

Patch round 2 — Clause ② contract review PASSED

The review was adversarial and mutation-based: it wired the inviter direction and confirmed the abstention pin fails (2 red, exactly as it should). It confirmed branch 1 reachable on both surfaces, the locale discrimination genuine (present-AND-absent, tags swapped), scope clean (deliverPhoneOtp, the four #15107 sends and phoneSmsLocaleChain all untouched), the docs replacement accurate including the no-row case, and no stale shipped statement left behind.

Two wording items followed, both landed in 914eec5c80 — no behaviour, assertion or fixture change:

  1. The SMS-branch overstatement above, corrected here and in the changeset and the three code comments that carried it (sendPhoneInviteSms, setDefaultSmsLocale, phone-sms-texts.ts). content/docs/** deliberately untouched — the review found the shipped sentence literally accurate as written.
  2. Test provenance. The five SMS invitation pins were nested inside describe('#14762 …'), so their reporter path credited the wrong card. They now sit in their own sibling describe naming finding(plugin-auth): 邀请邮件的语言仍取部署默认——等用户级语言列落地后给邀请单独一梯级(#14319 裁 A-now/C-later 的追踪) #14641. A move only: no re-indentation, and no expect / await / const line in the diff. Verified with vitest list — all five now read … > #14641 — the invitation SMS reads the invitee's own locale > …, and the #14762 block retains exactly its 7 OTP tests.

Re-verified on 914eec5c80: 332/332 on the two suites (matching the review's baseline exactly), typecheck green with the test-typecheck debt ledger unchanged (10 files / 94 errors / 23 pinned signatures), and the changeset / comment-mask / census / keyed-text / nul-byte ratchet gates all green on the committed head.

The review also recorded one limit that is not this PR's to fix: on the invitation surface, "branch 2 took the default path" and "branch 1 resolved to nothing" are observationally identical, and only #14762's existing pin distinguishes them — the #14641 block alone cannot, in principle.

needs:contract-review stays applied: the review passed, and the seat clears the carrier as the last step before arming.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8


Generated by Claude Code

…en the address already holds a row
The two invitation sends kept the deployment rung while the other four auth
sends gained a per-recipient one in #14762. The recorded reason — an invitee
has no `sys_user` row until acceptance — covers only one of the two
populations an invitation reaches, so both sends now take a two-branch shape:
1. an address / phone number that ALREADY carries a row (an existing user
invited into a second organization, a re-invitation, or an imported
phone-only account) uses that row's `locale`;
2. a genuinely new invitee with no row keeps the deployment default.
The inviter direction stays rejected on both branches, now pinned against a
manager that HAS the top rung wired rather than one with no rung at all.
Reachability of branch 1 is measured, not assumed: better-auth's
`create-invitation` rejects only an address already a member of THIS org
(`routes/crud-invites.mjs`, installed 1.7.2), and the SMS invite's one in-repo
caller creates the account before it sends.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@github-actions

github-actionsBot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 3 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/phone-sms-texts.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • 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))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/phone-sms-texts.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 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 f594e70d796cbdd8e751c30105ede566acfec584packageMentionDocs.

Which tree this was computed on

This run read content/docs from 44226537f30f7cc46ce5f82834776a55082a0570 — the merge of head 914eec5c802f51d741b1d4f60ecc221f87228834 into base f594e70d796cbdd8e751c30105ede566acfec584, 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 44226537f30f7cc46ce5f82834776a55082a0570 && git checkout 44226537f30f7cc46ce5f82834776a55082a0570
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f594e70d796cbdd8e751c30105ede566acfec584 914eec5c802f51d741b1d4f60ecc221f87228834 && git checkout -B drift-repro f594e70d796cbdd8e751c30105ede566acfec584 && git merge --no-ff 914eec5c802f51d741b1d4f60ecc221f87228834
node scripts/docs-audit/affected-docs.mjs --json f594e70d796cbdd8e751c30105ede566acfec584

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

… pins their own #14641 describe
Two wording items from the Clause-2 contract review, which passed. No
behaviour, assertion or fixture changes.
1. The changeset claimed an imported phone-only account's `locale` wins on the
SMS invite path. Re-measured: `admin-import-users.ts` contains 0
occurrences of `locale` (positive control: `sendInviteSms` twice in the
same file) and `sys_user.locale` declares no column default (positive
control: `defaultValue` appears 15 times elsewhere in that object). So on
the only in-repo caller the row exists but its column is empty at send
time, and that flow still resolves to the deployment default — the
pre-change behaviour. The rung is wired and answers for an out-of-repo
caller or a future import that populates the column; a changeset becomes
release notes, so it now says exactly that. The same overstatement is
corrected in the three code comments carrying it (`sendPhoneInviteSms`,
`setDefaultSmsLocale`, `phone-sms-texts.ts`).
2. The five SMS invitation pins were nested inside `describe('#14762 ...')`,
so their reporter path credited the wrong card. They now sit in their own
sibling describe naming #14641. Move only — no re-indentation, and no
`expect`/`await`/`const` line in the diff.
`content/docs/**` deliberately untouched: the review found the shipped
sentence literally accurate as written.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@hotlongClaude

Copy link
Copy Markdown
Contributor

Director audit (summon 13, session_01WXyGTWPbbreqXow7Z2pZCk), head 914eec5c8: read the diff and the in-lane review record in the body; concur with PASS. The two-branch reading is the only implementable form of terminal state C; the inviter direction stays refused and is now pinned against a manager with the rung wired; the SMS-invite scope follows the tree's own comments assigning it to this card; the docs sentence and the changeset are accurate as amended in round 2. The release action stays with the services seat, whose in-seat review owns the landing. If the head is green and still unlanded at this seat's next check-in, the stall clause applies and this seat lands it on the seat's PASS.


Generated by Claude Code

@os-sales
os-sales marked this pull request as ready for review September 4, 2026 01:37
@claude

claudeBot commented Sep 4, 2026

Copy link
Copy Markdown
ContributorAuthor

Landing — contract review PASSED, patch round 2 verified, carriers cleared, armed

domain:services execution seat, session session_01AUF1NoViznQK32gqpK8wS8.

CI on head 914eec5c802f: all checks complete — 32 green, 2 skipped, 0 failures, 0 incomplete, read at job level with the latest run per check name. All six Test Core shards passed individually; the aggregator is not a substitute for reading them.

Landing order observed: carriers cleared first (both needs:contract-review — on this PR and on card #14641 — each a read-modify-write with a comparative read-back, both MATCH), then undraft with draft=false confirmed on read-back, then auto-merge armed at 01:37:23Z. ⛔ One re-arm maximum, unspent.

What patch round 2 corrected, and what it found that nobody ordered

The Clause-② contract review (PASS, verdict adopted at 14641#issuecomment-5534181333) found the changeset claiming "an imported phone-only account — the row's locale wins". That is false for the only in-repo caller, re-measured by this seat before ordering the fix: admin-import-users.ts contains 0 occurrences of locale (positive control: sendInviteSms twice in the same file), and sys_user.locale declares no column default. A changeset becomes release notes, so it would have shipped a claim users do not get.

The dev extended the fix past the two places it was given. The same overstatement sat in two sibling comments it had authored the round before (setDefaultSmsLocale, phone-sms-texts.ts) and in the PR body — which is what reviewers actually read. Its reasoning: leaving an identical false claim next door defeats the correction. Verified here — purest form and imported phone-only now return 0 in the PR body and in both source files (control: sendPhoneInviteSms still resolves, 1 hit).

The test move was proved, not asserted.vitest list places all five SMS pins under the new #14641 describe, and the #14762 block retains exactly its 7 OTP tests — 7 + 5 = the 12 that were nested before, so nothing was captured or dropped. A pass count cannot see that; the reporter path can. Suite count 332/332, matching the review's independent baseline exactly, and the diff filtered to expect( / await / const lines is empty for that file.

No ablation re-run, deliberately and stated: the round changes no executable line, so round 1's result (predicted 9 red, measured 9, test-for-test) still describes this tree — and the review re-derived the same conclusion independently by mutation, wiring the inviter direction and getting exactly the 2 red it should.

Recorded so it does not evaporate

On the invitation surface, "branch 2 took the default path" and "branch 1 resolved to nothing" are observationally identical — no request rung sits between them. Only #14762's existing pin distinguishes them; the #14641 block alone cannot, and cannot in principle. This is a limit of the surface, not a defect in the pins, and it is carried in the PR body as well as here.

On MERGED, pm:dispatched comes off card #14641.


Generated by Claude Code

@os-sales
os-sales added this pull request to the merge queueSep 4, 2026
Merged via the queue into main with commit f074616Sep 4, 2026
42 checks passed
@os-sales
os-sales deleted the claude/issue-14641-invitation-locale-rung branch September 4, 2026 02:04
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

3 participants

@hotlong@os-sales@claude