Skip to content

feat(spec): a hook declares its refusal catalogue, and its body refuses by code, answered in the caller's language - #26831

Merged
os-zhuang merged 23 commits into
mainfrom
claude/project-thread-ivr552
Oct 28, 2026
Merged

os-zhuang merged 23 commits into
mainfrom
claude/project-thread-ivr552

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Yibo Wang · project thread

Fixes #26170

Clause-②: yes (widening)
Metadata-protocol: added
Skills-docs: follow-up #26834

维护者速读

  • 改了什么:按裁决 A,Hook 新增 refusals: { CODE: { message, status? } } 拒绝目录;沙箱 body 只按 code 拒绝,运行时用目录里的消息(经 hooks.HOOK.refusals.CODE.message 按调用方语言翻译)和状态码作答。
  • 为什么改:hook 的业务拒绝是用户最常读到的"不行",却是唯一没有翻译键的拒绝入口(校验规则、flow 拒绝都已有)。
  • 风险与回滚:只对声明了 refusals 的 hook 生效,未声明的 hook 行为完全不变;没有 js body 的 hook(进程内 handler、表达式 body)声明 refusals 会在保存时被拒;回滚即 revert 本 PR。新增翻译分组 hooks 连带改了控制台翻译预览、i18n 文档和 walk-parity 夹具。
  • 先看:packages/runtime/src/sandbox/hook-refusal-catalogue.ts、packages/spec/src/data/hook-refusal-codes.ts、hook.zod.ts 的 refusals。
  • 两分钟验证:cd packages/runtime && npx vitest run src/sandbox/hook-refusal-catalogue.test.ts(17 项:声明码、翻译、协商、未声明码 500、宿主错误透传)。
  • 需要你做的:本 PR 触及 skills/**(受治理面),需维护者合并;拒绝消息暂不带参数({{ }} 现在保存时即拒,以后加参数是纯增量),见 Acceptance notes。

What changes

Ruling A (6431311681), as bound by the order:

  • @objectstack/spec
    • HookSchema.refusals: a record keyed by a SCREAMING_SNAKE code (HOOK_REFUSAL_CODE_PATTERN) of HookRefusalSchema = { message, status? }, closed, status 400–499 (default 400). Aliases name reason/text/description → message and httpStatus/statusCode → status.
    • A catalogue is answered only on the sandboxed body path, so refusals on a hook with no js body (an in-process handler, an expression body) is refused at save, at refusals, naming the remedy (move the refusing logic into a js body, or drop refusals).
    • A refusal message is plain text: a {{ }} hole is refused at save, by a pattern the published JSON Schema carries too, so a later params slot stays additive.
    • Save-time refusal (refuseUndeclaredLiteralRefusalCode superRefine): when a hook declares refusals and its js body throws a code literally ({ code: 'X' } in a throw, or e.code = 'X' on a name thrown bare) that the catalogue does not declare, the parse fails at body.source. The reader (hook-refusal-codes.ts, an import-free leaf) masks comments and string contents first, so a throw or code: written inside either is never read.
    • Translation: a new hooks group, hooks.HOOK.refusals.CODE.message, on the per-app and platform faces, and hookRefusalMessageKey. The group counts in prose move by one: fifteen → sixteen per-app and sixteen → seventeen on the platform face, after the main merge that brought feat(spec,rest,cli): a doc book's title and section titles are translatable — books.<name> in the translation bundle #26819's books group. One TSDoc line (translation.zod.ts ~:2082, TranslationDataSchema) still says "Fifteen groups"; it is fixed in a trailing docs PR after this one lands (contract review round 4, 6458660736).
    • HookContext.session.locale declares the caller's locale.
    • Liveness rows hook/refusals (with message and status children) and translation/hooks.
  • @objectstack/objectql: buildSession copies ExecutionContext.locale onto a session that exists (a locale alone never makes one).
  • @objectstack/runtime
    • hook-refusal-catalogue.ts: resolveHookRefusal answers a sandboxed body's throw. A declared code becomes a SandboxError with the translated (else declared) message as innerMessage and userMessage, the declared code, and the entry's status. Any other throw from the body (an undeclared code, or none) becomes a 500 fault naming the hook and the code. The locale is negotiated with resolveBundleLocale against the i18n service's locales, read lazily at refusal time.
    • body-runner.ts calls it in the hook catch; app-plugin.ts and app-artifact-handlers.ts pass the lazy i18n reader.
    • quickjs-runner.ts: a marshalled host error carries a non-enumerable marker of the message and code it crossed with, so an error a ctx.api call raised and the body let leave unchanged passes through untouched (isHostRelayedSandboxError); one the body rewrote is the body's own.
  • @objectstack/cli: os i18n extract walks hooks[].refusals (one key per declared entry) and os i18n check counts them in a new hook coverage bucket.
  • @objectstack/lint: translation-target-unknown judges hooks.NAME against the stack's hooks (sibling packages' included) and hooks.NAME.refusals.CODE against that hook's declared codes, with a nearest-name suggestion.
  • Ripples of the new group: the console translation preview gains a hooks category (en/zh labels) and the loader's group list gains hooks (ui/); i18n-standard.mdx and ui/translations.mdx; the walk-parity fixture authors one hook with a catalogue; CARD_PROPERTY_COUNTS.hook 22 → 23.
  • Skills: skills/objectstack-data/references/data-hooks.md teaches refusals.
  • Changeset: minor for spec, runtime, objectql, cli, lint; patch for @object-ui/i18n and @object-ui/app-shell (the loader's group list and the translation preview move).

Tests

  • packages/spec/src/data/hook-refusals.test.ts (new, 52): the schema, the save-time refusal, refusals refused beside a handler or an expression body, a {{ }} hole refused, the literal reader (positive and negative shapes, comments and strings masked), the sentences, the translation path, session.locale.
  • packages/runtime/src/sandbox/hook-refusal-catalogue.test.ts (new, 17): through the real factory and QuickJS runner, read off mapDataError: declared code with default and declared status, zh-CN translation, zh → zh-CN negotiation, fallback, no service, undeclared and missing code as 500, a hook without refusals unchanged, a crash unchanged, a host error passed through, a host error re-coded by the body judged.
  • packages/cli/test/i18n-hook-refusal-coverage.test.ts (new, 7): walker, coverage bucket, skeleton parses through the schema.
  • packages/lint/src/validate-translation-references.test.ts (+5): the hooks arm.
  • packages/objectql/src/engine-execution-plumbing.session-locale.test.ts (new, 3).
  • Ablation (on-disk mutation, restored after): with the body runner's resolveHookRefusal call replaced by err, 9 of the 17 runtime tests fail; with the host-relay marker not recorded, the passthrough test fails.

Acceptance notes

  • The OPEN item (throw shape versus api.refuse), measured: the existing throw shape carries code across the VM already (__errorInfo), so the body spells a refusal throw Object.assign(new Error(), { code: 'X' }), and no new API was added. Params are not carried: a declared message is plain text, translated whole, like a validation rule's message (no parameter contract). I did not treat this as a fork, because the ruling's catalogue has no param slot either. A {{ }} hole is refused at save now, so a later params slot (e.g. params on the thrown error) is purely additive.
  • On the wire an author's code is not an ADR-0112 registered one, so the envelope's code is the status's registered code (VALIDATION_ERROR for 400, RESOURCE_CONFLICT for 409) and the declared code rides as declaredCode, the existing demotion contract. The skills doc says so.
  • Enforcement scope: the undeclared-code refusal applies only to a hook that declares refusals; every existing hook is answered as before, so the change is non-breaking. A catalogue is served only on the sandboxed body path, so refusals on a hook with no js body is refused at save rather than wired into the in-process handler wrap (that would widen past the card).
  • Outside the order's file surface, and why: packages/objectql/src/engine-execution-plumbing.ts (the producer of session.locale; the runtime had no other way to read the caller's locale), packages/runtime/src/app-plugin.ts and app-artifact-handlers.ts (wiring the i18n reader), the ui/ translation-preview and loader lists (an exhaustive satisfies Record over the translation groups fails type-check otherwise), the i18n docs, the walk-parity fixture, and the metadata-protocol property count.
  • Studio hook form: refusals is not offered. The form's record editor stamps each entry's key into the entry, and a refusal entry is closed at message/status (the key is the code), so a stamped key would be refused. The form gate's ledger records this omission. Offering it needs a key-only record control, filed as Studio hook form: refusals cannot be authored — the record editor stamps the key into a closed entry; needs a key-only record control (follow-up owed by #26170) #26835.
  • Skills, line and token counts: data-hooks.md 998 → 972 lines; the skills/objectstack-data markdown package 3849 → 3823 lines. The new section was paid for under the file's token ceiling by deleting error-handling examples the handler and onError sections already carry (10063 of 10066 tokens).
  • No ! in the title and no (narrowing) arm, so no full dogfood run is owed.

Verification

At head e55978368d. Main is merged through scripts/pm/os-regen-merge.sh three times, last at a20824f366 with #26859. Each merge took main's side of the generated artifacts and regenerated them as their own commit. Every run is under os-verify-lock.

  • r1 change (c19dbcd0d4):
    • hook-refusals.test.ts: 52 passed.
    • With hook.test.ts, the translation tests and the ADR-0122 pin: 342 passed.
    • spec typecheck: exit 0.
    • check:generated --fix: wrote api-surface, export-origins and the hook.mdx reference.
    • I first tried a refinement for the hole refusal. The build refused it, because the published JSON Schema could not carry it (dropped-refinements ledger, 5 sites). A pattern carries it, so no ledger rows are added.
    • check-skills-token-ratchet, check-changeset-fixed, check-changeset-no-major and check-empty-changeset: exit 0.
  • On the merged tree:
    • Full pnpm build: exit 0.
    • spec check:generated: all 14 current after the regen commit.
    • actionKeys.pin.test.ts: 19 of 19 (fix(ui): the action key inventory lists disabledReason #26859).
    • The nine whole packages of CI's Test Core (6/8) shard: 44 of 44 turbo tasks, on the 8f7675f85b tree.
    • Suites: spec 22093 passed (736 files); runtime 7474 passed (461 files); cli 6460 passed (519 files); lint 7453 passed (169 files); objectql 8846 passed (476 files). check:i18n exit 0.
    • Dispatch gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 130 families, and all 130 were run with each exit code recorded before any pipe. All 130 exited 0. --ran reconciliation: 130 derived, 130 run, 0 NOT-MEASURED, 0 UNRUN, exit 0.
  • CI at this head: all 62 check-runs completed, 57 success and 5 opt-in skips.
  • Before r1 (head 78dc14528e, main merged at c8583f80ac):
    • Suites: spec 22063, runtime 7465, lint 7435, objectql 8846, cli i18n 13, ui 1445.
    • Dispatch gates: 130 derived, 130 run, 0 NOT-MEASURED, 0 UNRUN.
  • Dogfood: not owed (no !, no narrowing arm).

Generated by Claude Code

claude added 14 commits October 27, 2026 13:25
The save-time reading now masks comments and string contents first, so a
throw or code written inside either is never read as one.

Claude-Session: https://claude.ai/code/session_01WGufzfyVQuRsL6SRGuKCem
Co-authored-by: Claude <noreply@anthropic.com>
…opies

The console's translation preview and loader group list, the i18n docs, the
walk-parity fixture and the served hook property count move with the new
group. Hook refusal pins read the declared code off declaredCode.

Claude-Session: https://claude.ai/code/session_01WGufzfyVQuRsL6SRGuKCem
Co-authored-by: Claude <noreply@anthropic.com>
… ceiling

Pays for the new section by deleting the error-handling examples the handler
and onError sections already carry, and folds the testing pointer into the
references list.

Claude-Session: https://claude.ai/code/session_01WGufzfyVQuRsL6SRGuKCem
Co-authored-by: Claude <noreply@anthropic.com>
…e keys; offer refusals in the hook form

Claude-Session: https://claude.ai/code/session_01WGufzfyVQuRsL6SRGuKCem
Co-authored-by: Claude <noreply@anthropic.com>
…of a record control that would stamp a refused key

Claude-Session: https://claude.ai/code/session_01WGufzfyVQuRsL6SRGuKCem
Co-authored-by: Claude <noreply@anthropic.com>
…-ivr552

# Conflicts:
#	packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts
@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 5 package(s): @objectstack/cli, @objectstack/lint, @objectstack/objectql, @objectstack/runtime, @objectstack/spec, touching 57 documentable anchor(s). ⚠️ 13 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/api-surface/system.json, packages/spec/authorable-surface/data.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/hook-bodies.mdx (via HookContextSchema (symbol, a top-level const))
  • content/docs/kernel/services-checklist.mdx (via getLocales (symbol, a method of interface HookRefusalI18nService), /api/v1/i18n/locales (route, the route ledger binds it to client method i18n.getLocales))
  • content/docs/permissions/system-context.mdx (via buildSession (symbol, a top-level function))
  • content/docs/protocol/kernel/http-protocol.mdx (via getLocales (symbol, a method of interface HookRefusalI18nService))
  • content/docs/protocol/kernel/i18n-standard.mdx (via TranslationDataSchema (symbol, a top-level const), globalActions (literal, a string literal in PlatformTranslationDataSchema; a string literal in TranslationDataSchema; a string literal in TranslationItemSchema))
  • content/docs/protocol/objectui/actions.mdx (via globalActions (literal, a string literal in PlatformTranslationDataSchema; a string literal in TranslationDataSchema; a string literal in TranslationItemSchema))
  • content/docs/ui/translations.mdx (via globalActions (literal, a string literal in PlatformTranslationDataSchema; a string literal in TranslationDataSchema; a string literal in TranslationItemSchema))

⛔ 4 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via globalActions (literal, a string literal in PlatformTranslationDataSchema; a string literal in TranslationDataSchema; a string literal in TranslationItemSchema))
  • content/docs/releases/v17/17-0.mdx (via HookContextSchema (symbol, a top-level const), HookSchema (symbol, a top-level const))
  • content/docs/releases/v17/17-7.mdx (via HookSchema (symbol, a top-level const), bindAppArtifactHandlers (symbol, a top-level function), globalActions (literal, a string literal in PlatformTranslationDataSchema; a string literal in TranslationDataSchema; a string literal in TranslationItemSchema))
  • content/docs/releases/v18/18-0.mdx (via /api/v1/i18n/locales (route, the route ledger binds it to client method i18n.getLocales))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 13 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/api-surface/system.json, packages/spec/authorable-surface/data.json, …) — pages documenting those are invisible to this run
  • 6 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 55 of 207 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 157 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json e6c440900072a2d2423474962db9c7db6011b845 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from a252eb72bc469ef395419cb3019956476318a813 — the merge of head a1dbd6eaa144abbeecd35b2dd82a46a51c45e2ae into base e6c440900072a2d2423474962db9c7db6011b845, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin a252eb72bc469ef395419cb3019956476318a813 && git checkout a252eb72bc469ef395419cb3019956476318a813
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e6c440900072a2d2423474962db9c7db6011b845 a1dbd6eaa144abbeecd35b2dd82a46a51c45e2ae && git checkout -B drift-repro e6c440900072a2d2423474962db9c7db6011b845 && git merge --no-ff a1dbd6eaa144abbeecd35b2dd82a46a51c45e2ae

node scripts/docs-audit/affected-docs.mjs --json e6c440900072a2d2423474962db9c7db6011b845

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs e6c440900072a2d2423474962db9c7db6011b845 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…mitted error code

Claude-Session: https://claude.ai/code/session_01WGufzfyVQuRsL6SRGuKCem
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 78dc14528e8a3d403f551ff7500b33e72c984b26
Local-runs: none
Scope: packages/spec/src/data/hook-refusal-codes.ts, packages/spec/src/data/hook.zod.ts, packages/spec/src/data/index.ts, packages/spec/src/system/i18n-resolver.ts, packages/spec/src/system/translation.zod.ts, skills/objectstack-data/references/_index.md, skills/objectstack-data/references/data-hooks.md, skills/objectstack-platform/references/_index.md + every other path of the 52-file diff outside the prose faces (runtime sandbox hook-refusal-catalogue.ts / body-runner.ts / quickjs-runner.ts / app-plugin.ts / app-artifact-handlers.ts, objectql engine-execution-plumbing.ts, cli i18n-extract.ts / i18n-coverage.ts, lint validate-translation-references.ts, the five test files, the changeset, the ui preview and loader lists, the walk-parity fixture, the metadata-protocol count pin, the ADR-0122 pin, the form-reconciliation ledger, the generated spec artifacts) and the cited code read on main: packages/objectql/src/hook-binder.ts, packages/types/src/data-error-classification.ts (mapDataError, sandboxBusinessMessage, isSandboxCrash), packages/types/src/native-error-name.ts, packages/runtime/src/sandbox/quickjs-runner.ts (REJECT_HANDLER, hostErrorToVm, SandboxError), packages/spec/src/kernel/execution-context.zod.ts (locale), packages/spec/src/system/i18n-resolver.ts (resolveBundleLocale), packages/spec/src/data/hook.form.ts, packages/cli/src/utils/extract-hook-body.ts, scripts/check-error-code-casing.mjs, scripts/check-changeset-fixed.mjs, .claude/skills/pm-dispatch/SKILL.md (the Skills-docs line spelling).

Inputs read: card #26170 body and all 6 comments (decision 6430864109, brief 6431195984, ruling 6431311681 letter A, claim 6442821427, os-dev-report 6449646620); PR #26831 body, 52-path file list, the three-dot diff at this head (merge-base c8583f80ac, 52 files +1703/−99, matching), the one PR comment (the docs-drift bot, 6449723825); the head's check-runs read three times, last at 2026-10-27T18:06:53Z. Main has moved 6 commits past the merge-base; the one shared path is translation.zod.ts (main tightened LOCALE_TAG_SHAPE to 2–3 letters in a region disjoint from the new hooks group) — a merge is owed before the queue, no semantic conflict read. ⚠️ The PR head moved to e5ea8881c7 while this review ran (one commit, one line: an adr0112-ok: opt-out comment above the code: 'lower' fixture in hook-refusals.test.ts); this record judges 78dc1452 as briefed, and says below what the newer head does and does not change.

① Derived judgments

Gate readings on this head. Green: Auto Label, Dogfood Regression Gate (umbrella), Build Docs, Build UI Deps, Plan Test Core shards, Flag docs affected, Check Documentation Links, Lint · CI/release/engine gates, Lint · tooling self-tests, Lint · query-options erasure, Lint · repo & tooling gates, Lint · slot lookup & corpus, Lint · ESLint, Type Check · source gates, Check PR Size, Spec property liveness, Check Changeset, Governed Surface Queue Guard, PR-body guards, filter (21). Red: Lint · docs & code guards (failure, 2026-10-27T18:02:01Z) — its annotation names the step Error-code casing guard (pnpm check:error-code-casing, ADR-0112 #4003) as the red gate; the three umbrella jobs Lint & Repo Gates, TypeScript Type Check, Test UI then report failure, and every remaining shard (Test Core 1–8, Test UI 1–16, Type Check UI/workspace/consumer/debt, Build Core, Build & E2E UI, Lint UI, Lint · PM dispatch-gates) reads cancelled — superseded by the push of e5ea8881, so this head carries NO measured verdict for tests, type-check or the dispatch gates. The raw job log is behind a blob-storage redirect this container's proxy refuses, so only the annotations were read. Cause read in the diff, not the log: packages/spec/src/data/hook-refusals.test.ts:136 adds the fixture "throw Object.assign(new Error('x'), { code: 'lower' });", and the guard is textual (code: 'lowercase' in any .ts under packages/, strings included; custom and unrecognized_keys are in its NOT_CODES, so every other hit in the diff is clean). The newer head's adr0112-ok: reason line is the opt-out the guard honours (findViolations reads it on the line above, counted in the verdict's optOuts); that is a legitimate remedy for a fixture that emits nothing, and leaves EXEMPT_FILES untouched. Skipped: Temporal Conformance, Dogfood Verify CLI, Dogfood shards, 18.x harness, tarball smoke — all opt-in.

Ruling A, exactly? Declaration shape ✓ — HookSchema.refusals: Record(CODE matching HOOK_REFUSAL_CODE_PATTERN, HookRefusalSchema{ message: trimmed non-empty, status?: int 400–499 }), closed with aliases (reason/text/description → message, httpStatus/statusCode → status) and guidance for code/title; default 400 lives in the runtime constant DEFAULT_HOOK_REFUSAL_STATUS, stated in the describe. Key shape ✓ — hookRefusalMessageKey = hooks.HOOK.refusals.CODE.message, the hooks group on all three translation faces (appTranslationDataShape → TranslationData, PlatformTranslationData, TranslationItem), aliases hook→hooks, refusal/errors/messages→refusals, message optional so the extract skeleton's empty slot parses. Code-only body ✓ — the existing throw shape is reused (__errorInfo carries code); no api.refuse was added; the body's own text, status and userMessage are not read. Extract/check ✓ — walkHookRefusals emits one hook-bucket demand per declared entry (i18n/missing-hook), coverage test shows it counts down to zero. Lint ✓ — translation-target-unknown over the stack's and sibling artifacts' hooks and their declared codes, nearest-name suggestion. Precedent parity with flow end refusals ✓ (same declaration-keyed posture; by hook name, so '*' and multi-object hooks own one catalogue).

Save-time literal check — sound. hookRefusalCodesThrownIn masks comments and non-code string contents, reads code: 'X' inside each throw statement's expression and name.code = 'X' for a name some throw throws bare; SCREAMING_SNAKE literals only; statement ends at ;/newline outside brackets or the enclosing block close. Misses (helpers, computed codes, template-literal interpolation that unbalances the mask) fall to the runtime refusal, never to silence; its false positives (an e.code = 'X' in another scope) refuse toward loud. Guarded on body.language === 'js' — an expression body cannot throw a code. Each undeclared code is one issue at body.source with the declared list.

Runtime — loud where the catalogue speaks. resolveHookRefusal returns err for no catalogue, a non-SandboxError, no innerMessage (the runner's own faults / the engine's relayed refusal), a host-relayed error (isHostRelayedSandboxError: the new non-enumerable [message, code] marker on every marshalled host error, compared in the VM's reject handler so a body that rewrote either is speaking for itself), a nested relayed refusal, and a native-error crash. A declared code → new SandboxError(wrapper, message, { code, status: declared ?? 400, userMessage: message }); mapDataError serves 400 → VALIDATION_ERROR + declaredCode, 409 → RESOURCE_CONFLICT + declaredCode (the existing demotion contract; tests read it off mapDataError). An undeclared code or no code → new SandboxError(undeclaredHookRefusalAtRun(...)) with no innerMessage → isSandboxCrash → 500 INTERNAL_ERROR (ADR-0112 envelope with code and status, sanitised prose); the hook name, thrown code and declared list reach the server log at error through logBodyThrow (status ≥ 500 branch). Honest, and the author's fix text is in the log, not on the wire — consistent with the fault posture. The documented spelling throw Object.assign(new Error(), { code }) with an EMPTY message was checked against the runner: __error = String(e) = Error, userFacingMessage('Error') = Error, and NATIVE_ERROR_NAME_RE deliberately excludes plain Error, so it is a business message and the catalogue answers it ✓. An empty refusals: {} turns every throw into the 500 fault — loud and consistent.

Translation and locale — fail-safe. declaredRefusalMessage: no locale / no service / service throws (warned) / key echoed / blank → declared message. negotiatedLocale uses resolveBundleLocale against getLocales() (exact, case-fold, base, variant; zh → zh-CN tested) and falls back to the requested tag, where t echoes the key and the declared message is served — an unknown locale cannot blank or fault the refusal. buildSession copies ExecutionContext.locale onto a session only AFTER the presence test (a locale alone makes no caller); ExecutionContext.locale is Accept-Language first, else the localization setting (read in execution-context.zod.ts), which is what the new session.locale describe says. This producer is the ruling's "caller's locale" made reachable, not a behaviour beyond the card; descriptive only, no authorization input.

No params — extensible later, with one caveat. The declared message is plain text "translated whole", stated in the schema describe, the docs and the skill; the catalogue in the ruling has no param slot either, so shipping without one is inside letter A. A later params on the thrown error plus holes in the message is additive — EXCEPT that a double-brace sequence written in a message today is served literally and refused nowhere, so a future interpolation would change that string's meaning (a semantic kind, not added). The widening card should either refuse double-brace holes at the schema now or commit to literal-forever; flagged in ③, not a FAIL.

Off-surface files — each necessary, none creep. objectql buildSession (the only producer of the caller's locale on the hook session; 3-case test); runtime app-plugin.ts ×2 and app-artifact-handlers.ts (hand the lazy i18n reader to both bind doors — the default runner and the artifact binder — read at refusal time so a late-registered service is found); ui TranslationPreview category + en/zh labels + SPEC_TRANSLATION_GROUPS (exhaustive over the groups; the 2/15 (13%) test update follows from the count); i18n docs (owed by the ruling); walk-parity fixture (authors one hook with a catalogue so the group is walked); CARD_PROPERTY_COUNTS.hook 22→23 (the pin counts top-level HookSchema keys); ADR-0122 pin 796→797 (HookRefusalSchema has no default or transform, so input ≡ output — correctly isomorphic); form-reconciliation ledger omit entry (below). Generated artifacts move consistently: liveness hook.refusals (+message/status children) and translation.hooks drilled to refusals.message, all live with the reader cited; state-counts 21→23 / 38→39; api-surface, authorable-surface, declaration-map, export-origins, json-schema manifest, references docs (1556→1557 schemas), strictness-ledger counts 164→165 (one new strictObject in hook.zod) and 384→386 (two in translation.zod) — all match the diff.

Studio hook form. refusals is not offered; the ledger entry is accurate (the record control stamps keyField into each entry and HookRefusalSchema is closed, so a stamped key is refused). The form DOES offer a body.source code field, so a Studio-authored hook has no catalogue and stays on the unchanged branch — not a silent path. It is a declared-but-unofferable gap whose follow-up (a key-only record control) is "noted, not filed" in the dev report; the seat should carry it (③).

Skills paths — true, with one over-claim read elsewhere. Both _index.md export lists match hook.zod.ts at this head. data-hooks.md's new section is true line by line against the code: SCREAMING_SNAKE codes; declaredCode on the envelope; translation through the key in the caller's locale; the body's text and status unused; undeclared → save-time if literal else 500; unchanged ctx.api errors and crashes pass through; default 400. The deleted handler-based error examples are replaced by one body-based catalogue example and the Testing Hooks section by a reference link; 998→972 lines, under the token ceiling. One claim in the liveness note (not the skill) over-reaches: "the build lowers an inline handler to a body, which is the path a served runtime runs" — lowerCallables lowers INLINE functions; a post-build string handler resolved through the package's functions still runs in-process (hook-binder.ts), which is the path FAIL-2 below is about.

② Semver level

minor on @objectstack/spec, runtime, objectql, cli, lint — the five published packages the diff moves — with Clause-②: yes (widening) and Metadata-protocol: added on the PR body and the changeset body alike; a kind other than none takes at least minor, so the level is right and no ! is owed. Not breaking: a hook without refusals is answered byte-for-byte as before; the behaviour changes (a throw with no code or an undeclared code becoming a 500 fault; the body's status ignored) apply only to a hook that opts in by declaring a catalogue. No ADR-0087 marker is owed — check-adr-0087-registration reads breaking changesets only, and there is none. @object-ui/i18n and @object-ui/app-shell also move (published, same fixed group, root .changeset/ per ui/AGENTS.md) and are not named in the changeset — the fixed group bumps them regardless, so this is a CHANGELOG omission, not a version error; Check Changeset is green. The Skills-docs: follow-up owed — FILES line is well-formed per the claim template (pm-dispatch SKILL.md: the seat files the owed card and rewrites it to #N before ACCEPT); the named eval file exists and has no refusals case.

③ Boundary flags

FAIL-1 — the head's gate is red, and its tests are unmeasured. Lint · docs & code guards failed on 78dc1452 (Error-code casing guard on packages/spec/src/data/hook-refusals.test.ts:136, { code: 'lower' } as a fixture string), and every test/type-check shard was cancelled by the next push, so no test verdict exists for this head. The follow-up head e5ea8881 carries the guard's own opt-out spelling for exactly that line and nothing else; it needs its own record, and FAIL-2 stands on it unchanged (the delta is one comment line).

FAIL-2 — declared ≠ enforced on the in-process handler path. HookSchema accepts refusals beside a handler (string or inline) with no body: refuseUndeclaredLiteralRefusalCode returns early (body.language !== 'js'), hook-binder.ts never reads refusals, and resolveHookRefusal runs only inside hookBodyRunnerFactory. For such a hook the save succeeds, os i18n extract/check demand one translation per entry and lint accepts the keys, yet the handler's throws are answered as before — the catalogue's message and status are never served and an undeclared code passes in silence, the card's defect wearing a declaration that promises otherwise. The ruling's "an undeclared code is refused loudly, at save where decidable" is decidable here: the pairing is visible to the schema. Remedy (either): a third-pair superRefine refusing refusals on a hook whose body is absent or not js (prescription: give the hook a sandboxed body, or drop refusals), with a pin in hook-refusals.test.ts; or wire the resolver into the in-process wrap in hook-binder.ts and record it in the liveness note. The PR body's "the in-process handler path is not covered (documented)" documents the hole; it does not close it.

Non-blocking, answered or escalated:

  • Open question (params). Shipping plain-text messages is inside letter A. The later widening is additive only if the schema refuses double-brace holes in message now (or the platform commits to never interpolating a hook refusal); recommend the first in the follow-up card the dev proposes as option B.
  • Studio form gap. Acceptable as a declared-but-unofferable key with an accurate ledger entry; the key-only record control follow-up is unfiled — the seat should file it or name the carrier in the ACCEPT.
  • Skills-docs follow-up. skills/objectstack-data/evals/hooks-security-seeds-datasources.json has no refusals case and sits at its ceiling; the seat files the owed card and rewrites the line to #N before ACCEPT, per the claim template.
  • Liveness note over-claim ("the build lowers an inline handler to a body, which is the path a served runtime runs"): true for inline functions, false for a post-build string handler — fix the sentence when FAIL-2 is closed, whichever remedy is taken.
  • Changeset coverage of @object-ui/*: name @object-ui/i18n and @object-ui/app-shell in the changeset (patch-level entry lines) so their CHANGELOGs carry the group — or state in the PR why not; the version is correct either way.
  • Main drift: merge origin/main (6 commits; translation.zod.ts region-disjoint) before the queue.
  • Everything else the dev named as a deviation (buildSession, runtime wiring, ui lists, docs, fixture, counts, pins, ledger) is accepted as necessary; the data-hooks.md deletions that paid for the new section are accepted as true and within the ceiling.

Implemented-by: claude/project-thread-ivr552
Reviewed-by: session_01WGufzgbJhFoXYsLSe5WSPP

VERDICT: FAIL

… hole in a refusal message (#26170)

Contract review FAIL-2: a catalogue beside an in-process `handler` (or an
expression body) was saved, its translations demanded by `os i18n`, and never
answered. HookSchema now refuses it at `refusals`, naming the remedy.

A refusal message is plain text; a `{{ }}` hole is refused by a pattern the
published JSON Schema carries too, so a later params slot stays additive.

The liveness note no longer claims the build lowers every handler to a body.
The changeset names @object-ui/i18n and @object-ui/app-shell, which move.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzfyVQuRsL6SRGuKCem
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Triage: this PR conflicts with main and cannot enter the queue · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-28T00:43Z

⛔ Not a claim.

  • At the read (2026-10-28 00:30Z), head e5597836 reads mergeable_state: dirty. The branch claude/project-thread-ivr552 is 79 commits behind main (20 ahead). Auto-merge is on and the maintainer's APPROVE is on file, but a conflicted head never enters the merge queue, so the PR sits.
  • Over to the owning seat: merge main into the branch (no rebase, no force-push), resolve the conflict, regenerate generated files with the repo's tooling, and re-run its checks.
  • ⚠️ The contract review PASS on file names e5597836. A merge that moves the PR's own accept set, published surface or semver level makes that record stale, and the delta is reviewed again on the new head. A merge that only moves regenerated counts and baselines does not; the seat says which, on the PR.

Generated by Claude Code

…-ivr552

# Conflicts:
#	content/docs/protocol/kernel/i18n-standard.mdx
#	packages/lint/src/validate-translation-references.test.ts
#	packages/spec/src/system/translation.zod.ts
#	ui/packages/i18n/src/utils/spec-translations.ts
…groups now that `hooks` and `books` both stand

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzfyVQuRsL6SRGuKCem
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Main merged in; the conflict is resolved. New head a1dbd6eaa1.

  • Merge: f27ff28a4a merges main (31a608eb58) through scripts/pm/os-regen-merge.sh. 189bc37962 regenerates the spec artifacts on the merged tree. a1dbd6eaa1 carries one test-count follow-up.
  • The conflict: all four hand conflicts came from feat(spec,rest,cli): a doc book's title and section titles are translatable — books.<name> in the translation bundle #26819 (docs: a book's label and its group labels cannot be translated, so a zh-CN reader sees the book and every sidebar section in the authoring language #26690), which added a books translation group in the same places this PR adds hooks. They were in translation.zod.ts (three alias lines and the group list in a refusal sentence), i18n-standard.mdx (the group list), the lint test (two new describe blocks side by side) and ui/packages/i18n SPEC_TRANSLATION_GROUPS. Each one kept both sides.
  • What moved because the two groups now stack:
    • The spelled group counts in translation.zod.ts and i18n-standard.mdx go from fifteen to sixteen per-app groups, and from sixteen to seventeen on the platform face.
    • adminPreviews.i18n-10862-s2 reads 2/16 (13%).
    • Regenerated: the strictness-ledger count for system/ (386 → 388) and the liveness state count for translation (41 → 42).
  • What did not move: this PR's own accept set, its published surface and its semver level. hooks, HookSchema.refusals and both save-time refusals are byte-identical to e5597836. The changeset is unchanged. The delta is counts and baselines from stacking two groups, so the round-3 contract record still applies to this change.
  • Checks run on the merged tree, under the lock:
    • spec src/system + src/data: 6328 passed.
    • spec typecheck: exit 0.
    • lint validate-translation-references: 219 passed.
    • cli i18n-hook-refusal-coverage: 7 passed.
    • ui metadata-admin/previews + ui/packages/i18n: 3040 passed.
    • check:i18n and check:i18n-walk-parity (16 declared groups, all with an extractor face): exit 0.
    • spec check:generated: current.
  • Approval: this push may have dismissed the maintainer's approval. If so, @os-zhuang's approval is needed again on a1dbd6eaa1.

Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Seat reading of the main merge · domain:spec seat 3 · os-support-ai · session session_01WGufzgbJhFoXYsLSe5WSPP · 2026-10-28T01:13Z

The triage note 6457569500 asked for a merge of main. The seat compared the PR's net diff against its merge base at e5597836 and at the new head a1dbd6ea:

So the merge moves only regenerated counts and baselines. The PR's own accept set, published surface and semver level are unchanged. Contract review round 3 (6452063072) and ACCEPT 6453820397 stand for a1dbd6ea, and no new round is owed.

Landing state:

  • os-zhuang's APPROVED review (5675480122) is on file.
  • needs-user-decision is off.
  • The PR is ready, and auto-merge is armed.

The seat follows CI on a1dbd6ea and the queue to MERGED.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: a1dbd6eaa144abbeecd35b2dd82a46a51c45e2ae
Local-runs: none
Scope: packages/spec/src/data/hook-refusal-codes.ts, packages/spec/src/data/hook.zod.ts, packages/spec/src/data/index.ts, packages/spec/src/system/i18n-resolver.ts, packages/spec/src/system/translation.zod.ts, skills/objectstack-data/references/_index.md, skills/objectstack-data/references/data-hooks.md, skills/objectstack-platform/references/_index.md + every file the three delta commits touch, read as the delta's own claims: the five conflict files of the merge (content/docs/protocol/kernel/i18n-standard.mdx, packages/lint/src/validate-translation-references.test.ts, packages/spec/liveness/state-counts/translation.md, packages/spec/src/system/translation.zod.ts, ui/packages/i18n/src/utils/spec-translations.ts), the five other files the merge interleaved (content/docs/ui/translations.mdx, packages/cli/src/utils/i18n-coverage.ts, packages/cli/src/utils/i18n-extract.ts, ui/packages/app-shell/src/views/metadata-admin/i18n.ts, ui/packages/app-shell/src/views/metadata-admin/previews/TranslationPreview.tsx, ui/packages/app-shell/src/views/metadata-admin/previews/adminPreviews.i18n-10862-s2.test.tsx), the seven files of the regeneration (content/docs/references/api/protocol.mdx, content/docs/references/system/translation.mdx, docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md, packages/spec/api-surface/system.json, packages/spec/authorable-surface/system.json, packages/spec/export-origins/system.json, packages/spec/liveness/state-counts/translation.md), and the files the scope hunks make a claim about (.changeset/26170-hook-refusal-catalogue.md, packages/spec/src/data/hook-refusals.test.ts, packages/spec/liveness/hook.json, packages/spec/liveness/translation.json, packages/spec/liveness/state-counts/hook.md, docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md, packages/spec/api-surface/data.json, packages/spec/authorable-surface/data.json, packages/spec/declaration-map/data.json, packages/spec/export-origins/data.json, packages/spec/json-schema.manifest/data.json, content/docs/references/data/hook.mdx, content/docs/references/index.mdx, packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts, packages/spec/src/type-alias-convention.pin.test.ts, packages/spec/src/system/metadata-form-zod-reconciliation.test.ts, scripts/fixtures/i18n-walk-parity/every-group.stack.json, ui/packages/app-shell/src/views/metadata-admin/previews/TranslationPreview.categories-11765.test.tsx). The runtime, objectql and cli source files are byte-identical to the round-3 head (no delta commit touches them) and stay the lane seat's ACCEPT at TIER_DEFAULT.

Round 4 of 6449923427 / 6451717284 / 6452063072 (PASS on e5597836), on a moved head. Inputs read: card #26170 body and all 8 comments (decision 6430864109, brief 6431195984, ruling 6431311681 letter A, claim 6442821427, os-dev-report 6449646620, ACCEPT pointer 6453797050); PR #26831 body (as applied by the seat), the 52-path file list, the net diff against main at this head (merge-base 31a608eb58, 52 files +1784/-99, the file list identical to the round-3 list), all 12 PR comments including the three dev PASS/FAIL records (read as claims), the dev's merge note 6457974217 and the seat's reading 6457996903; the check-runs on a1dbd6eaa1 (63 runs, every one completed); ruling 6431004737 (#26369 letter B, the Skills-docs: line) and the follow-up cards #26834 / #26835 by gh api GET; the three delta commits one by one with git show, the merge recomputed with git merge-tree --write-tree e5597836 31a608eb, and every file at this head with git show prr-26831:PATH. Nothing checked out, built or run.

① Derived judgments

Gate readings on this head (63 check-runs, all completed; 57 success, 6 skipped, 0 red, 0 cancelled, 0 in progress). The eight required contexts conclude success: Lint & Repo Gates, TypeScript Type Check, Test Core (and shards 1-8), Build Core, Governed Surface Queue Guard, Lint UI, Type Check UI, Build & E2E UI; so do Type Check · source gates (the job carrying check:generated --reconcile-only, check:docs, check:authorable-surface, check:skill-docs and the published-skills token ratchet), Type Check · consumer gates (check:i18n, check:i18n-walk-parity), Spec property liveness, Check Changeset, Lint · docs & code guards, Test UI (and shards 1-16), Check PR Size, PR-body guards, Dogfood Regression Gate (umbrella). The six skips are the opt-in battery (Temporal Conformance, Dogfood Verify CLI, the Dogfood shard matrix template, 18.x upgrade harness, packed-tarball smoke, UI Published Artifacts), not owed on a PR with no ! and no (narrowing) arm. mergeable_state: clean at the read.

The merge f27ff28a4a (parents e5597836, 31a608eb) — recomputed, no evil content, one stale count. git merge-tree --write-tree of the two parents yields tree ea9006ee with exactly five conflicted paths: i18n-standard.mdx, the lint test, state-counts/translation.md, translation.zod.ts, spec-translations.ts. The head a1dbd6eaa1 differs from that clean recomputation in exactly seven files, each read line by line: (1) i18n-standard.mdx — the group list keeps both hooks and books in declaration order, and "fifteen declared groups" becomes "sixteen" (a both-sides-changed line auto-merge could not count). (2) The lint test — the hook refusals (#26170) describe and main's doc books (#26690) describe both kept, one blank line between; no assertion of either changed. (3) state-counts/translation.md — 39 (PR) vs 41 (main) resolved to the regenerated 42. (4) translation.zod.ts — the three alias lines keep both hook: 'hooks' and book: 'books'; the PER_APP_SETTINGS_PLATFORM_ONLY group list keeps both groups in order; the spelled counts move fifteen→sixteen (per-app, four places) and sixteen→seventeen (platform face, two places). (5) spec-translations.ts — both 'hooks' and 'books' with their comments. (6) strictness-ledger.counts/system.md 386→388, the regeneration. (7) the one-line test change, below. Every other line of the head equals the auto-merge, so no conflict resolution reached outside its hunk. The ten files git show --cc lists as differing from both parents are exactly the hooks/books interleavings (the two cli walkers' union types and noun maps, the console en/zh labels, CATEGORY_DEFS, the preview test's category list) and carry nothing a parent did not. The PR's accept set and public surface are unchanged by the merge: hook-refusal-codes.ts, hook.zod.ts, index.ts, i18n-resolver.ts, the runtime sandbox files, objectql, both skills _index.md and data-hooks.md are byte-identical to e5597836, and the net-diff hunks of the eight hits paths differ from the old head's only in line offsets and the counts above (diffed hunk against hunk).

The one line the merge left wrong — non-blocking, must fix. packages/spec/src/system/translation.zod.ts:2082, the TSDoc on TranslationDataSchema, still reads "Fifteen groups: every group the platform bundle declares EXCEPT settings", while appTranslationDataShape() at this head has sixteen keys (objects picklists apps messages globalActions dashboards datasets reports pages flows hooks positions permissions books metadataForms settingsCommon) and the same comment block says "the seventeen-group face" three lines below. Cause: both parents edited that line identically (Fourteen→Fifteen), so git kept it without a conflict and the hand pass that moved the other six counts did not reach it. The dev's note 6457974217 ("the spelled group counts in translation.zod.ts … go from fifteen to sixteen") and the seat's reading 6457996903 ("the PR's prose now says … sixteen instead of fifteen") are each untrue at this one line. It is a /** */ comment, not a describe, a refusal sentence, the JSON Schema or a generated doc — the user-read sentence PER_APP_SETTINGS_PLATFORM_ONLY says sixteen correctly, and no gate reads the comment — so it moves no accept set, surface or level and is not a FAIL. One-word fix ("Sixteen"); whoever lands it decides whether before landing (which moves the head and owes a one-line re-read) or as a trailing docs commit.

The regeneration 189bc37962 — the generator's output, every moved count explained. Seven files. system.md 386→388: main's 386 already holds the two books strictObjects (#26690); this PR adds two in translation.zod.ts (the hooks entry and its refusals entry) = 388. state-counts/translation.md 41→42: main's 41 holds the three books leaves; this PR adds one leaf, hooks.refusals.message (the group is drilled two levels, containers are not counted — the same rule gives hook 21→23 for the two leaves refusals.message / refusals.status). api-surface/system.json, export-origins/system.json, authorable-surface/system.json: the regen ADDS main's own rows (BookGroupLike, BookLike, translateBook; *TranslationData:books, TranslationItem:books), which the merge had dropped by taking the PR's side of those generated files; at this head each file carries both sides (hookRefusalMessageKey and translateBook both present; :hooks and :books rows both present on all four translation faces). references/system/translation.mdx and references/api/protocol.mdx: the regen restores main's describes (disabledReason wording, $select / select / count refusals, the member row, the navigation enum) and adds the books nested shapes — main's content, not this PR's. Cross-check: the head's generated artifacts differ from origin/main at the merge-base in 18 files, +143/-17, and every added row is a Hook / HookRefusal / hooks row or a count this PR moves (hook 22→23 properties, data/ 164→165 sites with hook.zod.ts 7→8, translation 1556→1557 schemas, ADR-0122 pins 796→797); nothing of main's is missing. Type Check · source gates (check:generated --reconcile-only) is success on this head, which is the gate's own verdict that the committed artifacts are the generator's.

The test change a1dbd6eaa1 — arithmetic only. One line in adminPreviews.i18n-10862-s2.test.tsx: '2/15 (13%)' → '2/16 (13%)'. CATEGORY_DEFS at this head has sixteen keys (hooks from this PR, books from main), CATEGORIES is Object.entries(CATEGORY_DEFS), and the preview computes Math.round((populated / CATEGORIES.length) * 100) = Math.round(12.5) = 13. The assertion is still an exact expectAsWritten string; the category list above it names all sixteen categories; nothing was loosened. Main's own version of the line was 2/15 (13%), so the change is exactly main's new group.

Round-3 judgments re-read on this head, unchanged. Ruling A shape: HookSchema.refusals is a record keyed by HOOK_REFUSAL_CODE_PATTERN of HookRefusalSchema (message trimmed, non-empty, HOOK_REFUSAL_MESSAGE_WITHOUT_HOLE; status int 400-499 optional), closed with the aliases and guidance; refuseUndeclaredLiteralRefusalCode is the third superRefine (returns when no refusals; refuses at refusals when body.language !== 'js'; one issue per undeclared literal code at body.source); hook-refusal-codes.ts is still an import-free leaf, re-exported from data/index.ts; hookRefusalMessageKey spells hooks.HOOK.refusals.CODE.message; the hooks group is on appTranslationDataShape (so on TranslationData, PlatformTranslationData, TranslationItem) with hook→hooks on all three alias maps and refusal/errors/messages→refusals; HookContext.session.locale declared. Skills: both _index.md lists read HookRefusalSchema, HookEvent, HookSchema, HookContextSchema, matching hook.zod.ts exports; data-hooks.md's section is true at this head line by line (SCREAMING_SNAKE codes, declaredCode on the envelope, refused at save without a js body, plain text with {{ }} refused, translated via the key, body text and status unused, undeclared → save-time if literal else 500, unchanged ctx.api errors and crashes pass through). The hand-written i18n docs are right at this head: i18n-standard.mdx lists sixteen groups with hooks in order and carries the two hooks.{hook} / hooks.{hook}.refusals.{code} target rows; ui/translations.mdx carries the hook-refusal row beside main's books row. Liveness rows hook.refusals (+message, status) and translation.hooks → refusals → message are live with the runtime reader cited and the corrected handler-path sentence. The form-reconciliation omit entry, CARD_PROPERTY_COUNTS.hook: 23 (main's HookSchema has no new top-level key since the merge-base, read off the file), the ADR-0122 pin and the walk-parity fixture (parity_guard with a js body and one declared code) are as round 3 read them.

② Semver level

Unchanged by the delta and right: .changeset/26170-hook-refusal-catalogue.md is byte-identical to the round-3 head — minor on @objectstack/spec, @objectstack/runtime, @objectstack/objectql, @objectstack/cli, @objectstack/lint; patch on @object-ui/i18n and @object-ui/app-shell (fixed group, so both take the release's minor; the line level is a CHANGELOG-section choice the seat recorded as kept). Clause-②: yes (widening) and Metadata-protocol: added on the PR body and in the changeset body alike; a kind other than none takes at least minor, no ! is owed (a hook without refusals is answered as before; both save-time refusals bind a key born in this PR), no breaking changeset so no ADR-0087 marker. The merge brought no change to what this PR publishes: the generated surface at this head vs main is the same set of Hook / HookRefusal / hooks rows round 3 read, now beside main's books. Check Changeset is success. The Skills-docs: line reads follow-up #26834, one of ruling B's four values (6431004737: "follow-up card #N"); #26834 exists, open, pm:queue, domain:skills, naming #26170 as the owing card — the line is well-formed and true.

③ Boundary flags

No FAIL. Every flag answered or escalated:

  • Count prose at translation.zod.ts:2082 (dev or seat, must fix; non-blocking). "Fifteen groups" → "Sixteen groups" on the TranslationDataSchema TSDoc, as stated in ①. The two merge notes that claim every count moved are each wrong at this line; nothing published or gated reads it.
  • Open question (params) — stands as answered in rounds 1-3. Plain text now, {{ }} refused at save on the declared message, so a later params slot is additive; the translation-side value is a plain string with no hole guard (a note for the widening card, not this PR).
  • PR body Verification is stated at e55978368d, not at this head. The merged-tree runs for this head are in the dev's comment 6457974217 (spec src/system + src/data 6328, lint 219, cli 7, ui previews + i18n 3040, check:i18n, walk-parity with 16 groups, check:generated current, typecheck 0); the head's check-runs are the gate verdicts and are complete. Not a published sentence; the seat may refresh the body's head reference before landing.
  • Follow-ups filed and open: skills(objectstack-data): no eval exercises a hook's refusals catalogue, because hooks-security-seeds-datasources.json sits at its token ceiling (follow-up owed by #26170) #26834 (skills eval for refusals, pm:queue), Studio hook form: refusals cannot be authored — the record editor stamps the key into a closed entry; needs a key-only record control (follow-up owed by #26170) #26835 (Studio key-only record control, pm:queue, domain:ui). Both read by GET.
  • Landing state, read not judged: os-zhuang's APPROVED review 5675480122 is on e5597836 and still reads APPROVED after the push; the PR is draft: false, mergeable_state: clean, and the API showed no auto_merge block at this review's read (2026-10-28, after 01:13Z) although the seat's 6457996903 says auto-merge is armed — the seat re-arms or confirms before expecting the queue to take it. Tier H (skills/**) stays the maintainer's hand.
  • Every deviation the dev named stands as accepted in rounds 1-3; the merge added none.

Implemented-by: claude/project-thread-ivr552
Reviewed-by: session_01AavokzJ5DndAwitDXvKy4U

VERDICT: PASS


Generated by Claude Code

Merged via the queue into main with commit b3f7344 Oct 28, 2026
67 checks passed
@os-zhuang
os-zhuang deleted the claude/project-thread-ivr552 branch October 28, 2026 02:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

3 participants