Repository navigation
Description
Filed by the domain:spec seat (session_017RbbUMnxkUnWhE4j94v8FE) from the contract review of PR #14775 (#13881, isolated reviewer verdict 5518923117, "Decisions to file for the maintainer" item 2). Decision inbox card — needs-user-decision; no domain:* / type set by this seat (triage's). PR #14775 lands without touching any of the three surfaces below; this card decides the follow-up.
背景
登录用户的「语言」在公开面上现在有三种拼法并存:
SessionUserSchema.language(packages/spec/src/api/auth.zod.ts:36,z.string().default('en'),描述 "Preferred language")—— 已发布的 session 契约,但全仓没有生产者也没有消费者(只有自己的 spec 测试);sys_user.locale—— [Decision] Per-user notification locale —— 2026-08-13 裁决所等的「实测拉力」已到(hotcrm:4 个已发布语言 × 16 个 notify 节点 × 0 可本地化) #13881 裁决新增的一等列(PR feat(spec,platform-objects,service-messaging,plugin-auth): sys_user.locale + per-recipient notification locale (#13881) #14775),通知投递按收件人读它;GET /auth/me/localization返回的locale(packages/plugins/plugin-auth/src/current-user-endpoints.ts~:876-888)—— 取的是请求/部署的 locale,不是用户列;objectui 曾用它播种 UI 语言(console: the tenant's server-sidelocale(/auth/me/localization) is fetched but never seeds the UI language — first login is always browser-or-en#5419)。
#13881 的裁决明确排除「第二种拼法」,却没看见 language 早已躺在契约上。复审判 PR 可以不动它先落地,本卡决定收口方向。
带 re-check 命令的前提
language仍在契约上且无生产者:git grep -n "language" origin/main -- packages/spec/src/api/auth.zod.ts packages/plugins/plugin-auth/src packages/rest/src(只应命中 auth.zod.ts:36 与Accept-Language类用法)/auth/me/localization仍返回请求/部署 locale:git grep -n -B3 -A8 "localization" origin/main -- packages/plugins/plugin-auth/src/current-user-endpoints.ts- objectui 是否引用
SessionUser.language(未测,本席只有 objectstack 检出):在 objectui 仓git grep -n "\.language\b\|SessionUser" origin/main -- packages apps
一句话问题
用户在个人资料里选了中文,前端问服务器「这个用户什么语言」时,契约上有一个永远是 'en' 的 language、一个不看用户只看请求的 locale,和一个刚落地但契约上不可见的用户列 —— 三个答案,没有一个是对的。
选项 × 真实代价
| 选项 | 做什么 | 真实代价 |
|---|---|---|
| A 不动 | language 继续躺在契约上,sys_user.locale 只做存储 |
前端/AI 读契约会相信 language 有意义,拿到永远的 'en';用户选的语言 UI 永远看不到 —— 「声明 ≠ 兑现」原样留着 |
B 加 locale 到 session 契约,与 language 并存 |
两种拼法一个面 | 正是 #13881 裁决明令排除的形状;每个消费者都要猜读哪个 |
C 直接改名 language → locale |
破坏性改名,ADR-0087 登记 | 已发布契约破坏;objectui 是否引用未测 —— 可能弄坏 console 登录态 |
D 退役 language(ADR-0049 enforce-or-remove),只在 session 端点真正产出时再以 locale 投影用户列 |
先删从未兑现的键;/auth/me/localization 改成「用户列 → 请求 → 部署默认」并成为唯一的读面 |
一次 ADR-0087 退役登记 + 一个端点改动;需要先测 objectui 有没有读 language |
业务上:A = 产品文档里写着「支持用户语言」但设置无效;B = 设置页有两个「语言」下拉;C = 一次升级把老客户端弄坏;D = 像成熟 SaaS 那样,只有一个「我的语言」,后端按它答。
四轴(从业务立场)
- 实际业务需求:
language零拉动(从未产出、从未消费);用户列的 UI 侧拉动是真的(console: the tenant's server-sidelocale(/auth/me/localization) is fetched but never seeds the UI language — first login is always browser-or-en#5419 显示 console 曾试图从/auth/me/localization播种语言,而它今天不读用户列)。 - 项目长远合理性(≥50%):一个概念一个拼法、声明即兑现 —— D 把契约收窄到真实生产者,B 扩生特例,A 留着假声明,C 用破坏性动作换一个本可以先删再加的结果。
- 防 AI 犯错:AI 读契约看到
language就会用,拿到'en'还以为对了 —— 静默错误的温床;退役后读契约只剩一个locale,错了是响亮的编译期缺键。 - 创业阶段不扩散:D 是 remove 优于 declare-and-maintain;不设双拼写宽限(2026-08-27 裁:创业阶段不渐进)。
推荐 + 回退 + 置信缺口
- 推荐 D:先在 ADR-0049 通道退役
SessionUserSchema.language(ADR-0087 登记),/auth/me/localization的locale改为「用户列 → 请求 locale → 部署默认」,成为唯一读面;不在 session 契约上另加字段,直到有真实生产者。 - 回退 A(什么都不动,记录在案)。
- 本分析看不见什么:objectui / cloud / hotcrm 是否读
SessionUser.language(未测 —— 执行卡第一步必须先测,命中即改判为 C 的兼容路径);/auth/me/localization的现有消费者对「用户列优先」的预期。
裁后我会怎么执行(你不用管)
- 裁 D:等 PR feat(spec,platform-objects,service-messaging,plugin-auth): sys_user.locale + per-recipient notification locale (#13881) #14775 合并后立执行卡(spec + plugin-auth 落点,条款② yes):测 objectui/cloud 引用 → ADR-0087 退役
language→ 端点改读用户列 → 契约文本 + pin + changeset(spec minor,退役登记)。 - 裁 A/B/C:按裁决立卡或关卡;C 必须先测 objectui。
Refs
#13881(裁决 5494464459)· PR #14775(复审 5518923117,H3 finding)· #5419(console 播种 UI 语言,已关)· #14762 · ADR-0049 · ADR-0087
os-decision-facets
- ① 长远合理性:一个概念一个拼法、声明即兑现 —— 退役从未产出的
language,用户语言只经locale投影(D);B 扩生特例,A 留假声明,C 破坏性换同一结果。 - ② 实际业务拉动:
language零拉动(无生产者无消费者);用户列的 UI 播种拉动是真的(console: the tenant's server-sidelocale(/auth/me/localization) is fetched but never seeds the UI language — first login is always browser-or-en#5419)—— 拉动指向 D 的第二步,不指向保留language。 - ③ 防 AI 犯错:留着
language= AI 读契约拿到永远的'en'还以为对;删掉后错误是响亮的缺键。 - ④ 创业阶段不扩散:remove 优于 declare-and-maintain,不设双拼写宽限。
- 推荐:D(回退 A;C 只在 objectui 实测有引用时作为兼容路径)。
- 置信缺口:objectui / cloud / hotcrm 对
SessionUser.language与/auth/me/localization的引用未测。
Generated by Claude Code
Activity
huangyiirene commentedon Sep 3, 2026
Triage — sweep ③ lane repair. Added
priority:p2·domain:spec·i18n;needs-user-decisionkept. ⛔ No decision content added — the card carries its own, and the ruling is the maintainer's.Anchor (the lane is read from where the fix lands, ⛔ never guessed from the title):
SessionUserSchemais declared inpackages/spec/src/api/auth.ts— confirmed via its test atpackages/spec/src/api/auth.test.ts:4, which imports it from there. Whichever spelling wins, the published-surface half of it lands inpackages/spec. ⇒domain:spec.p2, notp3: this decision gates real queued work rather than sitting on its own. #14762 (pm:blocked) names precisely this question as the one its ruling did not settle — "does the user's stored locale outrank the request'sAccept-Languagefor a signed-in user's own sends?" — and #13881 (pm:dispatched,pm:blocking) is in flight on the column itself. Three spellings coexisting on a published surface is also the shape that gets more expensive per release, not less.git grep 13881 -- packages/atorigin/mainreturns 0 hits, andnormalizeRecipientLocaleinservice-messagingis 0. Any card describingsys_user.localeas present is describing that branch.sys_user.locale? — the ADR-0092 D2 self-service whitelist stays{name, image}after #13881 (column landsreadonly, system-context writes only) #14787 is coupled to this, not parallel to it. If the column isreadonlywith system-context writes only, then "the user's stored locale" is an administered value rather than a stated preference — which changes what the right precedence is, not merely who may write it. ⛔ Ruling these two independently can produce a pair that does not compose.Generated by Claude Code
os-project-manager commentedon Sep 3, 2026
Maintainer ruling recorded — D: retire
SessionUserSchema.language;/auth/me/localization.localebecomes the one read face, projecting the user columnDirector seat, summon #11, session
session_01ShyhexkB2d1AeRZ85tgAAe, 2026-09-03.Provenance (who / verbatim / where): maintainer, live PM chat with the director seat, 2026-09-03, replying to decision batch #18 in which this card was item 1 with the recommendation D (fallback A), presented together with #14787 (item 2) because the two compose. Verbatim reply: 「同意」 — adopts D, and B on #14787.
Ruled: D.
SessionUserSchema.language(packages/spec/src/api/auth.zod.ts) is retired under ADR-0049 enforce-or-remove with an ADR-0087 registry entry — no producer, no consumer, gone; no replacement field is added to the session contract until a real producer exists.GET /auth/me/localizationkeeps itslocaleand becomes the single read face for "this user's language", with the precedence that follows from #14787's ruling B (the column is a user-stated preference):sys_user.localewhen set → the request'sAccept-Language→ the deployment default. Options A (leave the dead field), B (addlocalebesidelanguage— the shape #13881 excluded) and C (breaking rename) are not taken; 不渐进 — no dual-spelling window.Execution:
domain:speclane (spec half) with theplugin-authendpoint in the same PR, after PR #14775 (#13881) has merged — the column must exist onmainfirst.Clause-②: yes(a published session type loses a member; a published endpoint's semantics change) ⇒claude-fable-5,needs:contract-review, in-seat review before ready. Step one of the dispatch is the measurement the card names: readers ofSessionUser.languageand of/auth/me/localizationin objectui, cloud and hotcrm — any hit is changed in the same stroke (objectui by a cross-lane card withBlocked-by:this one), ⛔ not bridged. Changeset:@objectstack/specminorwith a BREAKING note per the launch-window convention; endpoint pin asserting the three-step precedence; #14762 (pm:blocked) re-reads itsBlocked-by:once this lands.State transition, same stroke:
needs-user-decision→pm:queue. Ledger: director seat post #12708, summon #11.Generated by Claude Code
fix(auth): read the recipient's own sys_user.locale for auth OTP SMS …
os-justin commentedon Sep 4, 2026
Claim:
domain:specexecution seat, sessionsession_01H2oQebDDxYKfWZusyd8GXk, branchclaude/issue-14788-retire-session-user-language. Labelspm:queue→pm:dispatchedwritten in one stroke with the assignee (read back 2026-09-04T12:45Z:priority:p2,pm:dispatched,domain:spec,i18n; assignee set; no concurrent label stripped). Executes the maintainer's ruling D (comment 5522880441, verbatim 「同意」, 2026-09-03).Precondition verified (2026-09-04T12:44Z,
origin/mainc4d1354e3): PR #14775 (#13881) is merged — squash1401ae7c2is onorigin/main, andpackages/platform-objects/src/identity/sys-user.object.tsdeclares the BCP-47sys_user.localecolumn (:6,:19-21). The ruling's "after PR #14775 has merged" gate is open.Surface (read on
origin/mainc4d1354e3):packages/spec/src/api/auth.zod.ts:36—language: z.string().default('en')still onSessionUserSchema; card's re-check reproduces (zero producers/consumers outside its own test).packages/plugins/plugin-auth/src/current-user-endpoints.ts(no such file on main).GET /auth/me/localizationis served bypackages/plugins/plugin-hono-server/src/current-user-endpoints.ts:871-888, returningexecCtx.locale ?? null— request/deployment context, not the user column. The ruling's "plugin-auth endpoint in the same PR" is read as "the endpoint, in the same PR", wherever it lives; this is a cross-domain surface (plugin-hono-serverisdomain:cli) executed on the spec seat by the maintainer's ruling — the claim declares it here as the full file surface.Accept-Languagereading already exists inpackages/plugins/plugin-auth/src/auth-manager.ts:1069-1091(authEmailLocaleFromRequest); the dev decides whether the endpoint reuses it or the request-context resolver that feedsexecCtx.locale.retired-keysentry forSessionUser.language⇒ regeneratespackages/spec/src/migrations/registry.ts.Consumer measurement (objectui
origin/main0b24d7f, 2026-09-04T12:44Z):git grep -n -E '\.language\b|SessionUser|auth/me/localization' origin/main -- packages apps— zero readers ofSessionUser.language(all.languagehits are i18next / script-bodylanguage, unrelated);/auth/me/localizationis read byapps/console/src/languageSeed.ts:84(cacheLanguageSeed(json?.locale)),LocalizationFetchProvider.tsx:3andAppContent.tsx:233— they consumelocale(andcurrency), which the ruling keeps; the user-column-first precedence is exactly the seeding they wanted (#5419). ⇒ no objectui cross-lane card needed unless the dev finds a response-shape change. cloud / hotcrm: NOT MEASURED from this session (no checkout) — the dev states this in the PR body.Tier:
dispatch-gates --tierreports no path-derived mandate (floor). Card content: a published session type loses a member, a published endpoint's semantics change ⇒ Clause ② yes,CONTRACT_REVIEW_TIER; dev hangsneeds:contract-reviewon PR + card, in-seat review before ready.Serial check (2026-09-04T12:44Z): no open PR touches
auth.zod.tsorplugin-hono-server/src/current-user-endpoints.ts; no H17 trigger file intersects (anchor #9857, swept 07:48Z). The regenerated migration registry collides with PR #15299 (armed, in queue) ⇒ this PR lands serially after #15299 and resyncs the registry onmainbefore ready.Generated by Claude Code
os-justin commentedon Sep 4, 2026
Dispatch — round R3 slot refill, 2026-09-04T12:47Z
Seat
domain:spec, sessionsession_01H2oQebDDxYWfWZusyd8GXk,os-devsubagent atCONTRACT_REVIEW_TIER, branchclaude/issue-14788-retire-session-user-language, treeorigin/mainc4d1354e3(2026-09-04T12:44Z). Executes the maintainer's ruling D — comment 5522880441 (director seat relay, verbatim reply 「同意」, 2026-09-03), quoted for the binding part:① Task
SessionUserSchema.language(packages/spec/src/api/auth.zod.ts:36): remove the key; ADR-0087 D2/D3 registration as thespec-property-retirementplaybook prescribes for this schema's strictness (aretiredKey()tombstone if the schema is non-strict, otherwise the retired-keys entry alone),entries/retired-keysfor major 18,gen:migration-registry,check:generated --fix; fixauth.test.ts:48/68to pin the absence. Re-verify the card's premise first:git grep -n "language" origin/main -- packages/spec/src/api/auth.zod.ts packages/plugins/plugin-auth/src packages/rest/srcmust still hit only:36andAccept-Language-class uses — a producer appearing since means STOP and report.GET /auth/me/localizationthe one read face — handler atpackages/plugins/plugin-hono-server/src/current-user-endpoints.ts:871-888(plugin-auth/src/current-user-endpoints.tspath is stale; the ruling's "plugin-auth endpoint" is this endpoint).localeresolvessys_user.localewhen set (BCP-47 shape-checked, reusenormalizeRecipientLocale's acceptance or the column'slocale_bcp47_shaperule — never a second parser) → the request'sAccept-Language(reuse the reader inpackages/plugins/plugin-auth/src/auth-manager.ts:1069-1091or the request-context resolver that already feedsexecCtx.locale; do not write a third one) → the deployment default (localization.localesetting /II18nService.getDefaultLocale(), whicheverexecCtx.localealready derives from).currency/timezoneunchanged. Unauthenticated behaviour unchanged. Pin the three-step precedence in the endpoint's test file (three cases + the malformed-column fallback).git grep -n "localization" origin/main -- packages/spec/src) sayslocaleis the user's stated preference first;content/docssentences that describe/auth/me/localizationas "the request's / tenant's regional defaults" are rewritten (list them in the PR body).localeoff this endpoint (apps/console/src/languageSeed.ts:84,LocalizationFetchProvider.tsx), zero readers ofSessionUser.language— keep the response shape, and objectui needs no card. cloud / hotcrm NOT MEASURED — state that in the PR body verbatim.@objectstack/specminor + BREAKING note (SessionUser.languageremoved; launch-window convention) with<!-- adr-0087: registered … -->;@objectstack/plugin-hono-serverminor(endpoint precedence change, behaviour-visible). Add any package whose test/typecheck you touched.Acceptance:
git grep -n "language" origin/main -- packages/spec/src/api/auth.zod.ts→ 0 hits (control:git grep -n "SessionUserSchema" …non-empty); the endpoint pin's three precedence cases green;check:generatedclean;check-adr-0087-registrationgreen.② Constraints
git fetch origin main && git worktree add --no-track /home/user/objectstack-14788 -b claude/issue-14788-retire-session-user-language origin/main && cd /home/user/objectstack-14788 && pnpm install. Never edit the shared checkout; nevergit stash.needs:contract-reviewon PR and card (read-modify-write, read back),Fixes #14788. The seat reviews in-seat and flips ready; ⛔ you never flip ready or arm auto-merge.packages/spec/src/migrations/registry.tsis also regenerated by PR feat(spec)!: retire connector.errorMapping — eleven inert authorable keys, one spelled like the live userMessage channel (#14676, ADR-0049) #15299 (armed in the merge queue). Before you report, check whether feat(spec)!: retire connector.errorMapping — eleven inert authorable keys, one spelled like the live userMessage channel (#14676, ADR-0049) #15299 has merged (git fetch origin main && git log origin/main --oneline -5); if yes, mergemainthroughscripts/pm/os-regen-merge.shand regenerate; if not, say so — the seat resyncs after it lands.plugin-hono-serverisdomain:cli) — executed here by the maintainer's ruling; keep the endpoint change to the precedence + pin, nothing else in that package.node scripts/pm/dispatch-gates.mjs --commandson your change set; run every derived command; real output lines. No model identifiers anywhere. English only on GitHub.Implemented-by:with your session id.③ Report
Return the
os-devJSON report (status, PR + head SHA, changed files, commands with real output,contract_flags,open_questions,findings). Contract flags: any consumer ofexecCtx.localewhose meaning changes, any place wheresys_user.localeis not readable in the endpoint's context (system-context read needed?), and any mismatch between the column's shape rule and the endpoint's acceptance.升级与决策(逐字,runbook 步骤
1bc3c092;块 md522f2339f0acb64cdb50c7adc9db681c3,SKILL.md## 升级与决策atorigin/mainc4d1354e3)每个方案必须沿四条固定评估轴分析,这是决策分析的核心原则,不是可选项:
还是投机性能力面?判据要求实测(谁在写这个键、谁在读、
示例应用与真实部署的用法),「读起来像有用」不作数。这条轴会改变结论,不是陪衬。
contract-first),临时补丁式选项要明说长期代价。
契约收紧(严格 schema、publish 时响亮拒绝)优于消费端宽容(
??回退、静默容错)——宽容恰是 AI 批量犯错被掩盖的温床;声明即强制,绝不让 AI 声明一个运行时不兑现的能力。
「我们是一个创业项目,应该先专注于核心能力」):能力扩张默认从紧,
无拉动的声明面按 implementation-first 处置,已发布零消费的能力不因沉没成本获得豁免。
过渡也从紧 —— 创业阶段不渐进(维护者 2026-08-27 裁,逐字:「项目在创业阶段,
用户也很少,短期不考虑渐进。」):废弃别名/拼写与能力退役默认立即退休,
不设分阶段窗口、不留双拼写宽限;staged 选项仅凭具名外部用户证据才可呈报为推荐。
推荐意见必须基于这四条轴给出理由;四轴冲突时如实呈现权衡,
交维护者拍板。
Generated by Claude Code
os-justin commentedon Sep 4, 2026
Correction to the dispatch comment above (5540646566): the seat session id there is mistyped. The dispatching session is
session_01H2oQebDDxYKfWZusyd8GXk, as on the claim comment. Nothing else changes.Generated by Claude Code
3 remaining items
os-justin commentedon Sep 4, 2026
os-dev-report
{ "issue": 14788, "status": "done", "branch": "claude/issue-14788-retire-session-user-language", "pr": "https://github.com/objectstack-ai/objectstack/pull/15386", "head_sha": "06f8e2268a567073a69171138ea828f5ded9af26", "premise_still_valid": true, "summary": "Ruling D executed. SessionUserSchema.language is a retiredKey() tombstone (non-strict z.object; response surface, so no D2 conversion — the api/AuthFeaturesConfig:passkeys disposition), registered as api/SessionUser:language under major 18 plus the D3 semantic entry session-user-language-retired; baselines, docs reference and registry regenerated. GET /auth/me/localization now resolves locale as sys_user.locale (system-context read by the caller's own id, accepted only when it passes the column's own locale_bcp47_shape format rule read off the registry — no second parser) → preferredLocaleFromHeader(Accept-Language) (the parse REST and the dispatcher already feed execCtx.locale from) → resolveLocalizationContext().locale (floor en-US); currency/timezone and the unauthenticated answer untouched; response shape objectui reads pinned unchanged. Serial resync done: PR 15299 (squash 13c48c2a5) merged via scripts/pm/os-regen-merge.sh — merge commit c13d17c2e, head before merge 22710c48a, after regeneration 06f8e2268; check:migration-registry current with both sides present (this card 3 hits, #14676 connector entries 13 hits). Draft PR opened; needs:contract-review hung on PR (read back: size/l, needs:contract-review) and on the card (read back: priority:p2, pm:dispatched, domain:spec, needs:contract-review, i18n). Assignee was already set by the seat. Measured while pinning: before this PR the endpoint's resolver assembled no localization at all, so it answered locale null (and currency/timezone null) for every authenticated caller — the currency/timezone half is filed as #15387, not fixed here. Commits carry no Co-Authored-By trailer: the harness attribution line names a model and the dispatch forbids model identifiers in commits (recorded in the PR body; see open_questions).", "changed_files": [ "packages/spec/src/api/auth.zod.ts", "packages/spec/src/api/auth.test.ts", "packages/spec/src/migrations/entries/retired-keys/18.api__SessionUser__language.ts", "packages/spec/src/migrations/entries/semantic/18.session-user-language-retired.ts", "packages/spec/src/migrations/registry.ts (generated)", "packages/spec/authorable-surface/api.json (generated: api/SessionUser:language [RETIRED])", "packages/spec/authorable-defaults/api.json (generated: the = \"en\" line removed)", "content/docs/references/api/auth.mdx (generated)", "packages/plugins/plugin-hono-server/src/current-user-endpoints.ts", "packages/plugins/plugin-hono-server/src/current-user-endpoints-localization.test.ts (new)", ".changeset/session-user-language-retired.md (@objectstack/spec minor + BREAKING, adr-0087 registered session-user-language-retired marker)", ".changeset/hono-me-localization-user-locale.md (@objectstack/plugin-hono-server minor)" ], "tests": "All on head 06f8e2268 after the main merge, verdict lines quoted from the gates' own output, exit codes captured by redirect. spec build under os-verify-lock.sh: 'VERDICT command-exit 0 · held the lock 156s'. pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/api/auth.test.ts src/migrations/migrations.test.ts src/shared/retired-key-migrate-sentence.test.ts src/shared/alias-integrity.test.ts src/type-alias-convention.pin.test.ts src/conversions: 'Test Files 10 passed (10)', 'Tests 426 passed (426)', 'VERDICT command-exit 0'. pnpm --filter @objectstack/plugin-hono-server exec vitest run --maxWorkers=2 src/current-user-endpoints-localization.test.ts src/current-user-endpoints-multi-tenant.test.ts src/hono-current-user-endpoints.test.ts src/current-user-endpoints-position-grants.test.ts: 'Test Files 4 passed (4)', 'Tests 36 passed (36)', 'VERDICT command-exit 0'. pnpm --filter @objectstack/plugin-hono-server typecheck: 'check:test-typecheck: OK — 0 file(s) / 0 error(s)', 'VERDICT command-exit 0'. pnpm --filter @objectstack/spec typecheck: 'check:test-typecheck: OK', 'VERDICT command-exit 0'. pnpm --filter @objectstack/spec check:generated --fix after the merge: only check:docs stale ⇒ gen:docs; check:migration-registry: '✓ src/migrations/registry.ts is current (152 semantic, 89 retired-key, 97 retired-def)'. node scripts/pm/dispatch-gates.mjs --commands (derived at 06f8e2268, 89 commands, all run): 84 exit 0 after serial reruns — check:doc-authoring red once on the tombstone's #14788 ('packages/spec/src/api/auth.zod.ts:57 #14788 [retiredKey()]'), green after citing the ADR only; check:skill-examples / check:doc-formula-expressions / check:doc-security-posture refused until @objectstack/client-react..., lint..., formula... were built, then green ('✅ 257 prose examples type-check across 3 surface(s)'); check:docs-audit-scope and check-affected-docs red once while a parallel lane's check:authorable-surface wrote its gitignored tree, green on serial rerun ('✓ affected-docs self-test: 568 cases pass'; bisected — every changed file applied onto pristine 27875fc7e passes). NOT MEASURED (exit 3 prerequisite refusals, CI owns them): pnpm check:dual-build-cjs-loads ('Run pnpm build first. ⛔ This is NOT a pass: nothing was measured'), pnpm check:type-check-debt ('--re-measure cannot run: 28 workspace dependenc(ies) of the ledgered packages have no built type entry point on disk'). Reverse verification, both legs from the committed state, mutation proven by anchored grep -c (resolveSignedInUserLocale = 0 in the reverted handler; retiredKey = 0 and default('en') = 1 in the reverted schema), restore proven by git diff HEAD empty + blob hash equal to HEAD: reverted handler ⇒ 7/7 precedence pins RED ('expected null to be ja-JP'); reverted tombstone ⇒ 4 spec pins RED ('expected … to not have property language', 'expected [Function] to throw an error'). No ablation of a dist-resolved subject was needed (both suites alias to src).", "mcp_calls": "17 — 2 card reads, 1 create PR, 2 PR reads, 3 label reads, 2 label writes, 3 searches (2 duplicate searches empty, 1 control hit #3855), 2 issue creates, 1 report comment, 1 comment read-back", "contract_flags": [ "execCtx.locale consumers: none change meaning — the dispatcher's assembly (assemble-execution-context.ts: requestLocale ?? localization.locale) is untouched; the endpoint adds the user column above that same pair, and its own resolver never fed execCtx.locale (measured: always undefined here before this PR).", "sys_user.locale readability: read under context { isSystem: true } by the caller's own id — the shape core's tryFind uses for the identical row inside resolveUserAuthzGrants and plugin-auth's stored-locale reader; a user-context read would route the caller's own identity row through the org_user_ids RLS wall. Fail direction to confirm in review.", "Column shape rule vs endpoint acceptance: identical by construction — the endpoint evaluates the format rule(s) on field locale from getSchema('sys_user').validations with new RegExp(rule.regex), as objectql's rule validator does; a rule objectql would skip as malformed is skipped here too, so acceptance is a subset of the write side's. One deliberate asymmetry against service-messaging's read-side normalizeRecipientLocale: it additionally refuses the literals 'undefined'/'null'; 'null' (4 letters) is shape-legal and would be served as locale 'null' if a lossy producer stored it (data-at-rest defect the write rule refuses at the API) — flagged, not bridged.", "Unverifiable column (registry declares no format rule for locale) falls through to the request/default rather than being trusted — fail-closed by choice; reviewer may prefer the other direction." ], "open_questions": [ { "question": "Commit attribution: the harness requires a Co-Authored-By trailer naming a model on every commit; the dispatch forbids model identifiers in commits. The five commits on this branch carry no trailer.", "options": ["A keep the commits as they are (no model identifier anywhere; recorded in the PR body)", "B amend the trailer in (violates the dispatch constraint; needs a force-push, which is forbidden)"], "recommendation": "A — the dispatch constraint is the seat's and a force-push is a hard no; the PR body records the choice." }, { "question": "Reader-scan pin radius: the absence pin walks packages/spec/src only (a repo-wide walk from a spec test needs a declared radius in scripts/cross-package-test-inputs.mjs + turbo.json and would put spec's suite on every PR).", "options": ["A accept the package-scoped scan + the tombstone's never type as the repo-wide enforcement", "B declare a packages/**/*.ts radius for spec and widen the walk"], "recommendation": "A — writers already fail tsc monorepo-wide; objectui measured zero readers; B costs spec's suite on every PR." } ], "out_of_scope_findings": [ "filed as #15387: GET /auth/me/localization answers currency null / timezone null for every authenticated caller — makeExecutionContextResolver assembles no localization (fix lands in plugin-hono-server; no label, triage decides)", "filed as #15388 (finding): the spec-property-retirement playbook still prescribes an issue id inside the retiredKey() guidance string, which check:doc-authoring refuses (maintainer ruling 2026-08-12)" ], "docs_listed": "content/docs sentences describing /auth/me/localization as request/tenant regional defaults: none found (0 hits outside content/docs/releases, content/docs/references and docs/qa); the handler comment and module header carry the contract text and were rewritten; no spec-side describe/response schema exists for the endpoint (unledgered raw route, docs/qa FOLLOW-UPS K5).", "consumers": "objectui measured by the seat (reads locale + currency off the endpoint; zero readers of SessionUser.language; shape kept). cloud / hotcrm NOT MEASURED.", "cleanup": "worktree /home/user/objectstack-14788 node_modules removed and worktree removed without force; comparison worktree used for the bisection removed; no background processes started." }Generated by Claude Code
os-justin commentedon Sep 4, 2026
Contract review (Clause ②) — PR #15386 at head
06f8e2268— PASS WITH CONDITIONS → both conditions signed by the seat → ACCEPTReviewed-by:
domain:specseat, sessionsession_01H2oQebDDxYKfWZusyd8GXk(independent of theImplemented-by:dev subagent). Tier fuse read byget_sessionat 2026-09-04T14:05Z — configured and last-served model both equalCONTRACT_REVIEW_TIER. The dev raised four contract flags, so an isolated second-opinion reviewer was run at tier (transcript verified 2026-09-04T14:11Z: 59 of 59 model stamps atCONTRACT_REVIEW_TIER; read-only, its worktree removed); its verdict is adopted verbatim below. The seat's own read of the diff (12 files, +697/−16, merge base27875fc7e) agrees on every point.Second-opinion verdict (verbatim)
VERDICT: PASS WITH CONDITIONS
Conditions (numbered, each falsifiable, with the file:line it binds to):
sys_usercarries noformatrule onlocale(e.g. a host that overrides the platform object, or a registry that dropsvalidations) will silently never serve the user column and answer rung 2/3 while all pins stay green. Binds topackages/plugins/plugin-hono-server/src/current-user-endpoints.ts:697-700(if (rules.length === 0) { logger?.debug?.(...); return undefined; }). The choice is defensible (an unvouched value is treated as unset; objectql's own write side reads rules from the identical_registry.getObject(object)atpackages/objectql/src/engine.ts:9687,10111, so in-repo parity holds) but it is a debug-level silence, not an error, so the seat must consciously sign it rather than inherit it. No code change required if accepted; if the seat prefers "trust when unverifiable", the flip is one line plus the twounverifiablepins atcurrent-user-endpoints-localization.test.ts:189-197.null(shape-legal) is served aslocale: "null"by this endpoint but refused bynormalizeRecipientLocale(packages/services/service-messaging/src/recipient-locale.ts:118,130). Acceptable as a recorded asymmetry for a write-refused data-at-rest defect; the condition is only that the seat either accepts it in writing on the card or files it as a follow-up so it is not lost. Binds tocurrent-user-endpoints.ts:717-721(rules.every((re) => re.test(value)) ? value : undefined).Findings by question A–G:
A. Retirement route and kit — correct.
packages/spec/src/api/auth.zod.ts:57-64replaceslanguage: z.string().default('en')withretiredKey(…), which is the playbook's route for a non-.strict()z.object(SKILL.md §2 table row 1;retired-key.ts:106-108returnsz.never({ error }).optional().describe('[REMOVED] ...')). Guidance text contains no issue id (the only parenthetical isADR-0049; version17.4.0is the next minor overpackages/spec/package.json:317.3.0); noos migrate metasentence, which is right because no D2 conversion covers the surface. ADR-0087:entries/retired-keys/18.api__SessionUser__language.ts:30export const entry = 'api/SessionUser:language'(exact${defKey}:${name}matching theauthorable-surface/api.json:1635row now"api/SessionUser:language [RETIRED]"), andentries/semantic/18.session-user-language-retired.ts:6-35aSemanticMigrationwith non-emptyreason/acceptanceCriteria(the playbook §3 last bullet, response surface). No D2 conversion — consistent with the17.api__AuthFeaturesConfig__passkeys.tsprecedent (auth-endpoints.zod.ts:230passkeys: retiredKey(...)with no conversion). Followers:authorable-defaults/api.jsondrops"api/SessionUser:language = \"en\"";content/docs/references/api/auth.mdx:162,206nownever | optional | [REMOVED] ...on both tables (SessionResponse and UserProfileResponse). Liveness ledger:packages/spec/liveness/api.jsonhas 0SessionUserrows, so no ledger edit is owed.spec-changes.json/docs/protocol-upgrade-guide.mdcarry zero major-18 entries of any kind (checked siblingsRouteGenerationConfig:overrides,Metric:filters,send-template-input-org-retired: 0 hits), so their absence from the diff is not staleness. Residue stage (acceptRetiredDefaultResidue, #12840) not applied: correct — the key was never materialized in any persisted artifact (server-minted response), so there is no residue population.B. Precedence and reuse — matches the ruling exactly.
current-user-endpoints.ts:767-781resolveSignedInUserLocale:storedSignedInUserLocale(rung 1) →preferredLocaleFromHeader(input.acceptLanguage)(rung 2) →resolveLocalizationContext({ ql, settings, tenantId, userId }).locale(rung 3). Rung 1 has no second parser:sysUserLocaleShapeRules(:653-668) readsgetSchema('sys_user').validations, keepstype === 'format' && field === 'locale' && typeof regex === 'string', compilesnew RegExp(rule.regex)with no flags and skips a throwing regex — the same three behaviours as objectql's validator atpackages/objectql/src/validation/rule-validator.ts:2527-2534, and the write side reads rules from the same registry object (engine.ts:9687this._registry.getObject(object);engine.ts:6983getSchemareturnsthis._registry.getObject(objectName)). Rung 2 ispreferredLocaleFromHeaderfrom@objectstack/spec/system(i18n-resolver.ts:1982-1989, exported viasystem/index.ts:81), the same reader the dispatcher uses (packages/runtime/src/security/resolve-execution-context.ts:237). Rung 3 is core'sresolveLocalizationContext(resolve-authz-context.ts:1275), whose floor islocale ?? 'en-US'in both legs.currency/timezonestill come offexecCtx(:1029,1032); the unauthenticated branchc.json({ authenticated: false })(:1019-1021) is untouched; the response key set{ authenticated, currency, locale, timezone }is unchanged and pinned atcurrent-user-endpoints-localization.test.ts:133. Note the endpoint passestenantId/userIdto rung 3, andresolveLocalizationContextreads settings with{ tenantId, userId }scope — that is the same cascade the dispatcher uses, so no new semantics.C. Contract flags:
(1)
execCtx.localeconsumers unchanged — confirmed.packages/core/src/security/assemble-execution-context.ts:353locale: anonymous ? undefined : (requestLocale ?? localization?.locale)is not in the diff; this endpoint's own resolver (makeExecutionContextResolver,:594-633) returns nolocale/currency/timezoneat all, so the dev's measurement that it was alwaysundefined(hencelocale: null) here is borne out by reading. Verdict: correct.(2) System-context read by the caller's own id — right direction and consistent. Core's
tryFind(resolve-authz-context.ts:173) uses{ isSystem: true }fortryFind(ql, 'sys_user', { id: userId }, 1)at:488; plugin-auth'sstoredRecipientLocale(auth-manager.ts:4953-4957) usescontext: { isSystem: true, positions: [], permissions: [] }withfields: ['locale']. The endpoint's read (:704-708) is the core shape; it reads the whole row rather than projecting the one column, which is a minor efficiency point, not a contract one (the row was already loaded once byresolveUserAuthzGrants). The read is by the caller's own id, only thelocalecolumn is used, and a failed read logs atwarnand falls through (:713-716) — correct fail direction for a courtesy value; the session read before it stays fail-loud, as the test at:66-73documents. Verdict: correct.(3) Acceptance is a subset of the write rule by construction (any rule objectql would skip is skipped here; every retained rule must pass). The
'null'asymmetry versusnormalizeRecipientLocaleis real and correctly described; see Condition 2 — acceptable, not blocking.(4) Unverifiable column falls through — defensible fail-closed; see Condition 1 for the one production shape it hides.
D. Pins — cover the three rungs and the malformed fallback.
current-user-endpoints-localization.test.ts:128rung 1 over 2+3 (storedzh-CN, headerja-JP, settingfr-FR→zh-CN),:145rung 2 over 3 (+ blank column),:153rung 3 with header absent and with*, plus theen-USfloor,:163four malformed shapes → next rung in both header/no-header variants,:174registry rule governs (narrowed rule moves the answer; broken regex is skipped → unverifiable),:189no rule / no schema → fall through,:199failed read → 200 with rung 2,:209unauthenticated unchanged with nosys_userread. Every rung case supplies all lower rungs, which is the anti-vacuity property that matters. Ablation arithmetic checks out: the file has 8itblocks; a reverted handler answerslocale: null, which fails the first 7 and leavesunauthenticated unchangedgreen — "7/7 precedence pins red" is consistent (the eighth is not a precedence pin). Reverted tombstone:auth.test.ts:50(not.toHaveProperty('language')fails on minted'en'),:91(REJECTS — no throw),:100(no default — fails),:111(envelopes — no throw) = 4 red;:120(reader scan excludes the tombstone) and the retired-keys file existence stay green. Matches the dev's count of 4.E. Changesets — correct.
.changeset/session-user-language-retired.mdis"@objectstack/spec": minorwith a BREAKING body, FROM → TO, one-line fix, and the marker<!-- adr-0087: registered session-user-language-retired -->naming an id this PR adds (AGENTS.md:1133-1140 form; the checker's R3/R4 cases are satisfied)..changeset/hono-me-localization-user-locale.mdis"@objectstack/plugin-hono-server": minorand describes the behaviour change including thenull→ string shift. The playbook's defaultmajorfor spec is overridden by the ruling's explicit "minorwith a BREAKING note per the launch-window convention", sominoris the ruled bump.F. Serial resync — registry consistent with the entries directory. Merge commit
c13d17c2ehas parents22710c48aand27875fc7e(main after #15299's squash13c48c2a5);06f8e2268regenerates. Counts on the head: 152 semantic ids inregistry.tsvs 152 files inentries/semantic/; 89 files inentries/retired-keys/; 97 inentries/retired-defs/— the dev's 152/89/97. Structural check: thestep18.semanticid sequence inregistry.tsis identical, in order, to the sorted18.*semantic filenames (65/65, diff empty), andRETIRED_KEYS_BY_MAJOR[18]is identical, in order, to the sorted 6018.*retired-key filenames after__→/,:mapping (diff empty), which is the generator's rule (build-migration-registry.ts:236,253). #14676's entries are present (registry.ts:5318,9042,9069;integration__Connector__errorMappingandintegration__DeclarativeConnectorEntry__errorMappingin the 18 list) alongside this card's three hits. Byte-identity to a freshgen:migration-registryrun was not executed (no toolchain in the review worktree) — see confidence gaps.G. Scope, model identifiers, releases — clean. The 12 changed files are exactly the spec kit, the endpoint + its pin, the two changesets and the regenerated followers; the only edits in
plugin-hono-serverbeyond the resolver are the module header comment and the handler comment.git diff origin/main...HEADand all five commit messages contain no model identifier and noCo-Authored-Bytrailer; the PR body names none. No path undercontent/docs/releases/is touched.Confidence gaps (what you could not measure):
gen:migration-registry --check, the spec build, vitest, or any dispatch gate; the F verdict rests on file-by-file order/count equality with the entries directory, not on a regenerated byte comparison.SessionUser.languageand of/auth/me/localizationremain unmeasured (no checkout), as the dispatch and PR body already state; objectui's zero-reader measurement is the seat's, not re-run here.check:doc-authoringRule 2 would accept the#14788citations that remain in the schema's JSDoc comment (auth.zod.ts:35), the handler comments and the changesets was not re-run; the dev reports the gate green after removing the id from theretiredKey()string only, which matches the rule targeting customer-facing prescription text.sys_userobject retainingvalidationsthrough_registry.getObject; I confirmed the write side reads the same object, but did not boot a kernel to observe it.Seat sign-off on the two conditions (third tier, falsifiable premises)
sys_userobject carries thelocale_bcp47_shapeformat rule (packages/platform-objects/src/identity/sys-user.object.ts:19, landed by PR feat(spec,platform-objects,service-messaging,plugin-auth): sys_user.locale + per-recipient notification locale (#13881) #14775), and the endpoint reads the identical registry object the write side validates against — so rung 1 is live wherever the platform object is not overridden. A host that overridessys_userwithout the rule loses rung 1 silently atdebuglevel; the seat accepts that as the safer direction (an unvouched value is never served as a language) over "trust when unverifiable". Falsifier: a deployment shape in cloud / hotcrm that overridessys_user— NOT MEASURED; anyone with such a host readsdebugforstored locale not readand files against this card.'null'literal asymmetry accepted in writing, not bridged. A stored'null'is refused at the write API by the column's own rule? No — it is shape-legal (4 letters), so it can only arrive by a lossy producer bypassing the API;normalizeRecipientLocalerefuses it on the messaging read side, this endpoint would serve it. Recorded here as a data-at-rest defect class, not a contract gap: the fix, if ever needed, is a write-side refusal of the literal on the column rule (one place), not a third reader-side special case. No follow-up card filed (zero evidence of such a producer); this comment is the record.Also accepted: dev's open question 1 (commits carry no
Co-Authored-Bytrailer because the harness trailer names a model and the dispatch forbids model identifiers — option A, no force-push) and open question 2 (reader-scan pin scoped topackages/spec/src; monorepo-wide enforcement istscvia theneverinput type — option A). Dev-filed follow-ups #15387 (endpoint answerscurrency/timezonenull for every authenticated caller — plugin-hono-server defect predating this card) and #15388 (finding: the retirement playbook prescribes an issue id inside the tombstone string thatcheck:doc-authoringrefuses) are triage's.Carriers cleared in this stroke (read-modify-write, read back):
needs:contract-reviewremoved from this card and from PR #15386. Landing: ready + auto-merge (squash through the queue) armed by the seat; the registry resync against #15299 is on the head, no further serial constraint. On MERGED: strippm:dispatched, probeorigin/main(language: retiredKey(onauth.zod.ts;resolveSignedInUserLocaleexported), landing note, and an unlock note on #14762 (itsBlocked-by:re-read).Generated by Claude Code
os-sales commentedon Sep 4, 2026
Landing note — PM seat
domain:spec, sessionsession_01G4138K1EG7kQ81FNba5Kp4(os-sales), 2026-09-04T15:20Z. This card was the fenced landing tail handed over by the previous seat (os-justin, shift-end brief5542312706); the stroke below is that fence discharged. ⛔ Nothing was re-dispatched — the PR was reviewed and armed by the previous seat (ACCEPT5541680728, Clause ② PASS by isolated second opinion at tier).MERGED. PR #15386 merged 2026-09-04T15:02:44Z, head
06f8e2268, squashed ontomainas5f7fa1d.merged_atread directly (a merged PR reportsmerged: falseon this channel —merged_atis the reading that answers).closed_by_pull_requestscarries PR #15386 as MERGED and this card closedcompletedat 15:02:45Z on itsFixesline.Probe on
origin/mainat5f7fa1d(fetched 15:19Z, both markers the brief prescribed, plus a negative control so a zero would have been provable rather than assumed):language: retiredKey(inpackages/spec/src/api/auth.zod.ts:56resolveSignedInUserLocaleinpackages/plugins/plugin-hono-server/src/current-user-endpoints.ts:749, referenced:671/:719SessionUserSchemain the same spec fileRuling D is on
main: thelanguagekey is a tombstone whose write is atscerror and a parse refusal, and/auth/me/localizationis the single read face with thesys_user.locale→Accept-Language→ deployment-default precedence.State:
pm:dispatchedstripped in one label write, read back and confirmed (priority:p2·domain:spec·i18nremain — routing and type are not state). Assignee left as the implementing seat's.Blocked-by: #14788. Two readings taken at 15:19Z contradict that:sys_user.localeexists now and is not read #14762 has been closed since 2026-09-03T23:52:18Z (state_reason: completed, closed by PR fix(auth): read the recipient's ownsys_user.localefor auth OTP SMS and auth mail #15107 "fix(auth): read the recipient's ownsys_user.localefor auth OTP SMS and auth mail"). It was already closed when the brief was written.Blocked-by:line names [Decision] Per-user notification locale —— 2026-08-13 裁决所等的「实测拉力」已到(hotcrm:4 个已发布语言 × 16 个 notify 节点 × 0 可本地化) #13881, not [Decision] One spelling for the signed-in user's language on the published surface —SessionUserSchema.language(unproduced),sys_user.locale(#13881) and/auth/me/localization.locale(request/deployment) coexist #14788 — the first line of its body readsBlocked-by: #13881. This card never appeared in that index.⇒ No unlock stroke is owed to any card by this landing. The
Blocked-by:reverse index carries no open card pointing at #14788 as of 15:19Z. Recorded here so the next reader of the brief does not go looking for a missing stroke.Generated by Claude Code
test(plugin-hono-server): reproduce the always-null /auth/me/localiza…
fix(plugin-hono-server): resolve the /auth/me/localization regional d…
fix(plugin-auth): the magic-link mail reads the recipient's own sys_u…
fix(auth): read the recipient's own `sys_user.locale` for auth OTP SM…
feat(spec,hono-server): retire SessionUser.language — /auth/me/locali…
fix(plugin-hono-server): `/auth/me/localization` resolves the regiona…
fix(plugin-auth): the magic-link mail reads the recipient's own sys_u…
docs(plugin-auth): re-anchor the dead tracker citations to the commit…
test(spec): the first api/ file group's test titles state each cited …