You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
第二条使第一条的目标不可达:投递路径每次通知只解析一个语言(payload.locale 在 fan-out 前插值一次,或部署默认),从不按收件人解析。service-messaging/src/email-channel.ts L86-99 自述此状并写明「a per-user locale, when it lands, plugs in here」。
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/clirun-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.)
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.
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.tsAUTH_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.
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).
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
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.
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.
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.
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.
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.
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).
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.
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).
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.
.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.
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):
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).
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.
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):
auth-manager.tssendChangeEmailNotice docblock — replaced with the verdict's text verbatim ✅
auth-manager.tssetDefaultSmsLocale docblock — replaced with the verdict's text verbatim ✅
.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 ✅
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/main89a156af8 (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.
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/main89a156af8 at 01:45Z.
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.
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/clirun-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.
Activity
huangyiirene commentedon Sep 1, 2026
裁决:A ——
sys_user.locale一等列;解析点移到 per-recipient(维护者 2026-09-01,总监批 #23)项目总监席 · session
session_01KGtaLpkW1mycWgkbSb3H6t· 维护者对本批逐字:「同意」。2026-08-13「推迟至有实测拉力」的前提已被 hotcrm#1185 的测量满足(4 语言 × 16 notify 节点 × 0 可本地化,两个独立车道同判),该裁决按其自身条款解除。sys_user加locale一等列(用户语言是主流平台的一等用户属性;B 的 preference 袋会把一等概念藏进键值对并孕育第二种拼法,排除);payload.locale不再是 fan-out 前单值,插进email-channel.tsL86-99 自留的 seam;locale→ 部署默认(II18nService.getDefaultLocale()),缺失恒回退,⛔ 任何路径不得死信(hotcrm 实测的 "undefined" 字面量死信形状是反例钉);sys_user面与 better-auth 的用户表映射关系先测后动,若发现耦合冲突 ⇒ 停手报回,⛔ 不硬凑;TEMPLATE_*permanent 失败分类的交互写进 PR 正文供复审;packages/spec/src/system用户面扩宽)⇒ draft +needs:contract-review同笔,本席复审;changeset minor;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
huangyiirene commentedon Sep 2, 2026
分诊 · 反向索引维护(不动裁决,不动值级)
已补
pm:blocking(get_labels回读确认,现 3 个标签)。本卡现在有一个已登记的下游: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。Generated by Claude Code
claude commentedon Sep 2, 2026
Claim: PM loop,
domain:specseat (seat post #6017)Session:
session_017RbbUMnxkUnWhE4j94v8FEBranch:
claude/issue-13881-sys-user-localeWorktree:
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 is
SessionUserSchema(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 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.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
claude commentedon Sep 2, 2026
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):
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.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.main, read both diffs before touching any locale seam.email-channel.ts,io-node-config.zod.ts:135-155, theauth-manager.tscomment,system-context.mdx); flipped pins keep bearing weight.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.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.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
claude commentedon Sep 3, 2026
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.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.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.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.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.*.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.email-templates.mdxrewritten;io-node-config.mdxregenerated · item 7 · faithful.content/docs/releases/v17.mdx:3513-3517correctly left (release-owned).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
sys_notification_templatearm is an unflagged behaviour change ⇒ changeset text patch 3.payload.localeretired): answered — faithful to items 2–3, no change to this PR; the changeset already states the change and the fix line.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)./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.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.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.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
io-node-config.mdxequals the describe string;check:generatedwas measured by the dev, not re-run here (no pnpm).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.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):
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).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
claude commentedon Sep 3, 2026
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
claude commentedon Sep 3, 2026
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_017RbbUMnxkUnWhE4j94v8FEBranch:
claude/issue-13881-sys-user-localeWorktree:
objectstack-issue-13881(the dev's cloud sessionsession_0115Gh1e5jMywLPotTC9yvPt)Domain:
domain:specFile surface: unchanged from 5518052685 — delivered as PR #14775 (28 files, head
c4f538e5b)Container & model: L,
mode:cloud,model: claude-fable-5-1Clause-②: yes — contract review PASS on
c4f538e5b(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 vs
origin/main89a156af8at 01:45Z.Generated by Claude Code
claude commentedon Sep 3, 2026
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
claude commentedon Sep 3, 2026
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
fix(auth): read the recipient's own sys_user.locale for auth OTP SMS …
feat(spec,platform-objects,service-messaging,plugin-auth): sys_user.l…
fix(auth): read the recipient's own `sys_user.locale` for auth OTP SM…
fix(spec): give the email-template locale bundle a stated en-US floor…