Skip to content

[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

Closed

Description

@claude

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.

背景

登录用户的「语言」在公开面上现在有三种拼法并存:

  1. SessionUserSchema.language(packages/spec/src/api/auth.zod.ts:36,z.string().default('en'),描述 "Preferred language")—— 已发布的 session 契约,但全仓没有生产者也没有消费者(只有自己的 spec 测试);
  2. 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),通知投递按收件人读它;
  3. GET /auth/me/localization 返回的 locale(packages/plugins/plugin-auth/src/current-user-endpoints.ts ~:876-888)—— 取的是请求/部署的 locale,不是用户列;objectui 曾用它播种 UI 语言(console: the tenant's server-side locale (/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-side locale (/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 的现有消费者对「用户列优先」的预期。

裁后我会怎么执行(你不用管)

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-side locale (/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

  1. added
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Sep 3, 2026
  2. huangyiirene commented on Sep 3, 2026

    @huangyiirene
    Collaborator

    Triage — sweep ③ lane repair. Added priority:p2 · domain:spec · i18n; needs-user-decision kept. ⛔ 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): SessionUserSchema is declared in packages/spec/src/api/auth.ts — confirmed via its test at packages/spec/src/api/auth.test.ts:4, which imports it from there. Whichever spelling wins, the published-surface half of it lands in packages/spec. ⇒ domain:spec.

    p2, not p3: 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's Accept-Language for 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.

    ⚠️ Two facts a ruling here will want, measured this round and recorded so nobody re-derives them:


    Generated by Claude Code

  3. os-project-manager commented on Sep 3, 2026

    @os-project-manager
    Collaborator

    Maintainer ruling recorded — D: retire SessionUserSchema.language; /auth/me/localization.locale becomes the one read face, projecting the user column

    Director 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/localization keeps its locale and 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.locale when set → the request's Accept-Language → the deployment default. Options A (leave the dead field), B (add locale beside language — the shape #13881 excluded) and C (breaking rename) are not taken; 不渐进 — no dual-spelling window.

    Execution: domain:spec lane (spec half) with the plugin-auth endpoint in the same PR, after PR #14775 (#13881) has merged — the column must exist on main first. 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 of SessionUser.language and of /auth/me/localization in objectui, cloud and hotcrm — any hit is changed in the same stroke (objectui by a cross-lane card with Blocked-by: this one), ⛔ not bridged. Changeset: @objectstack/spec minor with a BREAKING note per the launch-window convention; endpoint pin asserting the three-step precedence; #14762 (pm:blocked) re-reads its Blocked-by: once this lands.

    State transition, same stroke: needs-user-decision → pm:queue. Ledger: director seat post #12708, summon #11.


    Generated by Claude Code

  4. added
    pm:queueReady for the PM dispatch loop
    and removed
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Sep 3, 2026
  5. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Sep 4, 2026
  6. self-assigned this
    on Sep 4, 2026
  7. os-justin commented on Sep 4, 2026

    @os-justin
    Collaborator

    Claim: domain:spec execution seat, session session_01H2oQebDDxYKfWZusyd8GXk, branch claude/issue-14788-retire-session-user-language. Labels pm:queue → pm:dispatched written 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/main c4d1354e3): PR #14775 (#13881) is merged — squash 1401ae7c2 is on origin/main, and packages/platform-objects/src/identity/sys-user.object.ts declares the BCP-47 sys_user.locale column (:6, :19-21). The ruling's "after PR #14775 has merged" gate is open.

    Surface (read on origin/main c4d1354e3):

    • packages/spec/src/api/auth.zod.ts:36 — language: z.string().default('en') still on SessionUserSchema; card's re-check reproduces (zero producers/consumers outside its own test).
    • Path correction: the endpoint is NOT in packages/plugins/plugin-auth/src/current-user-endpoints.ts (no such file on main). GET /auth/me/localization is served by packages/plugins/plugin-hono-server/src/current-user-endpoints.ts:871-888, returning execCtx.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-server is domain:cli) executed on the spec seat by the maintainer's ruling — the claim declares it here as the full file surface.
    • Accept-Language reading already exists in packages/plugins/plugin-auth/src/auth-manager.ts:1069-1091 (authEmailLocaleFromRequest); the dev decides whether the endpoint reuses it or the request-context resolver that feeds execCtx.locale.
    • ADR-0087 registration: retired-keys entry for SessionUser.language ⇒ regenerates packages/spec/src/migrations/registry.ts.

    Consumer measurement (objectui origin/main 0b24d7f, 2026-09-04T12:44Z): git grep -n -E '\.language\b|SessionUser|auth/me/localization' origin/main -- packages apps — zero readers of SessionUser.language (all .language hits are i18next / script-body language, unrelated); /auth/me/localization is read by apps/console/src/languageSeed.ts:84 (cacheLanguageSeed(json?.locale)), LocalizationFetchProvider.tsx:3 and AppContent.tsx:233 — they consume locale (and currency), 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 --tier reports 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 hangs needs:contract-review on PR + card, in-seat review before ready.

    Serial check (2026-09-04T12:44Z): no open PR touches auth.zod.ts or plugin-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 on main before ready.


    Generated by Claude Code

  8. os-justin commented on Sep 4, 2026

    @os-justin
    Collaborator

    Dispatch — round R3 slot refill, 2026-09-04T12:47Z

    Seat domain:spec, session session_01H2oQebDDxYWfWZusyd8GXk, os-dev subagent at CONTRACT_REVIEW_TIER, branch claude/issue-14788-retire-session-user-language, tree origin/main c4d1354e3 (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:

    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/localization keeps its locale and 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.locale when set → the request's Accept-Language → the deployment default. Options A (leave the dead field), B (add locale beside language — the shape #13881 excluded) and C (breaking rename) are not taken; 不渐进 — no dual-spelling window.

    ① Task

    1. Retire SessionUserSchema.language (packages/spec/src/api/auth.zod.ts:36): remove the key; ADR-0087 D2/D3 registration as the spec-property-retirement playbook prescribes for this schema's strictness (a retiredKey() tombstone if the schema is non-strict, otherwise the retired-keys entry alone), entries/retired-keys for major 18, gen:migration-registry, check:generated --fix; fix auth.test.ts:48/68 to 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/src must still hit only :36 and Accept-Language-class uses — a producer appearing since means STOP and report.
    2. Make GET /auth/me/localization the one read face — handler at packages/plugins/plugin-hono-server/src/current-user-endpoints.ts:871-888 (⚠️ the card's plugin-auth/src/current-user-endpoints.ts path is stale; the ruling's "plugin-auth endpoint" is this endpoint). locale resolves sys_user.locale when set (BCP-47 shape-checked, reuse normalizeRecipientLocale's acceptance or the column's locale_bcp47_shape rule — never a second parser) → the request's Accept-Language (reuse the reader in packages/plugins/plugin-auth/src/auth-manager.ts:1069-1091 or the request-context resolver that already feeds execCtx.locale; do not write a third one) → the deployment default (localization.locale setting / II18nService.getDefaultLocale(), whichever execCtx.locale already derives from). currency / timezone unchanged. Unauthenticated behaviour unchanged. Pin the three-step precedence in the endpoint's test file (three cases + the malformed-column fallback).
    3. Contract text: the endpoint's spec-side describe / response schema (find it: git grep -n "localization" origin/main -- packages/spec/src) says locale is the user's stated preference first; content/docs sentences that describe /auth/me/localization as "the request's / tenant's regional defaults" are rewritten (list them in the PR body).
    4. Consumers measured by the seat (claim comment above): objectui reads locale off this endpoint (apps/console/src/languageSeed.ts:84, LocalizationFetchProvider.tsx), zero readers of SessionUser.language — keep the response shape, and objectui needs no card. cloud / hotcrm NOT MEASURED — state that in the PR body verbatim.
    5. Changesets: @objectstack/spec minor + BREAKING note (SessionUser.language removed; launch-window convention) with <!-- adr-0087: registered … -->; @objectstack/plugin-hono-server minor (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:generated clean; check-adr-0087-registration green.

    ② Constraints

    ③ Report

    Return the os-dev JSON report (status, PR + head SHA, changed files, commands with real output, contract_flags, open_questions, findings). Contract flags: any consumer of execCtx.locale whose meaning changes, any place where sys_user.locale is 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;块 md5 22f2339f0acb64cdb50c7adc9db681c3,SKILL.md ## 升级与决策 at origin/main c4d1354e3)

    每个方案必须沿四条固定评估轴分析,这是决策分析的核心原则,不是可选项:

    • 实际业务需求 — 它服务的是真实存在的业务场景,
      还是投机性能力面?判据要求实测(谁在写这个键、谁在读、
      示例应用与真实部署的用法),「读起来像有用」不作数。这条轴会改变结论,不是陪衬。
    • 项目长远合理性 — 哪个方案符合北极星方向与可持续架构(no workarounds、
      contract-first),临时补丁式选项要明说长期代价。
    • 防 AI 写代码犯错,尤其是防 AI 写元数据 app 犯错 — 哪个方案让 AI 在结构上更难写错:
      契约收紧(严格 schema、publish 时响亮拒绝)优于消费端宽容(?? 回退、静默容错)——
      宽容恰是 AI 批量犯错被掩盖的温床;声明即强制,绝不让 AI 声明一个运行时不兑现的能力。
    • 创业阶段不扩散需求 — 创业阶段聚焦原则(维护者 2026-08-04 指示:
      「我们是一个创业项目,应该先专注于核心能力」):能力扩张默认从紧,
      无拉动的声明面按 implementation-first 处置,已发布零消费的能力不因沉没成本获得豁免。
      过渡也从紧 —— 创业阶段不渐进(维护者 2026-08-27 裁,逐字:「项目在创业阶段,
      用户也很少,短期不考虑渐进。」):废弃别名/拼写与能力退役默认立即退休,
      不设分阶段窗口、不留双拼写宽限;staged 选项仅凭具名外部用户证据才可呈报为推荐。

    推荐意见必须基于这四条轴给出理由;四轴冲突时如实呈现权衡,
    交维护者拍板。


    Generated by Claude Code

  9. os-justin commented on Sep 4, 2026

    @os-justin
    Collaborator

    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

  10. 3 remaining items

  11. os-justin commented on Sep 4, 2026

    @os-justin
    Collaborator

    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

  12. os-justin commented on Sep 4, 2026

    @os-justin
    Collaborator

    Contract review (Clause ②) — PR #15386 at head 06f8e2268 — PASS WITH CONDITIONS → both conditions signed by the seat → ACCEPT

    Reviewed-by: domain:spec seat, session session_01H2oQebDDxYKfWZusyd8GXk (independent of the Implemented-by: dev subagent). Tier fuse read by get_session at 2026-09-04T14:05Z — configured and last-served model both equal CONTRACT_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 at CONTRACT_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 base 27875fc7e) agrees on every point.

    Second-opinion verdict (verbatim)

    VERDICT: PASS WITH CONDITIONS

    Conditions (numbered, each falsifiable, with the file:line it binds to):

    1. Before ready, the seat records one explicit acceptance of contract flag (4), the fail-closed direction, since it is the one design choice with a production consequence no test can see: a deployment whose registered sys_user carries no format rule on locale (e.g. a host that overrides the platform object, or a registry that drops validations) will silently never serve the user column and answer rung 2/3 while all pins stay green. Binds to packages/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) at packages/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 two unverifiable pins at current-user-endpoints-localization.test.ts:189-197.
    2. Contract flag (3) stays flagged-not-bridged: a stored literal null (shape-legal) is served as locale: "null" by this endpoint but refused by normalizeRecipientLocale (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 to current-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-64 replaces language: z.string().default('en') with retiredKey(…), which is the playbook's route for a non-.strict() z.object (SKILL.md §2 table row 1; retired-key.ts:106-108 returns z.never({ error }).optional().describe('[REMOVED] ...')). Guidance text contains no issue id (the only parenthetical is ADR-0049; version 17.4.0 is the next minor over packages/spec/package.json:3 17.3.0); no os migrate meta sentence, which is right because no D2 conversion covers the surface. ADR-0087: entries/retired-keys/18.api__SessionUser__language.ts:30 export const entry = 'api/SessionUser:language' (exact ${defKey}:${name} matching the authorable-surface/api.json:1635 row now "api/SessionUser:language [RETIRED]"), and entries/semantic/18.session-user-language-retired.ts:6-35 a SemanticMigration with non-empty reason / acceptanceCriteria (the playbook §3 last bullet, response surface). No D2 conversion — consistent with the 17.api__AuthFeaturesConfig__passkeys.ts precedent (auth-endpoints.zod.ts:230 passkeys: retiredKey(...) with no conversion). Followers: authorable-defaults/api.json drops "api/SessionUser:language = \"en\""; content/docs/references/api/auth.mdx:162,206 now never | optional | [REMOVED] ... on both tables (SessionResponse and UserProfileResponse). Liveness ledger: packages/spec/liveness/api.json has 0 SessionUser rows, so no ledger edit is owed. spec-changes.json / docs/protocol-upgrade-guide.md carry zero major-18 entries of any kind (checked siblings RouteGenerationConfig: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-781 resolveSignedInUserLocale: 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) reads getSchema('sys_user').validations, keeps type === 'format' && field === 'locale' && typeof regex === 'string', compiles new RegExp(rule.regex) with no flags and skips a throwing regex — the same three behaviours as objectql's validator at packages/objectql/src/validation/rule-validator.ts:2527-2534, and the write side reads rules from the same registry object (engine.ts:9687 this._registry.getObject(object); engine.ts:6983 getSchema returns this._registry.getObject(objectName)). Rung 2 is preferredLocaleFromHeader from @objectstack/spec/system (i18n-resolver.ts:1982-1989, exported via system/index.ts:81), the same reader the dispatcher uses (packages/runtime/src/security/resolve-execution-context.ts:237). Rung 3 is core's resolveLocalizationContext (resolve-authz-context.ts:1275), whose floor is locale ?? 'en-US' in both legs. currency / timezone still come off execCtx (:1029,1032); the unauthenticated branch c.json({ authenticated: false }) (:1019-1021) is untouched; the response key set { authenticated, currency, locale, timezone } is unchanged and pinned at current-user-endpoints-localization.test.ts:133. Note the endpoint passes tenantId/userId to rung 3, and resolveLocalizationContext reads settings with { tenantId, userId } scope — that is the same cascade the dispatcher uses, so no new semantics.

    C. Contract flags:
    (1) execCtx.locale consumers unchanged — confirmed. packages/core/src/security/assemble-execution-context.ts:353 locale: anonymous ? undefined : (requestLocale ?? localization?.locale) is not in the diff; this endpoint's own resolver (makeExecutionContextResolver, :594-633) returns no locale/currency/timezone at all, so the dev's measurement that it was always undefined (hence locale: 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 } for tryFind(ql, 'sys_user', { id: userId }, 1) at :488; plugin-auth's storedRecipientLocale (auth-manager.ts:4953-4957) uses context: { isSystem: true, positions: [], permissions: [] } with fields: ['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 by resolveUserAuthzGrants). The read is by the caller's own id, only the locale column is used, and a failed read logs at warn and falls through (:713-716) — correct fail direction for a courtesy value; the session read before it stays fail-loud, as the test at :66-73 documents. 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 versus normalizeRecipientLocale is 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:128 rung 1 over 2+3 (stored zh-CN, header ja-JP, setting fr-FR → zh-CN), :145 rung 2 over 3 (+ blank column), :153 rung 3 with header absent and with *, plus the en-US floor, :163 four malformed shapes → next rung in both header/no-header variants, :174 registry rule governs (narrowed rule moves the answer; broken regex is skipped → unverifiable), :189 no rule / no schema → fall through, :199 failed read → 200 with rung 2, :209 unauthenticated unchanged with no sys_user read. Every rung case supplies all lower rungs, which is the anti-vacuity property that matters. Ablation arithmetic checks out: the file has 8 it blocks; a reverted handler answers locale: null, which fails the first 7 and leaves unauthenticated unchanged green — "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.md is "@objectstack/spec": minor with 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.md is "@objectstack/plugin-hono-server": minor and describes the behaviour change including the null → string shift. The playbook's default major for spec is overridden by the ruling's explicit "minor with a BREAKING note per the launch-window convention", so minor is the ruled bump.

    F. Serial resync — registry consistent with the entries directory. Merge commit c13d17c2e has parents 22710c48a and 27875fc7e (main after #15299's squash 13c48c2a5); 06f8e2268 regenerates. Counts on the head: 152 semantic ids in registry.ts vs 152 files in entries/semantic/; 89 files in entries/retired-keys/; 97 in entries/retired-defs/ — the dev's 152/89/97. Structural check: the step18.semantic id sequence in registry.ts is identical, in order, to the sorted 18.* semantic filenames (65/65, diff empty), and RETIRED_KEYS_BY_MAJOR[18] is identical, in order, to the sorted 60 18.* 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__errorMapping and integration__DeclarativeConnectorEntry__errorMapping in the 18 list) alongside this card's three hits. Byte-identity to a fresh gen:migration-registry run 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-server beyond the resolver are the module header comment and the handler comment. git diff origin/main...HEAD and all five commit messages contain no model identifier and no Co-Authored-By trailer; the PR body names none. No path under content/docs/releases/ is touched.

    Confidence gaps (what you could not measure):

    • I did not run 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.
    • I did not verify the ablation by executing it; D is arithmetic over the test files as written.
    • cloud / hotcrm readers of SessionUser.language and of /auth/me/localization remain unmeasured (no checkout), as the dispatch and PR body already state; objectui's zero-reader measurement is the seat's, not re-run here.
    • Whether check:doc-authoring Rule 2 would accept the #14788 citations 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 the retiredKey() string only, which matches the rule targeting customer-facing prescription text.
    • Production behaviour of rung 1 depends on the registered sys_user object retaining validations through _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)

    1. Condition 1 — fail-closed accepted, explicitly. Premise: in this monorepo the registered sys_user object carries the locale_bcp47_shape format 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 overrides sys_user without the rule loses rung 1 silently at debug level; 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 overrides sys_user — NOT MEASURED; anyone with such a host reads debug for stored locale not read and files against this card.
    2. Condition 2 — the '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; normalizeRecipientLocale refuses 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-By trailer 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 to packages/spec/src; monorepo-wide enforcement is tsc via the never input type — option A). Dev-filed follow-ups #15387 (endpoint answers currency / timezone null 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 that check:doc-authoring refuses) are triage's.

    Carriers cleared in this stroke (read-modify-write, read back): needs:contract-review removed 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: strip pm:dispatched, probe origin/main (language: retiredKey( on auth.zod.ts; resolveSignedInUserLocale exported), landing note, and an unlock note on #14762 (its Blocked-by: re-read).


    Generated by Claude Code

  13. removed
    needs:contract-reviewMarker only — PR awaits its at-tier Contract review PASS; no check reads it, the record decides
    on Sep 4, 2026
  14. os-sales commented on Sep 4, 2026

    @os-sales
    Collaborator

    Landing note — PM seat domain:spec, session session_01G4138K1EG7kQ81FNba5Kp4 (os-sales), 2026-09-04T15:20Z. This card was the fenced landing tail handed over by the previous seat (os-justin, shift-end brief 5542312706); the stroke below is that fence discharged. ⛔ Nothing was re-dispatched — the PR was reviewed and armed by the previous seat (ACCEPT 5541680728, Clause ② PASS by isolated second opinion at tier).

    MERGED. PR #15386 merged 2026-09-04T15:02:44Z, head 06f8e2268, squashed onto main as 5f7fa1d. merged_at read directly (a merged PR reports merged: false on this channel — merged_at is the reading that answers). closed_by_pull_requests carries PR #15386 as MERGED and this card closed completed at 15:02:45Z on its Fixes line.

    Probe on origin/main at 5f7fa1d (fetched 15:19Z, both markers the brief prescribed, plus a negative control so a zero would have been provable rather than assumed):

    reading result
    language: retiredKey( in packages/spec/src/api/auth.zod.ts ✅ :56
    resolveSignedInUserLocale in packages/plugins/plugin-hono-server/src/current-user-endpoints.ts ✅ exported at :749, referenced :671 / :719
    negative control — SessionUserSchema in the same spec file 5 hits (the file and the ref are readable, so a zero above would have been a real zero)

    Ruling D is on main: the language key is a tombstone whose write is a tsc error and a parse refusal, and /auth/me/localization is the single read face with the sys_user.locale → Accept-Language → deployment-default precedence.

    State: pm:dispatched stripped in one label write, read back and confirmed (priority:p2 · domain:spec · i18n remain — routing and type are not state). Assignee left as the implementing seat's.

    ⚠️ Correction to the inherited brief — the owed unlock note on #14762 is NOT owed, and I am recording why rather than posting a no-op. The brief instructs the successor to post an unlock note on #14762 as Blocked-by: #14788. Two readings taken at 15:19Z contradict that:

    1. plugin-auth: auth SMS (OTP / invite texts) and request-less auth mail keep the deployment locale — sys_user.locale exists now and is not read #14762 has been closed since 2026-09-03T23:52:18Z (state_reason: completed, closed by PR fix(auth): read the recipient's own sys_user.locale for auth OTP SMS and auth mail #15107 "fix(auth): read the recipient's own sys_user.locale for auth OTP SMS and auth mail"). It was already closed when the brief was written.
    2. Its 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 reads Blocked-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

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

Metadata

Metadata

Assignees

Labels

domain:specDomain lane — seat card indexed by label:pm:seati18npriority:p2Medium: important, M3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions