Skip to content

i18n: an app hook's business refusal has no translation key — a zh-CN user reads "A current clean version is required before signing." in English #26170

Closed

Description

@objectstack-fleet

Ruled: 6431311681 · letter A · 2026-10-27T01:21Z

Filing class: ① product defect (i18n surface) — reach: public door, measured on 18.1.0 with objectstack-ai/hotclm main (pnpm demo:zh, Console and user in zh-CN). Reader: the spec/i18n lane (translation face for hook refusals) and the runtime lane (packages/runtime/src/sandbox/body-runner.ts, the HTTP refusal envelope).

Business reason

Business rules that need the record's state live in hooks; their refusals are the sentences a user reads most often when the app says "not yet". Every other refusal door already speaks the reader's language — validation rules, flow end refusals (flows.<flow>.refusals.<node>.message, #22450), flow write refusals (#25929, #25809). A hook refusal is the one left: an app shipping a zh-CN pack still shows these in its source language, and no pack can fix it.

Measured

hotclm's contract_state_machine hook (src/objects/contract.hook.ts, refuse(message, code, status)) refuses, as designed:

  • 发起签署 without a clean version → 422 INVALID_STATE "A current clean version is required before signing."
  • 送审 without an approved review → 422 "An approved legal review is required before the contract enters approval."
  • a create born in a non-draft state → "A contract is created as a draft, not as active. …"
    Each reaches a zh-CN user verbatim as the toast. hotclm's i18n gate reports 0 missing keys — there is no key.

Why

translation.zod.ts declares refusal keys for flows (refusals.<node_id>.message) and messages for validation rules; hook-api.ts / hook-body.zod.ts give a hook body no keyed way to refuse, so the thrown string is the message.

Expected

A hook body can refuse with a key (e.g. objects.<object>._hooks.<hook>.refusals.<code> or a top-level message id) plus params, the runtime renders it in the caller's locale with the English string as fallback, and the i18n coverage gate counts the keys.

Dedupe: objectstack and objectui issues (2,000 most recently updated each, all states) grepped for hook … message|refus|throw … translat|locale|English|i18n — hits #25929 (flow write refusals, closed), #25383 (auth hooks refactor), objectui#12260 (activity labels), none this.


Filed from the hotclm browser regression thread (2026-10-26).

Activity

  1. objectstack-fleet commented on Oct 26, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: first grade — bug · priority:p1 · domain:spec · area:i18n · pm:queue · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-26T14:55Z

    ⛔ Not a claim, ⛔ not a dispatch.

    • p1 by 「协议新增 p1」: a hook refusal gets a translation slot (for example hooks.<hook>.refusals.<code>.message; the shape is the spec seat's to settle). It is the one refusal door every other door has already closed (validation rules, flow end, flow writes). The caliber question is still open, and this card is named in triage's report as a protocol addition.
    • The runtime half (the refusal envelope in body-runner.ts) resolves the key in the caller's locale. It shares the file with runtime: an app hook's intentional business refusal is logged as ERROR with a stack trace, twice per refused action #26153, so one holder is natural.
    • Owed with it: the published skill (skills/objectstack-data hooks) and the i18n docs name the new key, so authors use it.

    Generated by Claude Code

  2. objectstack-fleet commented on Oct 27, 2026

    @objectstack-fleet
    ContributorAuthor

    Decision filed · domain:spec seat 3 · os-support-ai · session session_01WGufzgbJhFoXYsLSe5WSPP · 2026-10-27T00:44Z · ⛔ not a claim

    Premise check (origin/main 6390fe52) before dispatch: the defect holds, but the fix's key shape and how a hook body raises a keyed refusal are an open public-contract choice. No rule or ruling settles it, so the card goes to the maintainer's decision box. The rest of the lane's queue keeps moving.

    背景

    一句话问题: 应用自己写的业务拦截(hook 里"签署前必须有干净版本"这类话)是平台上唯一一种无法翻译的拒绝提示,中文用户看到的是英文原句,而且任何语言包都补不上。

    前提读数(各带复查命令):

    Governing text:

    是否改协议: 是。下面三个选项都会给作者新增可写的东西(Clause-②: yes,Metadata-protocol: added)。

    选项

    选项 做什么 客户能感到的后果 工作量
    A. hook 上声明拒绝目录 Hook 新增 refusals: { <code>: { message, status? } }。body 只按 code 拒绝(抛 { code },或者新增 api.refuse(code, params))。翻译键是 hooks.<hook>.refusals.<code>.message。i18n extract/check 按声明逐条计数。抛出未声明的 code 时,保存时或运行时会响亮报错。 中文用户看到中文。作者漏翻译时,覆盖率检查会报出来。 spec + runtime + cli + lint + skills 文档,估计 2 个 PR(spec 与 runtime 在同一版本)。先例 #22450(flow end refusals),行数未实测。
    B. 只按 hook 名 + 抛出的 code 查翻译 不改 Hook 声明。body 照旧 throw { code, message },运行时去查 hooks.<hook>.refusals.<code>.message,查不到就退回原句。 能翻译,但 extract/check 看不见 body 里有哪些 code,覆盖率检查数不到,漏翻译时静默显示英文。 1 个 PR,比 A 少一个声明面和 lint。
    C. 按对象作用域挂键 键挂在 objects.<object>._hooks.<hook>.refusals.<code>。 一个 hook 绑定多个对象时要翻译 N 份,'*' 通配的 hook 没地方挂。 与 B 相近。
    D. 通用消息表 body 抛 messageKey + params,指向一张顶层 messages.<id> 表。 最灵活,但键是自由文本,拼错时静默显示原句。全平台出现第二套写法。 1–2 个 PR。

    业务含义直译: A 等于"拒绝话术先登记在案、再按编号使用",和校验规则、流程的做法一致。B 等于"话术写在代码里,翻译靠事后对号",对不上时没人知道。C 等于"同一句话按对象各存一份"。D 等于"另开一本通用词典"。

    四轴

    • 项目长远合理性(权重最高): A 让三种拒绝(校验规则、flow、hook)都遵循同一个模型:拒绝原文写在有名字的声明上,翻译挂在这个声明上,覆盖率可以计数。两年后平台上"一切用户看得见的拒绝提示都可翻译、都可计数",主流平台也都这样建模,比如 Salesforce 的 Custom Labels、ServiceNow 的 Messages:先登记,再引用。B 和 D 会多出第二种没有声明的约定。C 和"一个 hook 绑定多个对象"的结构冲突。
    • 实际业务拉动: 实测拉动只有 hotclm 一个应用,3 句文案。但所有用 hook 做状态机的应用都会撞上同一个问题。是实测拉动,不是投机。
    • 防 AI 犯错: A 下,AI 写了未声明的 code 会被响亮拒绝,漏翻译会被覆盖率检查报出来。B 和 D 下,AI 拼错键或漏翻译都静默显示英文,而且 i18n 检查报"0 缺失",正是这张卡发现问题的方式。C 下,AI 很容易只为一个对象写翻译。
    • 创业阶段不扩散: A 新增一个声明键(Hook.refusals)和一个翻译分组,是永久义务。B 少一个键,但换来一个检查不到的面。D 扩散最大。新增门禁:无,A 的计数复用既有 i18n coverage 检查,lint 只是既有引用校验的一个新分支。

    os-decision-facets

    • ① 长远:A 收拢为一个模型(声明 + 键 + 计数),B/D 增生第二约定,C 与多对象 hook 结构冲突
    • ② 拉动:hotclm 实测 3 句,用 hook 做状态机的应用都会撞上;非零拉动
    • ③ 防 AI:A 未声明 code 响亮拒绝 + 覆盖率可计;B/D 拼错与漏译静默;C 易漏对象
    • ④ 不扩散:A 新增 1 个声明键 + 1 个翻译分组;B 少一键但不可检;D 最扩散;不新增门禁
      Prior rulings read: hook refusal translation key locale ruling → 10 hits; ADR none; thread: none

    推荐

    推荐 A。 只看①选 A;②③④ 是否翻转:否。B 不取,因为它把"数不到的拒绝"写进契约。C 不取,因为它与多对象 hook 冲突。D 不取,因为它开了第二本词典。置信缺口:body 侧的写法是抛 { code } 还是新增 api.refuse(code, params),我倾向复用现有抛错形状加 code,留给实施时实测。如果实测发现现有形状装不下 params,dev 会报 fork,不会硬做。

    裁后执行

    裁定后,本席按选项派发一张 L 卡(卡线程,契约复审档复核)。spec 半与 runtime 半在同一版本落地。i18n 分组、extract/check 计数、lint 引用校验、skills/objectstack-data 的 hooks 文档同 PR。派发时如果 #25785(PR #26159)还没落地,本卡排在它之后(同为 area:i18n,共用 translation.zod.ts)。


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 27, 2026

    @objectstack-fleet
    ContributorAuthor

    Brief-claimed-by: session_01KrKXQVvgYk2AJ4VkqMobyb · batch #405 · 2026-10-27T01:09Z

    Director seat, summon #51 continuation (ledger objectstack#12708). Presented to the maintainer in batch #405 as item 2 of 3. Seat increment (re-verified on origin/main 6390fe52; the spec seat's decision comment 6430864109 is not restated):

  4. objectstack-fleet commented on Oct 27, 2026

    @objectstack-fleet
    ContributorAuthor

    Ruling: batch #405 item 2 · letter A · maintainer 「同意」 2026-10-27T01:17Z

    Recorded by the director seat (summon #51 continuation, session_01KrKXQVvgYk2AJ4VkqMobyb, ledger objectstack#12708). The maintainer answered batch #405 with 「同意」 — every seat letter. Brief 6431195984; the spec seat's decision comment 6430864109. Letter A: a hook declares its refusal catalogue — refusals: { <code>: { message, status? } } on Hook — and its body refuses by code; the translation key is hooks.<hook>.refusals.<code>.message; the runtime renders it in the caller's locale with the declared message as the fallback; the i18n extract/check gates count the declared keys; an undeclared code is refused loudly. Not B (an uncountable refusal written into the contract: the coverage gate reports "0 missing" while a zh-CN user reads English), not C (one copy per bound object, nowhere for '*'), not D (a second dictionary with free-text keys).

    Workload of the chosen letter (card column): L — spec half (hook.zod.ts, translation.zod.ts group, lint reference check) and runtime half (body-runner.ts / quickjs-runner.ts refusal envelope carries the code and params; locale render) in one version, two PRs; i18n extract/check counting, skills/objectstack-data hooks page and the i18n docs in the same PRs (Skills-docs: line owed under 6431004737). Precedent PR #22525 (flow end refusals, #22450): +1110 / −25, 26 files. The body-side spelling (throw { code, params } or api.refuse(code, params)) is the dev's to measure; a fork is reported, not forced. Serial: behind #25785 / PR #26159 on translation.zod.ts. Grade p1 stands under 6431014712 (a protocol addition is p1). Clause-②: yes, Metadata-protocol: added.

    Execution: needs-user-decision → pm:queue in this act (domain:spec, p1, area:i18n). Ruling-ref: names this comment.

  5. objectstack-fleet commented on Oct 27, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 2 · 2026-10-27T13:15Z
    Session: session_01WGufzgbJhFoXYsLSe5WSPP
    Account: os-support-ai
    Branch: claude/project-thread-ivr552 (card thread; the platform assigns the thread's branch and it can push only that one, written as measured instead of claude/issue-26170-<slug>)
    Worktree: objectstack-issue-26170
    Domain: domain:spec
    Seat: domain:spec#3
    File surface: packages/spec/src/data/hook.zod.ts (Hook.refusals), packages/spec/src/system/translation.zod.ts (a new hooks group region only), packages/cli/src/utils/i18n-extract.ts and i18n check (a hooks walker only), the runtime sandbox and refusal path (packages/runtime/src/sandbox/**, the hook refusal envelope), the hook-refusal lint branch, skills/objectstack-data hooks docs, tests, generated output, changesets — ruling 6431311681 option A only; ⛔ options B/C/D (stop on a fork; explain in the report)
    Container & model: L, mode:thread, model: TIER_DEFAULT — --tier: no path-derived mandate (schema face; one CONTRACT_REVIEW_TIER review owed before the queue, run by the seat), effort: high
    Clause-②: yes
    Metadata-protocol: added
    Responsibility: the spec lane owns the hook declaration face; the runtime half lands in the same version
    Thread-read: 6431311681
    Premise: holds — re-read on origin/main 43731031 (no hooks group in translation.zod.ts; __errorInfo carries code/fields/status/userMessage only, quickjs-runner.ts ~363)
    Serial constraints cleared: #25785 (PR #26159) landed 11:56Z; siblings #26112+#26142, #26128, #26463 edit other regions of translation.zod.ts / other walkers in i18n-extract.ts (region-disjoint, declared in each claim; merge main after each lands, generated files via os-regen-merge.sh); no other open PR touches translation.zod.ts or hook.zod.ts

  6. objectstack-fleet commented on Oct 27, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 26170,
      "status": "done",
      "branch": "claude/project-thread-ivr552",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/26831",
      "session": "session_01WGufzfyVQuRsL6SRGuKCem",
      "premise_still_valid": true,
      "summary": "Ruling A landed as one PR (1703+/99-, under the 3000-line line): Hook.refusals { CODE: { message, status? } } with a save-time refusal of literal undeclared codes, a hooks translation group (hooks.HOOK.refusals.CODE.message) walked by os i18n extract/check and judged by lint, and a runtime resolver in the sandboxed body runner that answers a declared code with the translated (else declared) message and status, and an undeclared or missing code with a 500 fault naming the hook. Body spelling reuses the existing throw shape plus code (no api.refuse); no params. HookContext.session.locale is produced in objectql buildSession. The new group rippled into the console translation preview and loader list, the i18n docs, the walk-parity fixture, a property count and an ADR-0122 pin; the Studio hook form does not offer refusals (the record editor would stamp a key the closed entry refuses), recorded in the form gate's ledger.",
      "tests": "New: spec hook-refusals.test.ts 45, runtime hook-refusal-catalogue.test.ts 17 (real factory + QuickJS, read off mapDataError), cli i18n-hook-refusal-coverage.test.ts 7, lint +5, objectql session-locale 3. Ablation on disk, restored: resolveHookRefusal call replaced by err -> 9/17 runtime tests fail; host-relay marker not recorded -> passthrough test fails. After last merge at 78dc14528e: runtime 7465 passed, lint 7435, objectql 8846, spec 22063 + census fix re-run 3/3, cli i18n 13, metadata-protocol degenerate 18, touched ui 1445, spec typecheck exit 0. Before it (547ffcae02): cli 6415, metadata-protocol 21580, platform-objects 1359, spec test:repo 1295.",
      "gates": "dispatch-gates --commands derived 130; all run under os-verify-lock, exit codes captured before pipes; 129 exit 0, pnpm check:spec-parsed-alias exit 1 -> fixed (HookRefusal isomorphism pin) -> exit 0. --ran reconciliation: 130 derived, 130 run, 0 NOT-MEASURED, 0 UNRUN, exit 0. Also spec check:generated (14 current), check:i18n 0, check:i18n-walk-parity 0, check:skills-token-ratchet 0. PR check-runs: pending at report time.",
      "dogfood_battery": "n/a — no ! in the PR title and no (narrowing) arm",
      "mcp_calls": "2 — mcp__github__issue_read (card + comments, read), mcp__claude-code-remote__subscribe_pr_activity (not a GitHub write); no MCP GitHub write tool",
      "api_writes": "2 relay writes via fleet-write: pr_create (POST /repos/objectstack-ai/objectstack/pulls draft + POST /issues/26831/assignees), and this os-dev-report comment (POST /issues/26170/comments); plus git push of the pinned branch",
      "open_questions": [
        {
          "question": "Should a declared hook refusal carry params (holes in the message)?",
          "options": [
            "A keep plain text, as validation-rule messages (shipped)",
            "B later widening: params on the thrown error and {{ }} holes in message and translation"
          ],
          "recommendation": "A now; B only on demand, as an additive widening"
        }
      ],
      "out_of_scope_findings": [
        "carrier: none · the Studio hook form cannot offer refusals until the record editor supports key-only records (it stamps keyField into each entry); recorded in metadata-form-zod-reconciliation's ledger, noted, not filed",
        "Skills-docs follow-up owed: skills/objectstack-data/evals/hooks-security-seeds-datasources.json has no refusals case and sits at its token ceiling"
      ],
      "deviations": "Files outside the order's surface, each named in the PR: objectql engine-execution-plumbing.ts (session.locale producer), runtime app-plugin.ts / app-artifact-handlers.ts (i18n reader wiring), ui translation preview + loader list (exhaustive group Record), i18n docs, walk-parity fixture, metadata-protocol count test, ADR-0122 pin file, form-reconciliation ledger. data-hooks.md paid for its new section by deleting duplicate error-handling examples (998 -> 972 lines; package md 3849 -> 3823). No reviewer requested: the order names none."
    }

    Generated by Claude Code

  7. objectstack-fleet commented on Oct 27, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #26831 comment 6453820397 · domain:spec seat 3 · 2026-10-27T21:17Z

    Tier H (touches skills/**): awaiting an authorized approval from os-zhuang or hotlong; needs-user-decision is on the PR. The seat lands it after approval.


    Generated by Claude Code

  8. objectstack-fleet commented on Oct 28, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:spec seat 3 · os-support-ai · 2026-10-28T02:23Z

    reading value
    PR #26831 approved by os-zhuang (review 5675480122, Tier H); merged through the queue at 2026-10-28T02:23Z, merge commit b3f73445f4
    origin/main c79eda5e contains the merge commit (git merge-base --is-ancestor)

    State: Fixes #26170 closed this card as completed. pm:dispatched comes off in this stroke; domain:spec, the area and the grade stay. Open on other cards: #26834 (skill eval), #26835 (Studio hook form). One stale TSDoc word (translation.zod.ts ~:2082, "Fifteen groups" after #26819's books group) lands in a trailing docs PR from the same thread (contract review round 4, 6458660736).


    Generated by Claude Code

  9. objectstack-fleet commented on Oct 28, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #27033 comment 6461536766 · domain:spec seat 3 · 2026-10-28T05:36Z

    This is the one-word follow-up from PR #26831 round 4: "Fifteen groups" becomes "Sixteen groups". The contract review passed (6459095198). The PR is ready, with auto-merge armed.


    Generated by Claude Code

  10. objectstack-fleet commented on Oct 28, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed (follow-up) · domain:spec seat 3 · os-support-ai · 2026-10-28T06:25Z

    reading value
    PR #27033 merged through the queue at 2026-10-28T06:23Z, merge commit d90657b2ca
    origin/main d90657b2 contains the merge commit (git merge-base --is-ancestor)

    TranslationDataSchema's TSDoc now says "Sixteen groups" (the stale word flagged by PR #26831's round-4 review, 6458660736). Nothing is left on this card from seat 3. #26834 and #26835 stay open on their own cards.


    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

area:i18nThe customer's own language, across UI, metadata and notificationsbugSomething isn't workingdomain:specpriority:p1High: required for production / M2

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions