Skip to content

[Decision] Per-user notification locale —— 2026-08-13 裁决所等的「实测拉力」已到(hotcrm:4 个已发布语言 × 16 个 notify 节点 × 0 可本地化) #13881

Description

@huangyiirene

按维护者指示上行立卡(2026-08-31,hotcrm#1185 决裁,逐字:「1185 如果是平台的问题就去平台立卡片」)。

背景:两条既有裁决的交汇

第二条使第一条的目标不可达:投递路径每次通知只解析一个语言(payload.locale 在 fan-out 前插值一次,或部署默认),从不按收件人解析。service-messaging/src/email-channel.ts L86-99 自述此状并写明「a per-user locale, when it lands, plugs in here」。

实测拉力(hotcrm#1185 的 dev 停手报告 + PM 复核,两个独立车道同判)

  • hotcrm 发布 4 个语言(en / zh-CN / ja-JP / es-ES),16 个 notify 节点,0 个可本地化:zh-CN / ja-JP / es-ES 用户从每一条自动化(成单、SLA 违约、升级、任务提醒、合同到期…)收到英文通知;
  • 应用侧无路可走(实测):sys_user 无 locale 列且 protection.lock: 'full',应用加不了;sys_notification_preference 亦无;逐 flow 查偏好的绕行被测量排除(无偏好行用户会以字面量 "undefined" 作 locale 静默死信,严格差于现状)—— 且按 2026-08-31 应用仓三原则([ruled] 应用仓基本原则落编:阻塞是平台缺陷时,应用侧等待平台修复 —— ⛔ 不绕行、不半边落地(维护者 2026-08-31,判例 hotcrm#549) #13848)应用侧绕行本就 ⛔;
  • 独立佐证:objectstack#7684(QA 清单车道,2026-08-11 关闭)同测同判;spec 文档面漂移(docblock 承诺 per-recipient 而实现刻意不做)已另行立卡 objectstack#12178。

要裁的形状(两个候选,均插在 email-channel 预留的 seam 上)

  • A · sys_user.locale 列:一等公民;动 packages/spec/src/system 的用户面(better-auth 相邻面需核),条款②;
  • B · 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:spec seat 2026-09-03T05:00Z: work complete — PR #14775 review PASS 5519054527, kicked from the merge queue twice by the packages/cli run-dev-unbuilt-workspace.e2e.test.ts defect that survived its first fix (#14648 closed, successor #14832); re-check the PR when #14832 closes, do not re-dispatch.)


Generated by Claude Code

Activity

  1. huangyiirene commented on Sep 1, 2026

    @huangyiirene
    CollaboratorAuthor

    裁决:A —— sys_user.locale 一等列;解析点移到 per-recipient(维护者 2026-09-01,总监批 #23)

    项目总监席 · session session_01KGtaLpkW1mycWgkbSb3H6t · 维护者对本批逐字:「同意」。2026-08-13「推迟至有实测拉力」的前提已被 hotcrm#1185 的测量满足(4 语言 × 16 notify 节点 × 0 可本地化,两个独立车道同判),该裁决按其自身条款解除。

    1. A:sys_user 加 locale 一等列(用户语言是主流平台的一等用户属性;B 的 preference 袋会把一等概念藏进键值对并孕育第二种拼法,排除);
    2. 解析点移到 fan-out 后按收件人:payload.locale 不再是 fan-out 前单值,插进 email-channel.ts L86-99 自留的 seam;
    3. 解析链 = 收件人 locale → 部署默认(II18nService.getDefaultLocale()),缺失恒回退,⛔ 任何路径不得死信(hotcrm 实测的 "undefined" 字面量死信形状是反例钉);
    4. better-auth 相邻面核验写进实施第一步:sys_user 面与 better-auth 的用户表映射关系先测后动,若发现耦合冲突 ⇒ 停手报回,⛔ 不硬凑;
    5. 与 TEMPLATE_* permanent 失败分类的交互写进 PR 正文供复审;
    6. 条款②:YES(packages/spec/src/system 用户面扩宽)⇒ draft + needs:contract-review 同笔,本席复审;changeset minor;
    7. 首个消费者 = hotcrm#1185(能力落地日退化为纯元数据转换);文档漂移卡 spec: NotifyConfigSchema template doc/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

  2. huangyiirene commented on Sep 2, 2026

    @huangyiirene
    CollaboratorAuthor

    分诊 · 反向索引维护(不动裁决,不动值级)

    已补 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 同笔,项目总监席复审;changeset minor。

    ⚠️ 该标签按裁决的措辞是在起草 PR 的那一笔上,所以本席没有提前打在卡上 —— 但它容易在认领与起草之间掉队,故在此点名。同样容易掉队的是第 4 项:better-auth 相邻面核验写进实施第一步,先测后动,发现耦合冲突 ⇒ 停手报回,⛔ 不硬凑。


    Generated by Claude Code

  3. claude commented on Sep 2, 2026

    @claude
    Contributor

    Claim: PM loop, domain:spec seat (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 as feat(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 (the sys_user declaration — one locale field; managedBy: 'better-auth', protection.lock: 'full' at :33-37) + packages/plugins/plugin-auth/src/auth-schema-config.ts (the better-auth user model mapping at :43-66 and its additionalFields seam 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 own locale: 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 (SessionUserSchema at :27-40 — the clause-② surface; it already declares language: 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 — the getDefaultTemplateLocale block 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) + whatever check:generated proves 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 --tier on the six files: no path-derived mandate; clause-② SUSPECT hint on the two packages/spec/src/** paths — the run printed its stale-tree warning (derived 59 commits behind origin/main); the dev re-takes the gate list on the actual diff.
    Clause-②: yes — ruling item 6; the widened published contract is SessionUserSchema (packages/spec/src/api/auth.zod.ts) plus the sys_user object declaration ⇒ the PR opens as draft with needs:contract-review in the same stroke; this seat reviews.
    Serial constraints cleared: file lists of all 28 open PRs scanned at 23:37Z — none touches platform-objects/src/identity/, plugin-auth/src/auth-schema-config.ts, plugin-auth/src/sys-user-writable-fields.ts, service-messaging/, api/auth.zod.ts or io-node-config.zod.ts. Directed in-flight check (cross-domain path): every pm:dispatched card in domain:engine (#14683 · #14474 · #14342 · #14099), domain:services (#14615 · #14602 · #14547 · #14530 · #14484 · #14373 · #14333 · #13533), domain:cli and domain:devx read — no claim names a file of this surface (text mentions of plugin-auth in #14099 / #14615 / #14602 / #14530 / #14373 and of platform-objects in #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, BUT plugin-auth landed 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: caller Accept-Language → deployment default, invitations left on the default until a per-user column exists = #14641) — the dev works from merged main and 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_issues at 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_user carries no locale column (sys-user.object.ts fields at :545-793 — none), protection.lock: 'full' at :33-37, managedBy: 'better-auth' at :22; email-channel.ts:86-99 still 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 / value at :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 no sys_user producer for it — the "second spelling" the ruling set out to avoid may already exist on the contract side.


    Generated by Claude Code

  4. claude commented on Sep 2, 2026

    @claude
    Contributor

    Dispatch (R4 of this shift, 2026-09-02T23:45Z) — domain:spec seat, session_017RbbUMnxkUnWhE4j94v8FE, seat post #6017. mode:cloud — cloud session session_0115Gh1e5jMywLPotTC9yvPt (created 23:45:00Z, model: claude-fable-5-1, outcome branch claude/issue-13881-sys-user-locale, title "⚡ spec #13881 sys_user.locale + per-recipient notification locale"), size L, clause ② yes. The dev leaves its own Claim: 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_user mapping (auth-schema-config.ts AUTH_USER_CONFIG, its additionalFields seam, the SQL schema-drift path on a protection.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-model additionalFields entry; H3 SessionUserSchema.language (packages/spec/src/api/auth.zod.ts, default 'en') already declares a per-user language on the published contract with no sys_user producer found — if locale and language would 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 in email-channel.ts and both single-value read sites collapse into it, with the standing of a producer-set payload.locale reported rather than assumed; H5 no fallback read from sys_user_preference (B was rejected); H6 the ADR-0092 profile-edit whitelist ({name, image}) is NOT widened silently — an open_questions entry if the ruling needs it; H7 a dead-letter pin: NULL / '' / absent locale ⇒ 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, the auth-manager.ts comment, system-context.mdx); flipped pins keep bearing weight.
    • Clause ② landing: draft PR with Fixes #13881 + needs:contract-review in the same stroke; never flipped to ready by the dev. Changesets measured by the gates (@objectstack/spec minor). PR body carries the better-auth verification, the TEMPLATE_* 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 blocked report; premise_still_valid: false without a PR is a legitimate delivery; report as an os-dev-report issue 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

  5. claude commented on Sep 3, 2026

    @claude
    Contributor

    Contract review (clause ②) — isolated reviewer verdict, adopted verbatim — domain:spec seat, 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_session at 01:15Z = claude-fable-5-1 = CONTRACT_REVIEW_TIER on origin/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 at aa2994e5d and the dev report (no dispatch text, no seat conclusions); its transcript carries 101 harness model stamps, all claude-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 as needs-user-decision cards filed by this seat. needs:contract-review stays 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

    1. Derived judgments

      • sys_user.locale column (platform-objects sys-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; readonly is 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 gated if (!opCtx.context?.isSystem) (engine.ts:11201, "system writes legitimately set read-only columns and are exempt") and the identity write guard passes isSystem (identity-write-guard.ts:91-97). No admin surface writes it (not in SYS_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.ts docblock :138-154, template.describe() :213, both superRefine messages :279/:300-301; notify-node.ts descriptor :197; generated io-node-config.mdx:99 matches the describe verbatim) · items 2, 3, 7 · faithful. Accept-set unchanged: NotifyConfigSchema keys are identical on both refs; payload stays z.record(z.string(), z.unknown()) (origin/main :256), so a node carrying payload.locale still validates — it is inert, not refused.
      • payload.locale no longer read — email both arms (origin/main email-channel.ts:192-194, :224 → probe templateLocale = recipientLocale, locale = recipientLocale ?? defaultLocale), inbox template path (origin/main inbox-channel.ts:143-144), SMS (origin/main sms-channel.ts:124) · items 2–3 · faithful. Measured on origin/main: the only payload.locale hits 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.locale if 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_template arm — email topic path and SMS — and SMS newly wired with getDefaultTemplateLocale (messaging-service-plugin.ts +268, SmsChannelOptions.getDefaultTemplateLocale new). On origin/main that arm was payload.locale ?? defaultLocale with defaultLocale = opts.defaultLocale ?? DEFAULT_LOCALE = the static 'en' (template-renderer.ts:9); II18nService.getDefaultLocale() was consulted only on the sendTemplate arm. Effect: a deployment with localization.locale = zh-CN and a topic bundle holding en and zh/zh-CN rows rendered en before and renders zh-CN now for every recipient without a column · faithful to item 3 (two rungs, everywhere) but beyond the seam item 2 named (L86-99 = the sendTemplate arm's option) and NOT stated as a behaviour change anywhere — patch 3.
      • Inbox channel: a NEW sys_user read per template-path delivery (inbox-channel.ts +80-95, +176-179; origin/main inbox-channel.ts had no findOne/sys_user at all) plus new InboxChannelOptions.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_OBJECT relocated to recipient-locale.ts and re-exported from email-channel.ts (EMAIL_USER_OBJECT alias unchanged) · implied by "one read point" (triage note) · additive minor widening, acceptable; no api-surface baseline covers service-messaging (EMAIL_USER_OBJECT appears 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 registers SYS_USER_PROFILE_EDIT_FIELDS and MANAGED_EXTENSION_EDITABLE_FIELDS (auth-plugin.ts:1290-1300), neither of which changed.
      • Translations: four *.objects.generated.ts gain the leaf; en mirrors 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.mdx rewritten; io-node-config.mdx regenerated · item 7 · faithful. content/docs/releases/v17.mdx:3513-3517 correctly left (release-owned).
      • plugin-auth comments: three sites rewritten (auth-manager.ts :3029-3037, :4710-4718; phone-sms-texts.ts; auth-email-locale.test.ts header) — two sites missed; see 7.
    2. 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 read fields: ['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: readRecipientLocale catches ⇒ rung 2. Downstream: sendTemplate floors at en-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: email it.each of 7 shapes asserts locale === 'ja-JP' and !== 'undefined'; the "read fails" pin's fake still returns locale: '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') === false pins the absent-key floor; inbox/SMS carry the "undefined" and throwing-read pins; the resolver has its own unit suite. templateDelivery(payload) puts locale inside payload (email-channel.test.ts :165-174) and the SMS channel() helper builds without a deployment default (:76-79), so the "not consulted" pins test what they claim.

    3. better-auth adjacency — PASS. AUTH_USER_CONFIG (auth-schema-config.ts:42-49) is modelName + three renames, no additionalFields; the file's only additionalFields block (:231-255) is AUTH_INVITATION_SCHEMA (D8). The ai_access reasoning quoted by the PR is the literal note at auth-manager.ts:1251-1258, and locale is declared identically to ai_access / source / manager_id (readonly, objectql-owned, not a better-auth field). D7 is the right adjacent declaration: the collision loop iterates MANAGED_EXTENSION_FIELDS itself against getAuthTables() with the auth manager's full plugin set (managed-extension-fields.test.ts :14-16, :104-118), so the constant change puts locale under the guard automatically; the probe's test also pins it declared-but-not-editable (:864-872). "SQL driver sync is additive" is true: initObjects diffs declared fields against columnInfo and alterTable-adds any name not in existingColumns (sql-driver.ts:9663-9681); no driver or objectql DDL path reads protection.lock (grep over packages/drivers/**/src and packages/objectql/src: empty) — the lock is metadata protection only.

    4. TEMPLATE_* interaction — INCOMPLETE. The recorded asymmetry (bundle with the deployment default's row but no en-US row; recipient with a third tag) is correct. Missed: with NO deployment default — i18n service absent or getDefaultLocale unimplemented, a shape messaging-service-plugin.ts:147-149 explicitly declares — origin/main called sendTemplate with 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 no en-US row (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 makes preferred truthy, the any-row rung is skipped, and that same delivery dead-letters TEMPLATE_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. The sys_notification_template arm cannot fail (store returns null, never throws) — correct as stated.

    5. 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 retired payload.locale read 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).
    6. 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_template arm is an unflagged behaviour change ⇒ changeset text patch 3.
      • Deviation 2 (payload.locale retired): 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/system holds no sys_user field declaration (its sys_user hits are name constants / auth-config / i18n-resolver only); the ruling mislocated the producer. Spec minor stands 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-user cannot carry it (not a better-auth field), so "users set their own language" necessarily means widening the ADR-0092 D2 generic-data-path whitelist, plus MANAGED_EXTENSION_EDITABLE_FIELDS.sys_user, plus dropping readonly.
      • Open question 2 (SessionUserSchema.language): the PR may land with it untouched — it changes nothing on the session contract, and language (auth.zod.ts:36, default('en')) has no producer and no consumer in this repo (only auth.test.ts pins). 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 spelling language; (ii) the wire already has a locale key on the current-user surface — GET /auth/me/localization returns locale: 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: retire language under ADR-0049 (declared, never produced) and project sys_user.locale as locale only when the session endpoint actually produces it; objectui's import of SessionUserSchema.language is unmeasured from this repo and must be measured before any rename.
      • Open question 3 (payload.locale third 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.
    7. 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 (sendChangeEmailNotice docblock): "Still NOT a per-recipient stored preference: sys_user carries no locale column and the 2026-09-02 ruling continues to defer one."
      • packages/plugins/plugin-auth/src/auth-manager.ts:4668-4669 (setDefaultSmsLocale docblock): "Per-user locale is not resolved yet — sys_user carries 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).
    8. 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.mdx equals the describe string; check:generated was 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_SHAPE is 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):

    1. packages/plugins/plugin-auth/src/auth-manager.ts:4459-4461 — replace "Still NOT a per-recipient stored preference: sys_user carries 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.locale exists 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.locale exists 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."
    2. packages/plugins/plugin-auth/src/auth-manager.ts:4668-4669 — replace "Per-user locale is not resolved yet — sys_user carries 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.locale exists 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.locale exists now and is not read #14762 (supersedes the feat(sms/i18n): 邀请/OTP 短信文案国际化(按用户 locale 渲染) #2815 note)."
    3. .changeset/notification-locale-per-recipient.md and the PR body (§ "Ruling item 5"): (a) add, after the "Behaviour change for producers" paragraph: "Second behaviour change: on the sys_notification_template arm (email topic path, SMS) the deployment default (II18nService.getDefaultLocale()) is now the second rung; before, that arm fell straight from payload.locale to the static en and never consulted it. A deployment whose localization.locale is e.g. zh-CN with a topic bundle holding en and zh rows renders zh there 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 no en-US row: old = the ladder's any-row rung delivered, new = TEMPLATE_NOT_FOUND), and correct "only where the deployment default would have too" accordingly.
    4. Wording: packages/services/service-messaging/src/recipient-locale.ts:21-23 and 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.md last 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):

    1. 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 + drop readonly, with the identity-write-guard pin, as a follow-up PR under this ruling (security-boundary widening, human floor).
    2. One spelling for the user's language on the published session/current-user surface, now that sys_user.locale exists while SessionUserSchema.language (unproduced, unconsumed) and /auth/me/localization.locale (request/deployment value) both already exist — A: leave language as is · B: add locale beside it (two spellings, the shape the ruling rejected) · C: rename language → locale (breaking; ADR-0087 entry; objectui import unmeasured) · D: retire language under ADR-0049 and project sys_user.locale as locale only with a real producer.

    Generated by Claude Code

  6. 1 remaining item

  7. claude commented on Sep 3, 2026

    @claude
    Contributor

    Contract review (clause ②) — PASS on the patched head c4f538e5b — domain:spec seat, 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_session at 01:45Z = claude-fable-5-1 = CONTRACT_REVIEW_TIER on origin/main. The isolated reviewer's FAIL verdict (5518923117) named four required patches and nothing else; the delta since the reviewed head aa2994e5d is one commit (c4f538e5b, 01:37Z, dev addendum 5519013282) touching exactly the four named files (+37/−15):

    1. auth-manager.ts sendChangeEmailNotice docblock — replaced with the verdict's text verbatim ✅
    2. auth-manager.ts setDefaultSmsLocale docblock — replaced with the verdict's text verbatim ✅
    3. .changeset/notification-locale-per-recipient.md — the second behaviour change (deployment default newly the second rung on the sys_notification_template arm; SMS newly handed the probe) and both TEMPLATE_* asymmetries recorded, "only where the deployment default would have too" corrected; the PR body § "Ruling item 5" carries the same two paragraphs ✅
    4. 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:generated current. Decisions #14787 / #14788 remain in the inbox and do not gate this landing.

    Landing (same stroke): needs:contract-review cleared on the PR (the card never carried it — the label was set with the PR, per the dev's claim); check-clause2-carriers --pair 14775 reading below; trial merge clean vs origin/main 89a156af8 (one main commit since the base touches auth-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 on c4f538e5b is green.


    Generated by Claude Code

  8. claude commented on Sep 3, 2026

    @claude
    Contributor

    Claim: PM loop, domain:spec seat (seat post #6017) — governing-claim addendum for the enqueue gate's content limb (the dev's own claim 5518094962 carries no Clause-② line; the seat's claim 5518052685 does, but the checker reads the newest Claim: naming the branch)
    Session: session_017RbbUMnxkUnWhE4j94v8FE
    Branch: claude/issue-13881-sys-user-locale
    Worktree: objectstack-issue-13881 (the dev's cloud session session_0115Gh1e5jMywLPotTC9yvPt)
    Domain: domain:spec
    File surface: unchanged from 5518052685 — delivered as PR #14775 (28 files, head c4f538e5b)
    Container & model: L, mode:cloud, model: claude-fable-5-1
    Clause-②: yes — contract review PASS on c4f538e5b (5519054527, after the isolated reviewer's FAIL 5518923117 and patch round 1); needs:contract-review cleared on the PR in the same stroke.
    Serial constraints cleared: unchanged from 5518052685; trial merge clean vs origin/main 89a156af8 at 01:45Z.


    Generated by Claude Code

  9. claude commented on Sep 3, 2026

    @claude
    Contributor

    Dual-carrier gate repair (domain:spec seat, session_017RbbUMnxkUnWhE4j94v8FE, 2026-09-03T01:48Z): the dev hung needs:contract-review on 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 signature check-clause2-carriers reads 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 tier claude-fable-5-1. No new verdict is issued here.


    Generated by Claude Code

  10. claude commented on Sep 3, 2026

    @claude
    Contributor

    State: pm:dispatched → pm:blocked (domain:spec seat, 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: the packages/cli run-dev-unbuilt-workspace.e2e.test.ts hang, 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 carries Blocked-by: #14832 and Unlock-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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions