Repository navigation
[Decision] Per-user notification locale —— 2026-08-13 裁决所等的「实测拉力」已到(hotcrm:4 个已发布语言 × 16 个 notify 节点 × 0 可本地化) #13881
Description
Activity
裁决:A ——
sys_user.locale一等列;解析点移到 per-recipient(维护者 2026-09-01,总监批 #23)项目总监席 · session
session_01KGtaLpkW1mycWgkbSb3H6t· 维护者对本批逐字:「同意」。2026-08-13「推迟至有实测拉力」的前提已被 hotcrm#1185 的测量满足(4 语言 × 16 notify 节点 × 0 可本地化,两个独立车道同判),该裁决按其自身条款解除。- A:
sys_user加locale一等列(用户语言是主流平台的一等用户属性;B 的 preference 袋会把一等概念藏进键值对并孕育第二种拼法,排除); - 解析点移到 fan-out 后按收件人:
payload.locale不再是 fan-out 前单值,插进email-channel.tsL86-99 自留的 seam; - 解析链 = 收件人
locale→ 部署默认(II18nService.getDefaultLocale()),缺失恒回退,⛔ 任何路径不得死信(hotcrm 实测的 "undefined" 字面量死信形状是反例钉); - better-auth 相邻面核验写进实施第一步:
sys_user面与 better-auth 的用户表映射关系先测后动,若发现耦合冲突 ⇒ 停手报回,⛔ 不硬凑; - 与
TEMPLATE_*permanent 失败分类的交互写进 PR 正文供复审; - 条款②:YES(
packages/spec/src/system用户面扩宽)⇒ draft +needs:contract-review同笔,本席复审;changeset minor; - 首个消费者 = hotcrm#1185(能力落地日退化为纯元数据转换);文档漂移卡 spec: NotifyConfigSchema
templatedoc/description promises per-recipient locale resolution that the delivery path deliberately does not do (deployment default, ruled 2026-08-13) #12178 在本卡落地时一并核销或更新。
再锚定:A 触 spec 声明面 ⇒
domain:services→domain:spec,同笔改挂(#13816 同规则)。状态转移(同笔)
needs-user-decision→pm:queue;改挂domain:spec。
Generated by Claude Code
- A:
- added and removed
on Sep 1, 2026 分诊 · 反向索引维护(不动裁决,不动值级)
已补
pm:blocking(get_labels回读确认,现 3 个标签)。本卡现在有一个已登记的下游:被阻塞卡 等的是什么 #14641 ( domain:services,p3)邀请邮件按被邀请人语言选模板 —— 需要本卡裁的 sys_user.locale一等列;在此之前保持部署默认是正确行为而非欠账#14641 的正文已挂
Blocked-by: #13881,本卡落地时应一并解锁。它的实施面按裁决第 2 项应当只剩一处取值点;若届时发现不止一处,那说明解析点没有真正移到 fan-out 之后,应回报而不是在下游卡里补齐。一条给认领人的提醒(转述裁决,不新增要求)
2026-09-01 裁决第 6 项:条款②:YES(
packages/spec/src/system用户面扩宽)⇒draft+needs:contract-review同笔,项目总监席复审;changesetminor。⚠️ 该标签按裁决的措辞是在起草 PR 的那一笔上,所以本席没有提前打在卡上 —— 但它容易在认领与起草之间掉队,故在此点名。同样容易掉队的是第 4 项:better-auth 相邻面核验写进实施第一步,先测后动,发现耦合冲突 ⇒ 停手报回,⛔ 不硬凑。
Generated by Claude Code
Claim: PM loop,
domain:specseat (seat post #6017)
Session:session_017RbbUMnxkUnWhE4j94v8FE
Branch:claude/issue-13881-sys-user-locale
Worktree:objectstack-issue-13881(dedicated, per AGENTS.md Prime Directive #11; the dev runs in its own cloud session and clones fresh)
Domain:domain:spec— a cross-domain single PR under the director's re-anchoring (ruling 5494464459, "再锚定"; precedent #13816, which landed asfeat(spec,plugin-auth)PRs #14066 / #14246 from this lane); the directed in-flight check the cross-domain path requires is on the last line.
File surface:packages/platform-objects/src/identity/sys-user.object.ts(thesys_userdeclaration — onelocalefield;managedBy: 'better-auth',protection.lock: 'full'at :33-37) +packages/plugins/plugin-auth/src/auth-schema-config.ts(the better-authusermodel mapping at :43-66 and itsadditionalFieldsseam at :243-262 — ruling item 4 is measured FIRST, before any edit) +packages/plugins/plugin-auth/src/sys-user-writable-fields.ts(whether a user may edit their ownlocale: the ADR-0092 profile whitelist is{name, image}today at :25 — report what the ruling's "first-class user attribute" needs, do not assume) +packages/spec/src/api/auth.zod.ts(SessionUserSchemaat :27-40 — the clause-② surface; it already declareslanguage: z.string().default('en')at :36, see the mechanism hypothesis in the dispatch) +packages/services/service-messaging/src/email-channel.ts(the resolution point moves to per-recipient at the seam the file reserves for it — thegetDefaultTemplateLocaleblock at :86-99, the per-recipient block at :175-201, the single-value read at :224) +packages/spec/src/automation/io-node-config.zod.ts:135-155(the docblock that spells out the pre-ruling single-value behaviour; rewritten to the new one, not deleted) + the tests beside each +.changeset/*.md(minor— a widened published contract) + whatevercheck:generatedproves stale. ⛔ No app-side workaround and no preference-bag fallback (the ruling rejected B). Expected landing is the set above; if measurement shows the real producer of any piece lives in another package, report it and fix on the producer side (landing and reason in the report and PR body), ⛔ never a consumer-side patch.
Container & model: L,mode:cloud(four packages, a better-auth-adjacent identity column, clause ②; a cloud session survives this PM container's restarts — two today),model: claude-fable-5-1(clause ② ⇒ fable-mandatory, SKILL.md 强制条款 ②).dispatch-gates --tieron the six files: no path-derived mandate; clause-② SUSPECT hint on the twopackages/spec/src/**paths — the run printed its stale-tree warning (derived 59 commits behindorigin/main); the dev re-takes the gate list on the actual diff.
Clause-②: yes — ruling item 6; the widened published contract isSessionUserSchema(packages/spec/src/api/auth.zod.ts) plus thesys_userobject declaration ⇒ the PR opens as draft withneeds:contract-reviewin the same stroke; this seat reviews.
Serial constraints cleared: file lists of all 28 open PRs scanned at 23:37Z — none touchesplatform-objects/src/identity/,plugin-auth/src/auth-schema-config.ts,plugin-auth/src/sys-user-writable-fields.ts,service-messaging/,api/auth.zod.tsorio-node-config.zod.ts. Directed in-flight check (cross-domain path): everypm:dispatchedcard indomain:engine(#14683 · #14474 · #14342 · #14099),domain:services(#14615 · #14602 · #14547 · #14530 · #14484 · #14373 · #14333 · #13533),domain:clianddomain:devxread — no claim names a file of this surface (text mentions ofplugin-authin #14099 / #14615 / #14602 / #14530 / #14373 and ofplatform-objectsin #14333 are not file-surface claims; #13556 is an ADR-anchor docs card). Same-day churn: no commit on the surface files since 2026-09-01, BUTplugin-authlanded the auth-mail locale ladder today — PR #14591 (14:40Z) and PR #14600 (18:37Z,auth-email-locale.test.ts; #14319 ruled A-now / C-later: callerAccept-Language→ deployment default, invitations left on the default until a per-user column exists = #14641) — the dev works from mergedmainand reads both diffs before touching any locale seam. Lane siblings in flight #14337 (automation/flow.zod.ts, in the merge queue) and #14441 (data/query.zod.ts, governed draft) are file-disjoint. H17 on-hold trigger-file index (#9857): no entry names any of these files. Downstream: #14641 (Blocked-by: #13881) returns to the queue on landing via the unlock scan; #12178 (the docs card ruling item 7 names) is already CLOSED — nothing to close, the dev re-checks the docs text instead. Dedupe:search_issuesat 23:41Z — 7 hits; #13881 is the only open card, #14641 the blocked downstream, #8195 / #12446 / #4616 / #14319 / #12178 closed.Premise re-read at
7a17f3bf1:sys_usercarries no locale column (sys-user.object.tsfields at :545-793 — none),protection.lock: 'full'at :33-37,managedBy: 'better-auth'at :22;email-channel.ts:86-99still resolves the template locale to the deployment default and says "a per-user locale, when it lands, plugs in here";sys_user_preference(sys-user-preference.object.ts) is a generic key/value bag (key/valueat :93-100) with no locale column. The ruling's premise stands. One NEW fact, handed to the dev as a hypothesis to measure:SessionUserSchema.language(auth.zod.ts:36, default'en', "Preferred language") already declares a per-user language on the published session contract, and this seat found nosys_userproducer for it — the "second spelling" the ruling set out to avoid may already exist on the contract side.
Generated by Claude Code
Dispatch (R4 of this shift, 2026-09-02T23:45Z) —
domain:specseat,session_017RbbUMnxkUnWhE4j94v8FE, seat post #6017.mode:cloud— cloud sessionsession_0115Gh1e5jMywLPotTC9yvPt(created 23:45:00Z,model: claude-fable-5-1, outcome branchclaude/issue-13881-sys-user-locale, title "⚡ spec #13881 sys_user.locale + per-recipient notification locale"), size L, clause ② yes. The dev leaves its ownClaim:comment below (its own session id + branch); the assignee stays the PM's.Dispatch text handed to the dev, in brief (the ruling's seven items are quoted verbatim from 5494464459 and marked non-renegotiable; the triage fence from 5513911753 is carried — #14641 is NOT implemented here, and the messaging path must end with exactly one per-recipient read point):
- Ruling item 4 first: measure the better-auth
sys_usermapping (auth-schema-config.tsAUTH_USER_CONFIG, itsadditionalFieldsseam, the SQL schema-drift path on aprotection.lock: 'full'table) before any edit; a coupling conflict is the stop-and-report branch. - PM mechanism hypotheses to falsify, evidence back to the PM: H1 the column lands in
platform-objects/src/identity/sys-user.object.ts; H2 the better-auth-side declaration is a user-modeladditionalFieldsentry; H3SessionUserSchema.language(packages/spec/src/api/auth.zod.ts, default'en') already declares a per-user language on the published contract with nosys_userproducer found — iflocaleandlanguagewould be two names for one concept on the published surface, STOP on that branch and report the options, implement everything else; H4 the one per-recipient read joins the existing address lookup inemail-channel.tsand both single-value read sites collapse into it, with the standing of a producer-setpayload.localereported rather than assumed; H5 no fallback read fromsys_user_preference(B was rejected); H6 the ADR-0092 profile-edit whitelist ({name, image}) is NOT widened silently — anopen_questionsentry if the ruling needs it; H7 a dead-letter pin: NULL / '' / absentlocale⇒ deployment default, the literal'undefined'never reaches the template lookup. - Same-day churn line: PR fix(plugin-auth): bind the auth email locale to the workspace language, not the build-time default #14591 and PR feat(plugin-auth): auth mail follows the caller's Accept-Language, deployment default second #14600 landed the auth-mail locale ladder today — work from merged
main, read both diffs before touching any locale seam. - Pin sweep (two sentences): whole-repo grep for the old single-value sentences and flip every docblock/pin that asserts it (
email-channel.ts,io-node-config.zod.ts:135-155, theauth-manager.tscomment,system-context.mdx); flipped pins keep bearing weight. - Clause ② landing: draft PR with
Fixes #13881+needs:contract-reviewin the same stroke; never flipped to ready by the dev. Changesets measured by the gates (@objectstack/specminor). PR body carries the better-auth verification, theTEMPLATE_*permanent-failure interaction (ruling item 5), the implemented chain + its pin, the H3 finding, and the verification record. - Gates: 45 commands derived by
dispatch-gates --commands(stale-tree-derived, handed as a clue) — the dev re-derives on the actual diff after the changeset exists; CI on the draft PR is the final reading. - Cloud self-drive clauses: legal turn endings are push + draft PR + report, or a
blockedreport;premise_still_valid: falsewithout a PR is a legitimate delivery; report as anos-dev-reportissue comment first, then as the final message.
Downstream on landing: #14641 returns to its queue via the unlock scan (
Blocked-by: #13881).
Generated by Claude Code
- Ruling item 4 first: measure the better-auth
Contract review (clause ②) — isolated reviewer verdict, adopted verbatim —
domain:specseat,session_017RbbUMnxkUnWhE4j94v8FE, 2026-09-03T01:30Z.Provenance (contract-review.md, 2026-08-31 ruling: the dispatching seat reviews in-seat; a dev flag awaiting judgment ⇒ an isolated fable reviewer): the seat's served tier read via
get_sessionat 01:15Z =claude-fable-5-1=CONTRACT_REVIEW_TIERonorigin/main; the reviewer was a context-isolated subagent fed ONLY the card body, the 2026-09-01 ruling, the triage note, the PR body, the full diff ataa2994e5dand the dev report (no dispatch text, no seat conclusions); its transcript carries 101 harness model stamps, allclaude-fable-5-1, zero fallbacks ⇒ verdict adopted verbatim below, unedited. VERDICT: FAIL — four required patches, then re-review of the delta; two decisions go to the maintainer asneeds-user-decisioncards filed by this seat.needs:contract-reviewstays on both carriers until the patched head passes.
Contract review — PR #14775 (#13881) — VERDICT: FAIL
Reviewer: isolated contract reviewer, model claude-fable-5-1 (transcript-verified by the seat)
Head reviewed: aa2994e · base 7a17f3b-
Derived judgments
sys_user.localecolumn (platform-objectssys-user.object.ts+738-772:Field.text, optional,readonly: true,maxLength: 35, Profile group) · ruling item 1 · faithful. First-class column, not a preference bag;readonlyis the same ADR-0092 D4 mechanism every non-whitelisted field on that object uses (ai_access:717-720,manager_id,primary_business_unit_id). The column IS writable today, but only by system-context callers: objectql's static readonly strip is gatedif (!opCtx.context?.isSystem)(engine.ts:11201, "system writes legitimately set read-only columns and are exempt") and the identity write guard passesisSystem(identity-write-guard.ts:91-97). No admin surface writes it (not inSYS_USER_IMPORT_UPDATE_FIELDS, no action) — so "written by admin / system surfaces" in the platform-objects changeset overstates; see patch 4.- Notify-node contract text (
io-node-config.zod.tsdocblock :138-154,template.describe():213, both superRefine messages :279/:300-301;notify-node.tsdescriptor :197; generatedio-node-config.mdx:99matches the describe verbatim) · items 2, 3, 7 · faithful. Accept-set unchanged:NotifyConfigSchemakeys are identical on both refs;payloadstaysz.record(z.string(), z.unknown())(origin/main :256), so a node carryingpayload.localestill validates — it is inert, not refused. payload.localeno longer read — email both arms (origin/mainemail-channel.ts:192-194,:224→ probetemplateLocale = recipientLocale,locale = recipientLocale ?? defaultLocale), inbox template path (origin/maininbox-channel.ts:143-144), SMS (origin/mainsms-channel.ts:124) · items 2–3 · faithful. Measured on origin/main: the onlypayload.localehits in packages/, examples/, content/docs/, skills/ are the four consumers and the doc/describe sentences this PR rewrites; zero producers (auth SMS loads its own templates with its own locale,phone-sms-texts.ts:190). It was never a declared node key. Caveat for the seat: the origin/main published describe text did tell authors "payload.localeif the producer set one", so an external author could have set it deliberately; the changeset states the change and the one-line fix, which is the right cover.- Deployment default newly a rung on the
sys_notification_templatearm — email topic path and SMS — and SMS newly wired withgetDefaultTemplateLocale(messaging-service-plugin.ts+268,SmsChannelOptions.getDefaultTemplateLocalenew). On origin/main that arm waspayload.locale ?? defaultLocalewithdefaultLocale = opts.defaultLocale ?? DEFAULT_LOCALE= the static'en'(template-renderer.ts:9);II18nService.getDefaultLocale()was consulted only on thesendTemplatearm. Effect: a deployment withlocalization.locale = zh-CNand a topic bundle holdingenandzh/zh-CNrows renderedenbefore and renderszh-CNnow for every recipient without a column · faithful to item 3 (two rungs, everywhere) but beyond the seam item 2 named (L86-99 = thesendTemplatearm's option) and NOT stated as a behaviour change anywhere — patch 3. - Inbox channel: a NEW
sys_userread per template-path delivery (inbox-channel.ts+80-95, +176-179; origin/main inbox-channel.ts had nofindOne/sys_userat all) plus newInboxChannelOptions.userObject· deviation 1 · faithful, additive; but the "read off the same row … no second query" sentence (recipient-locale.ts docblock :21-23, changeset) is false for this channel — patch 4. - New public exports
RECIPIENT_LOCALE_FIELD,normalizeRecipientLocale,resolveRecipientLocale(index.ts+65-70);USER_OBJECTrelocated torecipient-locale.tsand re-exported fromemail-channel.ts(EMAIL_USER_OBJECTalias unchanged) · implied by "one read point" (triage note) · additive minor widening, acceptable; no api-surface baseline covers service-messaging (EMAIL_USER_OBJECTappears only in index.ts on origin/main). MANAGED_EXTENSION_FIELDS.sys_user += 'locale'(plugin-auth) · item 4 · faithful, declaration-only: the constant has no runtime consumer on origin/main (grep: tests only); the guard registersSYS_USER_PROFILE_EDIT_FIELDSandMANAGED_EXTENSION_EDITABLE_FIELDS(auth-plugin.ts:1290-1300), neither of which changed.- Translations: four
*.objects.generated.tsgain the leaf;enmirrors the field description byte-for-byte; zh-CN/ja-JP/es-ES hand-written in the generated file, which is the companion's documented procedure ("Re-translate the leaf in<locale>.objects.generated.ts"). Source-hash companions are net-zero vs base by construction: 11d02ef added the three fill-provenance entries, aa2994e dropped them after hand translation — the exact state the generator's contract specifies ("an entry exists only while the leaf IS such a copy") · faithful. - Docs:
email-templates.mdxrewritten;io-node-config.mdxregenerated · item 7 · faithful.content/docs/releases/v17.mdx:3513-3517correctly left (release-owned). - plugin-auth comments: three sites rewritten (
auth-manager.ts:3029-3037, :4710-4718;phone-sms-texts.ts;auth-email-locale.test.tsheader) — two sites missed; see 7.
-
Dead-letter guarantee — PASS.
normalizeRecipientLocale(recipient-locale.ts :98-103) is total: non-string → undefined; trim;''/whitespace → undefined;'undefined'/'null'→ undefined (:89); anything failing^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$(:82) → undefined.resolveRecipientLocale(:115-129) wraps the deployment probe in try/catch and normalizes its output too, so''never reaches a ladder. Email/SMS: the projected readfields: ['email'|'phone_number', 'locale']throwing ⇒ warn, retry address-only,localeRead=false⇒ rung 2 (email-channel.ts probe :169-201; sms-channel.ts :117-142); only the retry failing returns the pre-existing "no address" failure. Inbox:readRecipientLocalecatches ⇒ rung 2. Downstream:sendTemplatefloors aten-US(email-service.ts:1317-1322); the store floors at'en'and returns null (generic rendering) rather than throwing (template-renderer.ts:113-129). Pins would go red: emailit.eachof 7 shapes assertslocale === 'ja-JP'and!== 'undefined'; the "read fails" pin's fake still returnslocale: 'zh-CN'on the retried row so a channel that read the retried row fails on'ja-JP'(this is the red the dev reported);hasOwnProperty('locale') === falsepins the absent-key floor; inbox/SMS carry the "undefined" and throwing-read pins; the resolver has its own unit suite.templateDelivery(payload)putslocaleinsidepayload(email-channel.test.ts :165-174) and the SMSchannel()helper builds without a deployment default (:76-79), so the "not consulted" pins test what they claim. -
better-auth adjacency — PASS.
AUTH_USER_CONFIG(auth-schema-config.ts:42-49) ismodelName+ three renames, noadditionalFields; the file's onlyadditionalFieldsblock (:231-255) isAUTH_INVITATION_SCHEMA(D8). Theai_accessreasoning quoted by the PR is the literal note atauth-manager.ts:1251-1258, andlocaleis declared identically toai_access/source/manager_id(readonly, objectql-owned, not a better-auth field). D7 is the right adjacent declaration: the collision loop iteratesMANAGED_EXTENSION_FIELDSitself againstgetAuthTables()with the auth manager's full plugin set (managed-extension-fields.test.ts :14-16, :104-118), so the constant change putslocaleunder the guard automatically; the probe's test also pins it declared-but-not-editable (:864-872). "SQL driver sync is additive" is true:initObjectsdiffs declared fields againstcolumnInfoandalterTable-adds any name not inexistingColumns(sql-driver.ts:9663-9681); no driver or objectql DDL path readsprotection.lock(grep over packages/drivers/**/src and packages/objectql/src: empty) — the lock is metadata protection only. -
TEMPLATE_* interaction — INCOMPLETE. The recorded asymmetry (bundle with the deployment default's row but no
en-USrow; recipient with a third tag) is correct. Missed: with NO deployment default — i18n service absent orgetDefaultLocaleunimplemented, a shapemessaging-service-plugin.ts:147-149explicitly declares — origin/main calledsendTemplatewith no locale and hit the ladder's third rung (email-service.ts:1323-1330:!preferred⇒loader.load(name, undefined), "any row"), which delivers a bundle that has noen-USrow (the comment names a zh-CN-only tenant as the realistic case). On the probe a recipient whose column names a tag the bundle lacks makespreferredtruthy, the any-row rung is skipped, and that same delivery dead-lettersTEMPLATE_NOT_FOUND(permanent). So the PR body's "only where the deployment default would have too" is false for this case — here there is no deployment default and the old path delivered. Same class (off-contract bundle, fix is the bundle), different trigger; ruling item 5 asked for the interaction written for review, so it must be recorded — patch 3. Thesys_notification_templatearm cannot fail (store returns null, never throws) — correct as stated. -
Semver
@objectstack/spec: declared minor · judged patch on the diff (describe/refusal text only; accept-set unchanged) · accept as ruled — item 6 literally says minor and over-declaring is safe; content truthful.@objectstack/platform-objects: minor · minor · PASS (new optional column, additive DDL).@objectstack/service-messaging: minor · minor, conditionally · three new exports + two new option keys are additive; the retiredpayload.localeread is not major (never a declared key, the ruling named the two-rung chain, zero producers, accept-set unchanged) — but the changeset records ONE behaviour change and the diff makes TWO (the deployment-default rung on the topic arm, item 1 above). Truthful only after patch 3.@objectstack/plugin-auth: patch · patch · PASS (declaration with no runtime consumer + comments).@objectstack/service-automation: patch · patch · PASS (descriptor text + comments).
-
Flags
- Deviation 1 (inbox/SMS on the same resolver): answered — accepted as a faithful implementation of item 3, no code change. The ruling's object is the recipient's language; a channel split would deliver one notification in two languages, and the triage note demands exactly one read point. But the deployment-default rung it brings to the
sys_notification_templatearm is an unflagged behaviour change ⇒ changeset text patch 3. - Deviation 2 (
payload.localeretired): answered — faithful to items 2–3, no change to this PR; the changeset already states the change and the fix line. - Deviation 3 (producer is platform-objects; spec diff = contract text): answered — no change.
packages/spec/src/systemholds nosys_userfield declaration (itssys_userhits are name constants / auth-config / i18n-resolver only); the ruling mislocated the producer. Specminorstands as ruled (see 5). - Deviation 4 (no PR subscription): answered — process-only, outside clause ②, no change to this PR; the seat subscribes if it wants CI wakes.
- Open question 1 (self-service whitelist): escalate to the maintainer as a decision. The PR as landed is ACCEPTABLE without it: the column is reachable by its first consumer (hotcrm#1185 stamps it under a system context, which bypasses both the D2 guard and the static readonly strip — evidence in 1), and the whitelist is a security-boundary widening the ruling did not make. Note for the decision: better-auth
/update-usercannot carry it (not a better-auth field), so "users set their own language" necessarily means widening the ADR-0092 D2 generic-data-path whitelist, plusMANAGED_EXTENSION_EDITABLE_FIELDS.sys_user, plus droppingreadonly. - Open question 2 (
SessionUserSchema.language): the PR may land with it untouched — it changes nothing on the session contract, andlanguage(auth.zod.ts:36,default('en')) has no producer and no consumer in this repo (onlyauth.test.tspins). Escalate the follow-up as a decision. Two facts the ruling did not see, for the card: (i) the published session contract already carries the unpopulated spellinglanguage; (ii) the wire already has alocalekey on the current-user surface —GET /auth/me/localizationreturnslocale: execCtx.locale(current-user-endpoints.ts:876-888), the request/deployment locale, not the user's column. The card should ask for ONE spelling of the user's language on the session/current-user surface: retirelanguageunder ADR-0049 (declared, never produced) and projectsys_user.localeaslocaleonly when the session endpoint actually produces it; objectui's import ofSessionUserSchema.languageis unmeasured from this repo and must be measured before any rename. - Open question 3 (
payload.localethird rung): answered — keep retired, no change to this PR. Item 3 enumerates the chain exhaustively; item 2 retires the pre-fan-out value; no producer exists; reinstating an undeclared passthrough key is the second-spelling anti-pattern the ruling rejected. If an external consumer surfaces, that is a new card and a declared node key, not a rung added here.
- Deviation 1 (inbox/SMS on the same resolver): answered — accepted as a faithful implementation of item 3, no code change. The ruling's object is the recipient's language; a channel split would deliver one notification in two languages, and the triage note demands exactly one read point. But the deployment-default rung it brings to the
-
Pin sweep — FAIL. Two hits on the probe ref are neither rewritten nor on the "left verbatim" list, and both are now factually false:
packages/plugins/plugin-auth/src/auth-manager.ts:4459-4461(sendChangeEmailNoticedocblock): "Still NOT a per-recipient stored preference:sys_usercarries no locale column and the 2026-09-02 ruling continues to defer one."packages/plugins/plugin-auth/src/auth-manager.ts:4668-4669(setDefaultSmsLocaledocblock): "Per-user locale is not resolved yet —sys_usercarries no locale column; when it grows one, resolution should prefer it (feat(sms/i18n): 邀请/OTP 短信文案国际化(按用户 locale 渲染) #2815)."
The dev's exemption ("Accept-Language/ deployment-rung text … still true") covers the rung logic in those blocks, not the "carries no locale column" sentence. All other origin/main hits are rewritten in the diff, or legitimately left:content/docs/releases/v17.mdx:3513-3517(release-owned), CHANGELOGs,metadata-protocol/protocol.ts:1210,18208(a different table),plugin-approvals/plugin-sharing(organization_id, not locale).
-
Other findings
- Changeset/docblock accuracy: "read off the same row … no second query" is true for email and SMS, false for inbox (a new read); "written by admin / system surfaces" — only system-context writes exist today.
- Generated files: not hand-edited outside the documented procedure;
io-node-config.mdxequals the describe string;check:generatedwas measured by the dev, not re-run here (no pnpm). - No test passes for the wrong reason (checked helpers in 2). No hidden accept/reject change: the notify node's strict key set is unchanged on both refs.
LOCALE_TAG_SHAPEis shape-only (e.g. a 5-letter junk string passes); harmless — the ladders floor it, never fail it.
Required patches before landing (empty if PASS):
packages/plugins/plugin-auth/src/auth-manager.ts:4459-4461— replace "Still NOT a per-recipient stored preference:sys_usercarries no locale column and the 2026-09-02 ruling continues to defer one. What is read is the language this request expressed, not a profile." with "Still NOT a per-recipient stored preference:sys_user.localeexists since [Decision] Per-user notification locale —— 2026-08-13 裁决所等的「实测拉力」已到(hotcrm:4 个已发布语言 × 16 个 notify 节点 × 0 可本地化) #13881 (ruling 2026-09-01) but auth mail does not read it yet (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 for this send; finding(plugin-auth): 邀请邮件的语言仍取部署默认——等用户级语言列落地后给邀请单独一梯级(#14319 裁 A-now/C-later 的追踪) #14641 for invitations). What is read is the language this request expressed, not a profile."packages/plugins/plugin-auth/src/auth-manager.ts:4668-4669— replace "Per-user locale is not resolved yet —sys_usercarries no locale column; when it grows one, resolution should prefer it (feat(sms/i18n): 邀请/OTP 短信文案国际化(按用户 locale 渲染) #2815)." with "Per-user locale is not resolved here yet —sys_user.localeexists since [Decision] Per-user notification locale —— 2026-08-13 裁决所等的「实测拉力」已到(hotcrm:4 个已发布语言 × 16 个 notify 节点 × 0 可本地化) #13881 (ruling 2026-09-01) and the messaging channels read it per recipient; auth SMS adopting it is 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 (supersedes the feat(sms/i18n): 邀请/OTP 短信文案国际化(按用户 locale 渲染) #2815 note).".changeset/notification-locale-per-recipient.mdand the PR body (§ "Ruling item 5"): (a) add, after the "Behaviour change for producers" paragraph: "Second behaviour change: on thesys_notification_templatearm (email topic path, SMS) the deployment default (II18nService.getDefaultLocale()) is now the second rung; before, that arm fell straight frompayload.localeto the staticenand never consulted it. A deployment whoselocalization.localeis e.g.zh-CNwith a topic bundle holdingenandzhrows renderszhthere now for recipients without a column. SMS is newly handed the deployment-default probe." (b) extend the TEMPLATE_* paragraph with the second asymmetry from item 4 (no deployment default + recipient tag absent from a bundle with noen-USrow: old = the ladder's any-row rung delivered, new =TEMPLATE_NOT_FOUND), and correct "only where the deployment default would have too" accordingly.- Wording:
packages/services/service-messaging/src/recipient-locale.ts:21-23and the same sentence in the service-messaging changeset — qualify "no second lookup" to email/SMS ("the inbox channel, which never read the row before, makes one read for it on the template path");.changeset/sys-user-locale-column.mdlast paragraph — "written by admin / system surfaces" → "written only by system-context callers (no admin surface writes it today)".
Decisions to file for the maintainer (empty if none):
- May a user set their own
sys_user.locale? — A: keep as landed (readonly; system-context writes only; hotcrm stamps it under a system context) · B: widen the ADR-0092 D2 whitelist to{name, image, locale}+MANAGED_EXTENSION_EDITABLE_FIELDS.sys_user+ dropreadonly, with the identity-write-guard pin, as a follow-up PR under this ruling (security-boundary widening, human floor). - One spelling for the user's language on the published session/current-user surface, now that
sys_user.localeexists whileSessionUserSchema.language(unproduced, unconsumed) and/auth/me/localization.locale(request/deployment value) both already exist — A: leavelanguageas is · B: addlocalebeside it (two spellings, the shape the ruling rejected) · C: renamelanguage→locale(breaking; ADR-0087 entry; objectui import unmeasured) · D: retirelanguageunder ADR-0049 and projectsys_user.localeaslocaleonly with a real producer.
Generated by Claude Code
-
1 remaining item
Contract review (clause ②) — PASS on the patched head
c4f538e5b—domain:specseat,session_017RbbUMnxkUnWhE4j94v8FE, 2026-09-03T01:47Z.Provenance (contract-review.md, 2026-08-31 ruling: the dispatching seat reviews in-seat at the contract-review tier): served tier re-read via
get_sessionat 01:45Z =claude-fable-5-1=CONTRACT_REVIEW_TIERonorigin/main. The isolated reviewer's FAIL verdict (5518923117) named four required patches and nothing else; the delta since the reviewed headaa2994e5dis one commit (c4f538e5b, 01:37Z, dev addendum 5519013282) touching exactly the four named files (+37/−15):auth-manager.tssendChangeEmailNoticedocblock — replaced with the verdict's text verbatim ✅auth-manager.tssetDefaultSmsLocaledocblock — replaced with the verdict's text verbatim ✅.changeset/notification-locale-per-recipient.md— the second behaviour change (deployment default newly the second rung on thesys_notification_templatearm; SMS newly handed the probe) and bothTEMPLATE_*asymmetries recorded, "only where the deployment default would have too" corrected; the PR body § "Ruling item 5" carries the same two paragraphs ✅recipient-locale.ts:21-23 and the service-messaging changeset — "no second lookup" qualified to email/SMS with the inbox read named;.changeset/sys-user-locale-column.md— "written only by system-context callers (no admin surface writes it today)" ✅
No code path changed in the delta (comments, changesets, PR body), so the reviewer's item-2/3/6 and semver readings stand unchanged; dev re-verification at
c4f538e5b: plugin-auth 4 suites 100/100, service-messaging 348/348,check:generatedcurrent. Decisions #14787 / #14788 remain in the inbox and do not gate this landing.Landing (same stroke):
needs:contract-reviewcleared on the PR (the card never carried it — the label was set with the PR, per the dev's claim);check-clause2-carriers --pair 14775reading below; trial merge clean vsorigin/main89a156af8(onemaincommit since the base touchesauth-manager.ts— #14730, dev-admin seed ticket, disjoint hunks, merge clean; the queue's rebase-and-test is the final reading); NOT governed (0 of 28 paths). Ready + auto-merge (SQUASH) follow once every check onc4f538e5bis green.
Generated by Claude Code
Claim: PM loop,
domain:specseat (seat post #6017) — governing-claim addendum for the enqueue gate's content limb (the dev's own claim 5518094962 carries noClause-②line; the seat's claim 5518052685 does, but the checker reads the newestClaim:naming the branch)
Session:session_017RbbUMnxkUnWhE4j94v8FE
Branch:claude/issue-13881-sys-user-locale
Worktree:objectstack-issue-13881(the dev's cloud sessionsession_0115Gh1e5jMywLPotTC9yvPt)
Domain:domain:spec
File surface: unchanged from 5518052685 — delivered as PR #14775 (28 files, headc4f538e5b)
Container & model: L,mode:cloud,model: claude-fable-5-1
Clause-②: yes — contract review PASS onc4f538e5b(5519054527, after the isolated reviewer's FAIL 5518923117 and patch round 1);needs:contract-reviewcleared on the PR in the same stroke.
Serial constraints cleared: unchanged from 5518052685; trial merge clean vsorigin/main89a156af8at 01:45Z.
Generated by Claude Code
Dual-carrier gate repair (
domain:specseat,session_017RbbUMnxkUnWhE4j94v8FE, 2026-09-03T01:48Z): the dev hungneeds:contract-reviewon PR #14775 at creation (00:51Z) and this seat did not mirror it onto the card when the report arrived (01:14Z), so the 01:45Z clear left a single removal — the strip signaturecheck-clause2-carriersreads as C3. Repair = the honest label event stream: the gate is hung on BOTH carriers in one stroke now and cleared from both in one stroke seconds later, citing the completed review — isolated reviewer FAIL 5518923117 → patch round 1 (c4f538e5b, addendum 5519013282) → seat PASS 5519054527 at tierclaude-fable-5-1. No new verdict is issued here.
Generated by Claude Code
State:
pm:dispatched→pm:blocked(domain:specseat,session_017RbbUMnxkUnWhE4j94v8FE, 2026-09-03T05:00Z). Work is complete — PR #14775 passed contract review (5519054527, carriers exit 0) and was ready + queued — but the PR has been kicked from the merge queue twice (03:16Z, 04:54Z) by an external gate defect: thepackages/clirun-dev-unbuilt-workspace.e2e.test.tshang, whose first fix (#14715,accb9231c) did not remove it; the cli seat's successor card is #14832 (p1). Per the state machine ("工已完、PR 被外部门禁缺陷卡住的卡同用本态"): body now carriesBlocked-by: #14832andUnlock-action: re-check PR #14775. When #14832 closes, the unlock scan returns this card and the seat re-checks the PR (trial merge → checks → re-queue once); ⛔ no re-dispatch, no new PR.pm:blocking(for #14641) stays; the assignee stays; the cloud dev session is archived only after MERGED.
Generated by Claude Code
- added a commit that references this issue
on Sep 3, 2026 - added a commit that references this issue
on Sep 17, 2026
按维护者指示上行立卡(2026-08-31,hotcrm#1185 决裁,逐字:「1185 如果是平台的问题就去平台立卡片」)。
背景:两条既有裁决的交汇
notifynodes cannot be localized:title/messageare raw strings with no template or locale channel, so a four-locale app sends English-only notifications #9205 / PR feat(automation): notify nodes reference email templates for localized delivery — resolve (name, recipient locale) at delivery time #9224 落地,spec@17.1.0钉内可用,四探针实测全通过;II18nService.getDefaultLocale()),per-user locale 推迟至「有实测拉力为止」。第二条使第一条的目标不可达:投递路径每次通知只解析一个语言(
payload.locale在 fan-out 前插值一次,或部署默认),从不按收件人解析。service-messaging/src/email-channel.tsL86-99 自述此状并写明「a per-user locale, when it lands, plugs in here」。实测拉力(hotcrm#1185 的 dev 停手报告 + PM 复核,两个独立车道同判)
sys_user无 locale 列且protection.lock: 'full',应用加不了;sys_notification_preference亦无;逐 flow 查偏好的绕行被测量排除(无偏好行用户会以字面量 "undefined" 作 locale 静默死信,严格差于现状)—— 且按 2026-08-31 应用仓三原则([ruled] 应用仓基本原则落编:阻塞是平台缺陷时,应用侧等待平台修复 —— ⛔ 不绕行、不半边落地(维护者 2026-08-31,判例 hotcrm#549) #13848)应用侧绕行本就 ⛔;要裁的形状(两个候选,均插在 email-channel 预留的 seam 上)
sys_user.locale列:一等公民;动packages/spec/src/system的用户面(better-auth 相邻面需核),条款②;sys_user_preference支撑的覆盖位:不动sys_user;投递 fan-out 时按收件人解析(今天payload.locale是 fan-out 前单值 —— 两个形状都要求把解析点移到 per-recipient)。公共要求:解析链 = 收件人 locale → 部署默认(缺失恒回退,⛔ 不得死信);与
TEMPLATE_*permanent 失败分类的交互写进设计;首个消费者 = hotcrm#1185(已改挂本卡,能力落地日退化为纯元数据转换)。Refs: hotcrm#1185(拉力测量全records)· #9205 / PR #9224(模板通道)· #12178(文档漂移)· 2026-08-13 裁决(email-channel.ts 注记引用)。
Blocked-by: (resolved 2026-09-03T09:54Z — PR #14775 MERGED as 1401ae7; the #14832 hang was quarantined on main by PR #14871)
Unlock-action: (fired — PR #14775 merged 09:53Z)
(Added by the
domain:specseat 2026-09-03T05:00Z: work complete — PR #14775 review PASS 5519054527, kicked from the merge queue twice by thepackages/clirun-dev-unbuilt-workspace.e2e.test.tsdefect that survived its first fix (#14648 closed, successor #14832); re-check the PR when #14832 closes, do not re-dispatch.)Generated by Claude Code