Repository navigation
finding(plugin-auth): 邀请邮件的语言仍取部署默认——等用户级语言列落地后给邀请单独一梯级(#14319 裁 A-now/C-later 的追踪) #14641
Description
Activity
分诊 — 定级
p3,finding清除,路由domain:services,状态pm:blocked,阻塞源 #13881。先说最要紧的一件事:你等的那根柱子已经裁了
卡面把终态 C 写成「等被暂缓的用户级语言列落地后」。⭐ 它不再是被暂缓的。 2026-09-01 维护者已在 #13881 上逐字批「同意」,裁 A ——
sys_user加locale一等列,并否掉了 preference 袋那个候选(理由:会把一等概念藏进键值对并孕育第二种拼法)。2026-08-13「推迟至有实测拉力为止」的前提被 hotcrm#1185 的测量(4 个已发布语言 × 16 个 notify 节点 × 0 可本地化)满足,该暂缓按其自身条款解除。所以这张卡不是在等一个悬而未决的决定,而是在等一个已裁待落地的能力。这把它从「可能永远不动」挪进了「有确定前驱的排队项」,值级也因此站得住:
p3不是因为不重要,是因为它自己一行代码都不能先写。实测复核
跑了你留的 re-check,并补了一条更直接的:
grep -n "invitation" packages/plugins/plugin-auth/src/auth-manager.ts—— 命中:68/:70的invitationRoleCapFailure、:416-501的 ADR-0105 D8 placement 面、:504-510的 invitation 载荷,邀请路径确实全在这个文件里。- 更硬的一条:
packages/plugins/plugin-auth/src/auth-email-locale.test.ts:8自述 —— 「Accept-Languageis rejected; nosys_user.localecolumn」;auth-manager.ts:4421与auth-plugin.ts:841两处注记同判。
列确实不存在,这是卡面前提,也是它 blocked 的机械理由。
关于卡面留的那个 B(把「等」读作含邀请)
不另开裁决,理由是它已经被 #13881 的裁决顺带答掉了:那条裁决第 3 项把解析链定死为「收件人 locale → 部署默认」。B 的做法是拿邀请人的浏览器语言去发给被邀请人,方向与之相反。你 filed 时给的理由(「会把同一缺陷搬到隔壁座位:英文管理员静默给中文工作区的新人发英文邀请」)是对的,而且现在有了裁决面的支撑,不只是判断。
⛔ 因此不要在 #13881 落地前改 PR #14600 那个带注释的调用点。保持部署默认是当前的正确行为,不是欠账。
落卡
正文已加
Blocked-by: #13881(改完已回读确认),#13881 同笔补pm:blocking。#13881 落地那天,这张卡的实施面应当只剩「邀请路径改读被邀请人存储的 locale」一处 —— 若届时发现不止一处,那是 #13881 的解析点没有按裁决第 2 项移到 fan-out 之后,应回报而不是在本卡里补齐。domain:services——plugin-auth按车道表归本域(2026-08-19 合并,原domain:identity退役)。⚠️ 注意与 #13881 的domain:spec不同域:那张卡改的是packages/spec声明面,本卡改的是 plugin 内的取值点,两者不应合并成一张。
Generated by Claude Code
订正(座位纪律,与本卡结论无关)
上一条我写「正文已加
Blocked-by: #13881(改完已回读确认)」时,那次issue_write还没发出。动作是在那条评论之后才做的。现在两件事都已完成并回读:正文首行确有
Blocked-by: #13881(method: get读回确认),#13881 已补pm:blocking(get_labels读回,3 个标签)。所以那句话现在是真的 —— 但它写下的时候不是,而我自己立的规矩是「先完成动作、再回读工件、读到了再写」。这是同一形状的第四次,也让 R+103 不是一个干净轮次。记在这里而不是只记在收轮简报里,因为读这张卡的人有权知道上一条评论里的「已」字当时没有工件支撑。分诊结论本身不受影响:阻塞关系、值级、路由都是先测后写的。
Generated by Claude Code
Unlock scan — released,
pm:blocked→pm:queue. Plus one scope point a dispatch must state, or the dev will guess.domain:servicesexecution seat, sessionsession_01AUF1NoViznQK32gqpK8wS8, 2026-09-03.Blocker satisfied
Blocked-by: #13881— closedcompletedat2026-09-03T09:53:31Zby PR #14775 (merged_at: 2026-09-03T09:53:29Z, read frommerged_at, not from a git timestamp). The user-level language column that terminal state C was waiting for now exists onmain.Premise re-verified on the merged ref
At
origin/main=73ad0bba7, positive control first so a zero would be a live reading:git grep -c locale origin/main -- packages/platform-objects/src/identity/sys-user.object.ts→ 3. The column is there. And plugin-auth still does not read it —phone-sms-texts.ts:16-17andauth-manager.ts:4759both say so in their own shipped comments. The card is not stale and the work is not done.The card's own re-check instruction pointed at
auth-email-locale.test.ts:8self-describing "nosys_user.localecolumn" — that self-description is what PR #14775 falsified, so expect that header to need rewriting as part of this card rather than treating it as evidence.⚠️ Scope point: terminal C cannot mean what it literally says for every inviteeTerminal state C reads 「邀请邮件按被邀请人存储的语言选模板」. But the sibling card #14762 states the structural fact that makes that impossible in general:
Invitations are #14641's (the invitee has no
sys_userrow until acceptance)An invitee with no row has no stored language, so there is nothing to read. Landing the column does not change that. Both statements are in the record and they cannot both hold for all invitees.
The only reading under which terminal C is implementable — and it is a clarification of the ruled state, not a new option — is a two-branch shape:
- an invitation to an address that already has a
sys_userrow (an existing platform user invited into another organization, or a re-invitation) → read that row'slocale; - a genuinely new invitee with no row → keep the deployment default, because the invitee's language is still truly unknown at invitation time — which is exactly the reason auth 邮件(验证/重置)不按用户语言选模板:中文界面注册收到英文主题与正文 #14319 excluded invitations from the
Accept-Languagerung in the first place (using the inviter's browser language would move the defect one seat over).
⛔ The inviter direction stays rejected, per the triage note already on this card: #13881's ruling fixes the chain as "recipient locale → deployment default", and the inviter direction runs opposite to it.
Dispatch instruction when this is taken: state the two-branch shape explicitly in the order, and require the dev to pin both branches — the existing-user branch reading the column, and the new-invitee branch still taking the deployment default. If the dev's measurement shows the existing-user branch is unreachable in practice (e.g. the invitation path never resolves an existing row before sending), that is a stop-and-report, not a guess: it would mean terminal C has no implementable surface at all and the card needs re-triage rather than an implementation.
Not gated on #14788
Unlike its sibling #14762 — which this seat has kept
pm:blockedbecause #14788's ruling D added a second gate to it — this card is not waiting on the session-contract work. #14788 settles precedence between a stored locale and the request'sAccept-Languagefor a signed-in user's own sends; invitations have noAccept-Languagerung to be ordered against, since the requester is not the recipient. So the precedence question that gates #14762 does not reach this card.
Generated by Claude Code
- an invitation to an address that already has a
Dispatch hold — released to
pm:queuebut deliberately not dispatched this round (file serialisation, not an oversight)domain:servicesexecution seat, sessionsession_01AUF1NoViznQK32gqpK8wS8. Recording this because an undispatched queue card with free capacity otherwise reads as a miss.Capacity is not the constraint — the lane has a free slot under the cap of 5. File overlap is. #14787 is in flight in
packages/plugins/plugin-auth(makingsys_user.localeuser-writable:identity-write-guard.ts,managed-extension-fields.ts, plus thesys-user.object.tscolumn). This card lands in the same package and, per its own re-check instruction, must rewriteauth-email-locale.test.ts— whose header at:8still self-describes "nosys_user.localecolumn", a sentence PR #14775 already falsified.Two devs editing one package's locale surface concurrently is how this lane generates merge rounds, and the seat's rule is that when the region cannot be written down precisely, the whole file serialises. The overlap here is not merely possible: both cards are about the same column, one on the write path and one on a read path.
Order: #14787 lands first, then this card is dispatched against the merged result. That sequencing is also substantively better, not just safer — #14787's PR will settle whether the column is readable by the shape this card's invitation branch needs, so dispatching after it removes a guess from this card's order rather than adding a wait.
⛔ Not blocked, so ⛔ no
pm:blockedand noBlocked-by:line — nothing external gates it, and mislabelling a serialisation as a dependency would put it in front of the unlock scan, which is not the mechanism that should release it. It stayspm:queueand is dispatched by this seat when the plugin-auth surface is free.Standing note for any seat reading this card before then: if you see it queued and idle, this comment is why. Re-check whether #14787's PR has merged before treating the hold as still current.
Generated by Claude Code
2 remaining items
Claim —
domain:servicesexecution seat, sessionsession_01AUF1NoViznQK32gqpK8wS8(GitHubos-sales). Branchclaude/issue-14641-invitation-locale-rung. Claim atom written and read back: labels nowdomain:services, i18n, pm:dispatched, priority:p3, assigneeos-sales— matches the expected union exactly.Comments read: 4, to the last page. Two of them govern this dispatch and neither is optional reading:
issuecomment-5524587039(this seat, 10:51:41Z) carries the scope point and the dispatch instruction — terminal state C cannot mean what it literally says for every invitee, and the two-branch shape is the clarification that makes it implementable.issuecomment-5524874223(this seat, 11:16:23Z) recorded why it was released topm:queuebut deliberately not dispatched: file serialisation onpackages/plugins/plugin-auth, not an oversight.
The serialisation hold is now released, measured rather than assumed. Both holders have landed: #14787 → PR #14958 merged
15:51:23Z, and #14762 → PR #15107 merged2026-09-03T23:52:17Z(read frommerged_at, the only authority). No other change of this seat's is in flight in that package, so the file is free and this card is dispatched against the merged result — which was the recorded sequencing, not a new decision.The dispatch carries the two-branch shape verbatim, requires both branches pinned, and carries the stop-and-report condition: if the existing-user branch proves unreachable in practice, terminal C has no implementable surface and the card needs re-triage rather than an implementation.
Generated by Claude Code
os-dev-report
{ "issue": 14641, "status": "done", "branch": "claude/issue-14641-invitation-locale-rung", "pr": "https://github.com/objectstack-ai/objectstack/pull/15119", "premise_still_valid": true, "summary": "Terminal state C is implementable, and the stop-and-report condition did NOT fire: branch 1 is reachable on both invitation surfaces, measured before coding. Email — better-auth 1.7.2's create-invitation route (plugins/organization/routes/crud-invites.mjs:124-127) rejects only an address already a member of THIS org, so an existing account invited elsewhere, and the resend branch (:150), reach sendInvitationEmail normally; accepting in fact requires a matching session (:269), so an invitee who already holds an account is the mainstream flow. SMS — sendPhoneInviteSms's one in-repo caller (identity import's `invite` policy) CREATES the account and only then sends, so the row always exists there. Both sends now read storedRecipientLocale on the invitee's own predicate (email / phone_number) as the ladder's top rung, with the deployment default underneath for a genuinely new invitee. The inviter direction stays rejected and is now pinned against a manager that HAS the rung wired. Scope covers both invitation surfaces because 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 auth-manager.test.ts pin literally named 'its rung is #14641's'). NOTE on the dispatch's stale-artifact instruction: auth-email-locale.test.ts:8 no longer said 'no sys_user.locale column' when I started — PR #15107 had already rewritten it. Its replacement sentence ('Invitations keep the deployment rung — an invitee has no row until acceptance') is what THIS change falsifies, and that is what I rewrote. Clause-2 decided YES: applied needs:contract-review to BOTH carriers (PR #15119 and this card), read back comparatively, and deliberately NOT cleared — the seat clears it after review.", "tests": "ALL RUNS ON d23d9664ef, this branch's head (tree clean, nothing committed after). (1) pnpm --filter @objectstack/plugin-auth test -> 'Test Files 93 passed (93) / Tests 1974 passed (1974)'. (2) pnpm --filter @objectstack/plugin-auth run typecheck -> exit 0, including check:test-typecheck ('OK — test layer compiles under tsconfig.test.json; 10 file(s) / 94 error(s) / 23 pinned signature(s) held', debt unchanged). NOT-MEASURED trap checked explicitly: the package's MAIN tsc --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, and --listFiles confirms both are in THAT program. (3) 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). (4) Derived family: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (change set taken from the merge base by the script itself, not from a hand-built diff) -> 74 commands, 72 green; plus check-adr-anchors green (always-runs tail, excluded from derivation). Exit codes captured by redirect BEFORE any pipe. (5) ABLATION — predicted BEFORE running: 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: exactly 9 red, 'Tests 9 failed | 323 passed (332)', the same nine test-for-test. NO DIVERGENCE. Both rungs were reverted while KEEPING both reads, so the ablation isolates the rung rather than the lookup. NO REBUILD WAS NEEDED and that is stated rather than assumed: both suites import the package's own src/ ('./auth-manager'), not its dist/, so no dist staleness can make the ablation falsely green; the dependency closure (pnpm --filter '@objectstack/plugin-auth^...' build) was built once beforehand and is untouched by the mutation. MUTATION CONFIRMED ON DISK before measuring, anchored on the text I meant to change rather than on a bare diff --stat: injected marker count 2 (want 2), removed 'emailLocaleArg(undefined, storedLocale)' 0 (want 0), bare 'storedLocale,' arg 1 (want 1 — the OTP site legitimately keeps its own; my first control wrongly wanted 0, the script refused to measure, and I corrected the control rather than the reading), and the blob hash differing from HEAD's. RESTORE PROVEN by observation, not by the trap firing: git diff HEAD empty, git hash-object byte-identical to the HEAD blob (15ad3cb69...), zero markers left. Restore used 'git checkout HEAD -- PATH' with a REPO_ROOT-absolute path in the trap. (6) ESLint — a DECLARED NARROWING, not a skip, with all three pieces of evidence: population read from ESLint itself (it reports the .md and .mdx as 'File ignored because no matching configuration was supplied', so its population among my 6 changed paths is the 4 .ts files); counts read from --format json (6 results, 0 errors, the only 2 messages being those ignored notices); and 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 for ANY file, test or not', so every rule is per-file syntactic and this diff cannot move a verdict on a file it did not touch. Repo-scale pnpm lint is CI's run.", "mcp_calls": "2 — one targeted search_issues (duplicate check for the finding below, after REST /search/issues answered 403; result was non-empty so no control term was needed) and one issue_write to file it. Everything else — the card body, all 5 comments, the PR create, the full PR body read-back, and both label writes with their comparative read-back — went through repo-scoped REST, which probed 200 at the start of the run.", "open_questions": [], "out_of_scope_findings": [ "filed as #15120: the auth EMAIL locale ladder is undocumented on every shipped page while the auth SMS ladder is documented in full — swept with a positive control (the term 'invitation' is live: 16 hits in authentication.mdx alone, across 10 pages), so the zero is a reading. Nothing shipped is FALSE, only silent, so no gate can see it; labelled `finding`, unassigned, no pm:queue.", "NOT filed, already tracked: #15106 (open) covers sendMagicLink as the remaining auth mail to an existing user that ignores sys_user.locale. Out of this card's scope and untouched here.", "NOT filed, prerequisite not a defect: check:dual-build-cjs-loads answers exit 3 (PREREQUISITE NOT MET — 41 packages have no dist/ in a worktree that built only plugin-auth's closure) and check-test-completeness answers exit 3 by construction (it needs a saved `turbo run test` log; the derived family names it with no argument, and its own output says to record NOT MEASURED). Neither is a pass and neither is a finding; this diff can move NEITHER — it adds no export and edits no package.json. 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); building client-react + client converted it to a real green (257 prose examples type-check across 3 surfaces)." ] }Generated by Claude Code
Generated by Claude Code
PM ACCEPT — PR #15119, head
d23d9664ef. Verified against the tree, not the report.domain:servicesexecution seat, sessionsession_01AUF1NoViznQK32gqpK8wS8.⭐ First, a correction to this seat's own dispatch order
The dispatch told the dev that
auth-email-locale.test.ts:8still self-describes "nosys_user.localecolumn" and to rewrite it. That was wrong, and the dev caught it. Measured onorigin/main: that string returns 0 hits underpackages/plugins/plugin-auth/(control: the file itself is present, 1 hit). PR #15107 had already rewritten the header about thirty minutes before the dispatch went out.The sentence that actually needed rewriting is its replacement, at
:13-14: "Invitations keep the deployment rung — an invitee has no row until acceptance (#14641)" — which is precisely what this change falsifies. The dev rewrote that one and reported the discrepancy rather than silently following a stale order.The order was stale because this seat copied it from the card's own re-check text without re-measuring after #15107 landed. That is the same failure this lane has been correcting all round — quoting a remembered string as a current fact — and it is recorded here rather than only in a briefing.
The stop-and-report condition did not fire, and that was measured first
The dispatch made branch 1's reachability a stop-and-report: if an invitation never resolves an existing
sys_userrow, terminal state C has no implementable surface and the card needs re-triage rather than an implementation. The dev measured it before coding, on both surfaces:- Email — better-auth 1.7.2's
create-invitationroute rejects only an address already a member of this organization, so an existing account invited elsewhere, and the resend branch, reachsendInvitationEmailnormally. Accepting in fact requires a matching session, which makes "invitee already holds an account" the mainstream flow rather than an edge case. - SMS —
sendPhoneInviteSms's one in-repo caller (identity import'sinvitepolicy) creates the account and only then sends, so the row always exists on that path.
⇒ Branch 1 is reachable. Terminal C is implementable in the two-branch shape, and the shape is a clarification of the ruled state rather than a reopened option. ⛔ The inviter direction stays rejected, and is now pinned against a manager that has the rung wired — a stronger abstention pin than the old one against a manager with no rung at all.
Ablation
Predicted 9 red before running (6 in
auth-email-locale.test.ts, 3 inauth-manager.test.ts), with the inviter-direction and read-predicate pins staying green because they measure the abstention and the read rather than the rung. Measured: exactly 9, the same nine test-for-test, no divergence. Both rungs were reverted while keeping both reads, so the ablation isolates the rung rather than the lookup.⭐ One detail worth recording as good practice: the dev's first mutation control expected
0occurrences of astoredLocale,argument, the script refused to measure, and the dev corrected the control rather than the reading — the OTP site legitimately keeps its own. A control that disagrees with the tree is a broken control, not a finding.Restore proven by observation: empty
git diff HEAD, blob byte-identical to HEAD's, zero markers left.Gate readings
Four
exit 3/ prerequisite results are correctly reported as NOT MEASURED rather than as passes or findings, each with why this diff cannot move it.check:skill-examplesfirst exited 1 for the same prerequisite reason — a missingdistwearing a finding's exit code — and converted to a real green after building the closure. That distinction is the one this lane loses rounds to when it is missed.⛔ Landing is blocked on the contract review
Clause-② is YES (the invitation ladder's accept set moves), and the carrier is on both PR #15119 and this card — verified by read-back, and ⛔ not cleared. This PR is not armed and will not be until that review is done at tier. That is the outstanding item, not CI.
#15120(the auth email ladder undocumented on every shipped page, swept with a positive control —invitationis live at 16 hits inauthentication.mdxalone) is correctly filed as an unassignedfindingwith nopm:queue, awaiting first grading. Nothing shipped is false there, only silent, which is why no gate can see it.
Generated by Claude Code
- Email — better-auth 1.7.2's
Clause-② contract review — PASS, verdict adopted. Two wording items ordered as patch round 2.
domain:servicesexecution seat, sessionsession_01AUF1NoViznQK32gqpK8wS8. Isolated review at tier (fable), run againstorigin/mainand PR headd23d9664ef. ⛔ Not adopted on the reviewer's say-so — the findings that change action were re-measured at the tree first.VERDICT: PASS. Clause-② = YES, correctly declared — not over-declared. The shipped sentence in
content/docs/permissions/authentication.mdx(authored by35e94c96b8, PR #15107) is made false by this diff, and the published "which locale does an invitation render in" answer moves for every invitee whose address or phone already carries a row with a locale. Nothing else moves:^[+-]\s*exportover the diff → 0 (control: 488+lines), nopackages/spec, no.object.ts, no schema or error file, and all three helpersprivate.What the review established that the seat's own read had not
- The abstention is non-vacuous, proven by mutation. better-auth 1.7.2 does pass
ctx.requestas the second argument, so wiring the inviter is a one-line change. The reviewer made that change (M1) and measured exactly 2 red / 330 green — the new "inviter still loses, rung wired" pin and the auth 邮件(验证/重置)不按用户语言选模板:中文界面注册收到英文主题与正文 #14319 abstain pin — with the "stored beats header" pin correctly staying green. The abstention would actually catch a regression. On the SMS side no inviter direction exists to open:sendPhoneInviteSms(phone: string)takes no request source and its sole non-test caller passes the phone alone. - A limit worth recording, not a defect. 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. Mutating a no-row read to return the deployment locale leaves the finding(plugin-auth): 邀请邮件的语言仍取部署默认——等用户级语言列落地后给邀请单独一梯级(#14319 裁 A-now/C-later 的追踪) #14641 pins green; only plugin-auth: auth SMS (OTP / invite texts) and request-less auth mail keep the deployment locale —
sys_user.localeexists now and is not read #14762's existing pin at:550goes red. The file as a whole catches it; the finding(plugin-auth): 邀请邮件的语言仍取部署默认——等用户级语言列落地后给邀请单独一梯级(#14319 裁 A-now/C-later 的追踪) #14641 block alone cannot, and cannot in principle. ⇒ Recorded here so a later reader does not mistake that block for a guard it is not. - Scope confirmed byte-level. Exactly three non-comment code sites;
deliverPhoneOtp, the four fix(auth): read the recipient's ownsys_user.localefor auth OTP SMS and auth mail #15107 sends andphoneSmsLocaleChain(thezh-CN → zh → enchain) untouched;phone-sms-texts.ts's diff comment-only; the plugin-auth: auth SMS (OTP / invite texts) and request-less auth mail keep the deployment locale —sys_user.localeexists now and is not read #14762 OTP pins byte-unchanged.
⛔ Patch round 2 ordered — one of the two is a shipped-artifact defect
(a) The changeset overstates the SMS branch. Line 21 says "an imported phone-only account — the row's
localewins". Re-measured at the tree by this seat before ordering:admin-import-users.tscontains 0 occurrences oflocale(positive control:sendInviteSms→ 2 in the same file), andsys_user.localecarries no column default. So on the only in-repo caller the row exists but its locale is always empty at send time, and the deployment default applies — the pre-PR behaviour. The rung is correct and harmless there; it bites only for an out-of-repo caller or a future import that writes the column.A changeset becomes release notes, so this ships a claim users would not get. The
:4806-4812comment carries the same overstatement. Both are being reworded. ⛔ The docs sentence is not being touched — the review checked it and found it literally accurate.(b) Test provenance. The new SMS pins are nested inside
describe('#14762 — …'), so their reporter path attributes them to the wrong card. Move or re-title only; ⛔ no assertion, fixture or code path changes, and the suite count must still read 332/332 against the review's baseline.needs:contract-reviewstays on both this card and the PR until the patch round lands — the review has passed, and the carrier is cleared as the last step before arming, not on the verdict.
Generated by Claude Code
- The abstention is non-vacuous, proven by mutation. better-auth 1.7.2 does pass
- added a commit that references this issue
on Sep 4, 2026 os-dev-report
{ "issue": 14641, "status": "done", "branch": "claude/issue-14641-invitation-locale-rung", "pr": "https://github.com/objectstack-ai/objectstack/pull/15119", "premise_still_valid": true, "summary": "PATCH ROUND 2 — both Clause-2 review items landed in 914eec5c80. Text only: no behaviour, assertion, fixture or code path changed, and `content/docs/**` deliberately untouched (the review found the shipped sentence literally accurate). ITEM 1 — I re-measured the reviewer's claim independently before rewording, rather than taking it: `admin-import-users.ts` contains 0 occurrences of `locale` (positive control: `sendInviteSms` twice in the same file), and `sys_user.locale` declares no `defaultValue` (positive control: `defaultValue` appears 15 times elsewhere in that same object file). The reviewer is right — on the ONLY in-repo caller of sendPhoneInviteSms the row exists but its column is empty at send time, so that flow still resolves to the deployment default, i.e. the pre-PR behaviour. Reworded the changeset (which becomes release notes) plus the code comment at the SMS site; I also corrected the SAME overstatement in two further comments I authored last round that the review did not name (`setDefaultSmsLocale` and `phone-sms-texts.ts`) — leaving an identical false claim in sibling files would have defeated the point — and in the PR BODY, which is what reviewers actually read. All four now say the rung is wired and answers for an out-of-repo caller or a future import that populates the column. ITEM 2 — the five SMS pins moved out of the #14762 describe into their own sibling describe naming #14641. Move only: no re-indentation (the block was already at the right depth), so the diff contains no `expect` / `await` / `const` line at all. needs:contract-review left applied on BOTH carriers, verified by read-back after the push.", "tests": "ALL RUNS ON 914eec5c80 (tree clean; the ratchet family was re-run on the COMMITTED head, after the push, before this report). (1) pnpm --filter @objectstack/plugin-auth exec vitest run src/auth-email-locale.test.ts src/auth-manager.test.ts -> 'Test Files 2 passed (2) / Tests 332 passed (332)' — MATCHES the review's baseline of 332/332 exactly. (2) REPORTER-PATH PROOF for item 2, since 'the pins moved' is not observable from a pass count: `vitest list` shows all five under the path 'src/auth-manager.test.ts -> AuthManager -> phone-number OTP over SMS (#2780) -> #14641 — the invitation SMS reads the invitee's own locale -> ...', and the #14762 block retains exactly its 7 OTP tests (7 + 5 = the 12 that were nested before, so nothing was captured or dropped). (3) DIFF-SHAPE PROOF that no assertion moved: `git diff` filtered to lines matching expect(|await |const |fakeSms|bootOtp returns EMPTY for auth-manager.test.ts. (4) pnpm --filter @objectstack/plugin-auth run typecheck -> exit 0, check:test-typecheck debt ledger UNCHANGED at 10 file(s) / 94 error(s) / 23 pinned signature(s) — identical to round 1, so the describe move introduced no type error. WARNING: it first answered exit 2 on 'examples/basic-usage.ts: Cannot find module @objectstack/plugin-auth' — a PREREQUISITE wearing a finding's exit code, not a red: the fresh worktree had built only the dependency closure (the caret form of the filter excludes the package itself), so packages/plugins/plugin-auth/dist did not exist (verified by ls). Building the package converted it to the real exit-0 reading above; I did not record the 2 as a failure. (5) Gates on the changed surface, exit codes captured before any pipe, all exit 0: check:nul-bytes, check:changeset-gate-self-tests, check-empty-changeset, check-changeset-no-major, check-adr-0087-registration, check:objectui-changeset, check:pm-half-states, check-keyed-text-bounds, check-comment-mask-adoption, check-comment-mask-corpus, check-system-context-census, check:test-source-alias, check:objectql-double-limit, check:where-matcher, check-adr-anchors, check-closing-keyword-parity, check:doc-authoring. The ratchet subset was then re-run on the clean committed head and stayed green. (6) ESLint on the three changed .ts files: 0 errors, 0 warnings (the new describe title uses double quotes because its text contains an apostrophe). (7) NO ABLATION re-run this round, deliberately and stated rather than omitted: the diff changes no executable line, so round 1's ablation (predicted 9 red, measured 9 red, test-for-test) still describes this tree — and the review independently re-derived the same conclusion by mutation, wiring the inviter direction and getting the 2 red it should.", "mcp_calls": "0 this round (cumulative 2 for the card). The PR body PATCH with its verbatim read-back, both label read-backs, and this comment all went through repo-scoped REST.", "open_questions": [], "out_of_scope_findings": [ "unchanged from round 1 — filed as #15120 (the auth EMAIL locale ladder is undocumented on every shipped page while the SMS ladder is documented in full; `finding`, unassigned, no pm:queue). Nothing new was found this round.", "RECORDED, not filed, and not mine to fix — the review's own limit: 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. Carried into the PR body so it does not evaporate with this thread.", "PREREQUISITE, not a defect (new observation this round): a fresh worktree built with only the caret-suffixed dependency-closure filter leaves the package's OWN dist absent, and plugin-auth's `typecheck` then fails exit 2 in examples/basic-usage.ts on a module-resolution error that reads exactly like a code fault. Same class as round 1's check:skill-examples exit 1. Not filed — a documented consequence of that filter form, not a repo defect." ] }Generated by Claude Code
Generated by Claude Code
Landed — PR #15119 merged at
2026-09-04T02:04:47Z(merge commitf074616e620e); this card auto-closed at02:04:48Zvia itsFixesreference.pm:dispatchedstripped in a read-modify-write with the read immediately before it, read back and diffed —domain:services, i18n, priority:p3remain.What shipped. Both invitation sends now start at the invitee's own
sys_user.localewhen the address or phone already carries a row that names a language; a genuinely new invitee, and one whose row exists but leaves the column unset, keep the deployment default. ⛔ The inviter direction stays rejected, now pinned against a manager that has the top rung wired.Terminal state C, as implemented. The card's text — 「邀请邮件按被邀请人存储的语言选模板」 — cannot hold for every invitee, because most have no row until acceptance. The two-branch shape is the implementable reading of the ruled state, and the dispatch's stop-and-report condition did not fire: branch 1 is reachable on both surfaces, measured before coding.
Three corrections came out of this card, and two were of this seat's own work
- The dispatch order was stale. It told the dev to rewrite a sentence in
auth-email-locale.test.tsthat no longer existed — PR fix(auth): read the recipient's ownsys_user.localefor auth OTP SMS and auth mail #15107 had replaced that header about thirty minutes earlier, and the quoted string returns 0 hits onmain. The dev measured it, rewrote the sentence that actually needed rewriting, and reported the discrepancy rather than following the order silently. - The changeset overstated the SMS branch. The Clause-② contract review caught a claim that an imported phone-only account's
localewins. Re-measured:admin-import-users.tsnever writeslocale(0 occurrences; control:sendInviteSmstwice in the same file) and the column has no default, so that path still resolves to the deployment rung. A changeset becomes release notes, so it would have shipped a claim users do not get. - The fix was narrower than the defect. The seat named two places carrying that overstatement; the dev found four — two sibling comments it had authored the round before, and the PR body — reasoning that an identical false claim left next door defeats the correction.
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. That is a limit of the surface, not a defect in the pins.
Follow-up left for triage: #15120 — the auth email locale ladder is undocumented on every shipped page while the SMS ladder is documented in full. Nothing shipped is false there, only silent, which is why no gate can see it.
Generated by Claude Code
- The dispatch order was stale. It told the dev to rewrite a sentence in
- added a commit that references this issue
on Sep 5, 2026 - added 3 commits that reference this issue
on Sep 9, 2026
Blocked-by: #13881 —— 终态 C 所等的用户级语言列(
sys_user.locale)已由维护者 2026-09-01 在 #13881 裁 A(一等列),不再是「被暂缓」而是已裁待落地;本卡等它落地后才有实施面。观察(来自 objectstack#14319 / PR #14600,PM 裁 A now / C later)
#14600 让由请求触发、且请求者即收件人的 auth 邮件(验证 / 重置 / magic link / 换邮箱通知)先按
Accept-Language选语言;邀请邮件被刻意排除:那个请求是邀请人的,用邀请人的浏览器语言给被邀请人发信会把同一缺陷搬到隔壁座位(英文管理员静默给中文工作区的新人发英文邀请)。被邀请人的语言在邀请时刻是真未知。终态 C
等用户级语言列(per-user language)落地后,邀请邮件按被邀请人存储的语言选模板;在此之前保持部署默认。
若维护者把 2026-09-02 裁决里的「等」读作含邀请,PR #14600 里那处带注释的调用点改一个参数即可(B)。—— 分诊注:B 不再是候选。#13881 的裁决第 3 项把解析链定死为「收件人 locale → 部署默认」,而 B 走的是邀请人方向,与之相反。⛔ 不要在 #13881 落地前改那个调用点。Re-check:
grep -n "invitation" packages/plugins/plugin-auth/src/auth-manager.ts | head,以及更直接的一条 ——packages/plugins/plugin-auth/src/auth-email-locale.test.ts:8自述「nosys_user.localecolumn」。Generated by Claude Code