Skip to content

Commit 39fd5fb

Browse files
feat(app-shell): a key-only record control, and the Studio hook form offers refusals with it (#26835) (#27160)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8G6QZjYQCXktarYR4vyuASc)_ Fixes #26835 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:Studio 钩子表单新增「Refusals」一行,可以逐条填写拒绝码、提示语和状态码;表单的 record 编辑器新增一个通用的「只用键」模式,任何 record 字段声明后都能用。 - 为什么改:原来的 record 编辑器会把键名写进每个条目,而钩子的拒绝条目是封闭对象,写进去就被 spec 拒收,所以 Studio 里根本没法配置可翻译的拒绝提示。 - 风险与回滚:只有声明了 `widget: 'key-only-record'` 的字段走新逻辑,其余 record 字段行为不变(有测试钉住);回滚即撤销本 PR。 - 先看:`ui/packages/app-shell/src/views/metadata-admin/SchemaForm.tsx` 的 RecordField 改动(键名不写入、改名逻辑);`packages/spec/src/data/hook.form.ts` 新增的一行。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/metadata-admin/SchemaForm.keyOnlyRecord-26835.test.tsx`,预期 8 个测试全绿。 - 需要你做的:无,席位落地。 ## What changes - **Key-only record face** (`SchemaForm.tsx`, `RecordField`). A `type: 'record'` form row that declares `widget: 'key-only-record'` (`KEY_ONLY_RECORD_WIDGET`) edits a map of closed entries: add, rename, remove and reorder keys, and edit each entry's fields, without writing the key into the entry. A key is checked against the record's served key rule (`propertyNames.pattern`, which the server derives from the spec's key schema), so the rule is never restated in the UI. A rename moves the entry in place once the typed key is valid, so an invalid intermediate spelling stays typeable. A record row with no such widget is untouched: the key is still mirrored into the entry. - **Hook form** (`packages/spec/src/data/hook.form.ts`). The Body section offers `refusals` with that control, with `message` (textarea, required) and `status` (number) per entry. - **Gate ledger** (`metadata-form-zod-reconciliation.test.ts`). The `hook` root omission row for `refusals` that the hook refusal catalogue PR recorded is removed: the form now offers the key, and the reconciliation reads `refusals.message` / `refusals.status` against `HookRefusalSchema`. - **i18n.** `engine.form.key` (en `Key`, zh `键名`) names the key column when the form declares no key label. - **Translations.** The generated metadata-form bundles carry the three refusals rows, translated in zh-CN, ja-JP and es-ES; three platform-objects census pins and one cli coverage filter move with them. - **Changeset.** Patch for `@objectstack/spec`, `@objectstack/platform-objects`, `@object-ui/app-shell`, `@object-ui/i18n`. ## Why After the hook refusal catalogue landed, a hook can declare `refusals: { CODE: { message, status? } }` so its refusal messages are translatable, but the Studio hook form could not author it: the generic record editor stamps each key into its entry (`name` by default), and a refusal entry is closed at `message` / `status`, so the stamped key is refused. The card asked for a generic key-only record control, opted into by declaration, and for the hook form to use it. ## Tests New file `SchemaForm.keyOnlyRecord-26835.test.tsx` (8 tests). Everything it reads comes from the spec: the form row is `hookForm`'s own, the field schema is the served derivation of `HookSchema` (`z.toJSONSchema` with `io: 'input'`), and the key rule is checked equal to `HOOK_REFUSAL_CODE_PATTERN.source`. - the hook form declares `refusals` as a record on the key-only face; - adding an entry writes no key into it; - a key that breaks the served rule (`clean_version`) is refused and the value is unchanged; - a rename moves the entry in place, unstamped, through an invalid intermediate spelling; a rename onto an existing key is refused; - round trip: two refusals authored in the form save a body `HookSchema` accepts, the parsed `refusals` serialise byte-identical to the saved ones, and the saved body reloads into the same rows in the same order; - control: a record row with no widget still mirrors the key, and `HookSchema` refuses that body with `unrecognized_keys` `['name']` at the entry. Ablation, one-off, restored by `scripts/ablation-replace.mjs` (blob back to HEAD `892c144b`, `git diff HEAD` empty): `const keyOnly = widget === KEY_ONLY_RECORD_WIDGET;` replaced with `const keyOnly = false;` (the hook row falls back to the stamping editor) turns 4 of 8 red, the round trip among them; the 4 that stay green are the declaration, the served-rule and the default-face pins, which do not read the face. ## Acceptance notes - The control refuses a bad key with the generic `engine.form.keyPattern` text naming the served pattern, not with the sentence `HookSchema` attaches to the record key ("A refusal code is SCREAMING_SNAKE …"). That sentence is inline in `hook.zod.ts` and the served JSON Schema does not carry it, so a generic control has no way to read it without restating it. The row's own help text states the spelling. - `keyField.field`, `regex` and `immutable` are not read on the key-only face (nothing is stamped, and a key is always renamable). Declaring `keyField` on such a row would also make the reconciliation gate count `keyField.field` as an offered member, so the hook row declares none. ## Verification Local, on the branch head named in the report: the new file 8/8; `ui` vitest over `packages/app-shell/src/views/metadata-admin/` and `packages/i18n/` 727 files green; spec vitest over `src/system/`, `src/data/hook*.test.ts`, `src/kernel/repeater-item-titles.test.ts` 65 files / 2208 tests green; `type-check` for `@object-ui/app-shell`, `@object-ui/i18n`, `@objectstack/spec` and `type-check:scripts` exit 0. The derived gate list (84 commands) is reconciled with `dispatch-gates --ran` in the report; `check:i18n` and `check:dual-build-cjs-loads` need a whole-repo build and are left to CI's Lint and Repo Gates. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes (CI). - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: no generated artifact moves (a form row, no schema change). - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01JQwxbaFd8s4eYs6HGFghK3 --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbaFd8s4eYs6HGFghK3)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9dacccf commit 39fd5fb

15 files changed

Lines changed: 409 additions & 42 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/platform-objects': patch
4+
'@object-ui/app-shell': patch
5+
'@object-ui/i18n': patch
6+
---
7+
8+
feat(app-shell): a key-only record control, and the Studio hook form offers `refusals` with it
9+
10+
Clause-②: no
11+
Metadata-protocol: none
12+
13+
A hook built in Studio can now declare its refusal catalogue. The hook form shows a **Refusals** row beside the body: add a code, write the message the user reads and an optional 4xx status, rename or remove a code. The saved body is exactly `refusals: { CODE: { message, status? } }`, the shape `HookSchema` accepts, so a hook made in Studio no longer has to be edited as code to refuse in translatable text.
14+
15+
- **`@object-ui/app-shell`.** The metadata form's `record` editor gains a key-only face. A `type: 'record'` row that declares `widget: 'key-only-record'` edits a map of closed entries (add, rename, remove, reorder, edit each entry's fields) and never writes the key into the entry. A key is checked against the record's served key rule (`propertyNames.pattern`, derived from the spec), so a code like `clean_version` is refused before it reaches the draft. A rename moves the entry in place once the new key is valid. A record row with no such widget behaves as before: the key is still mirrored into each entry.
16+
- **`@objectstack/spec`.** `hookForm` offers `refusals` with that control. `HookSchema` is unchanged.
17+
- **`@objectstack/platform-objects`.** The generated metadata-form bundles carry the three new hook-form rows, translated in zh-CN, ja-JP and es-ES.
18+
- **`@object-ui/i18n`.** One new Studio string, `engine.form.key` (en, zh), which names the key column when the form declares no key label.

‎packages/cli/test/i18n-flow-refusal-coverage.test.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,13 @@ describe('the coverage gate demands the refusal (#22450)', () => {
7979
flows: [quoteGeneration],
8080
translations: [{ 'zh-CN': { ...(flows ? { flows } : {}) } }],
8181
});
82+
// Scoped to the flows bucket: the metadata-form catalogue has `refusals` keys
83+
// of its own since the hook form offers its refusal catalogue (#26835), and
84+
// this synthetic tree translates no metadata form.
8285
const zhRefusals = (config: any) =>
83-
computeI18nCoverage(config).issues.filter((i) => i.locale === 'zh-CN' && i.key.includes('.refusals.'));
86+
computeI18nCoverage(config).issues.filter(
87+
(i) => i.locale === 'zh-CN' && i.key.startsWith('flows.') && i.key.includes('.refusals.'),
88+
);
8489

8590
it('reports the untranslated refusal in the flow bucket', () => {
8691
const issues = zhRefusals(tree());

‎packages/platform-objects/src/apps/translations/action-body-panel-echo-decisions.test.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -701,11 +701,15 @@ describe('#19403 round 5 — the blind spot, FIFTH shape: one schema, two forms,
701701
// it asserts `carrying.has('hook') === false`, and that assertion is TRUE
702702
// and CORRECT — `hookForm` enumerates no repeater at all — while seven
703703
// children sit enumerated and outside it.
704+
// 9 since #26835: the hook form offers the `refusals` record, whose two
705+
// entry fields (`message`, `status`) are enumerated the same way.
704706
expect(HOOK_ENUMERATED.covered.length, 'hookForm enumerates no repeater row properties').toBe(0);
705-
expect(HOOK_ENUMERATED.skipped.length).toBe(7);
707+
expect(HOOK_ENUMERATED.skipped.length).toBe(9);
706708
for (const child of SHARED_BODY_CHILDREN) expect(HOOK_ENUMERATED.skipped).toContain(`body.${child}`);
707709
expect(HOOK_ENUMERATED.skipped).toContain('retryPolicy.maxRetries');
708710
expect(HOOK_ENUMERATED.skipped).toContain('retryPolicy.backoffMs');
711+
expect(HOOK_ENUMERATED.skipped).toContain('refusals.message');
712+
expect(HOOK_ENUMERATED.skipped).toContain('refusals.status');
709713
//
710714
// ⇒ one composite, two forms, two different greens: a lit control that
711715
// passes on a sibling repeater, and a dark control that is right about

‎packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -937,6 +937,18 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
937937
label: "Memory Mb",
938938
helpText: "Per-invocation memory cap (MB, max 256)"
939939
},
940+
refusals: {
941+
label: "Refusals",
942+
helpText: "Refusal catalogue, keyed by the SCREAMING_SNAKE code the body throws (e.g. CLEAN_VERSION_REQUIRED). The caller reads the declared message, translated into their language, with the declared status. JavaScript (L2) bodies only"
943+
},
944+
"refusals.message": {
945+
label: "Message",
946+
helpText: "The sentence the user reads when the body throws this code — plain text, with no placeholders"
947+
},
948+
"refusals.status": {
949+
label: "Status",
950+
helpText: "HTTP status of the refusal (400–499). Absent: 400"
951+
},
940952
handler: {
941953
label: "Handler",
942954
helpText: "Handler function name (deprecated — prefer `body`)"

‎packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -937,6 +937,18 @@ export const esESMetadataForms: NonNullable<TranslationData['metadataForms']> =
937937
label: "Memoria (MB)",
938938
helpText: "Límite de memoria por invocación (MB, máx. 256)"
939939
},
940+
refusals: {
941+
label: "Rechazos",
942+
helpText: "Catálogo de rechazos, indexado por el código SCREAMING_SNAKE que lanza el cuerpo (p. ej. CLEAN_VERSION_REQUIRED). Quien llama recibe el mensaje declarado, traducido a su idioma, con el estado declarado. Solo cuerpos JavaScript (L2)"
943+
},
944+
"refusals.message": {
945+
label: "Mensaje",
946+
helpText: "La frase que lee el usuario cuando el cuerpo lanza este código — texto plano, sin marcadores"
947+
},
948+
"refusals.status": {
949+
label: "Estado",
950+
helpText: "Estado HTTP del rechazo (400–499). Si se omite: 400"
951+
},
940952
handler: {
941953
label: "Manejador",
942954
helpText: "Nombre de función manejadora (obsoleto — preferir `body`)"

‎packages/platform-objects/src/apps/translations/hook-execution-panel-echo-decisions.test.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -782,9 +782,10 @@ describe('#19403 round 6 — the composite blind spot, hook side, CLOSED', () =>
782782
it('the derivation reproduces round 5\'s measurement of what the repeater pin cannot see', () => {
783783
// `hookForm` enumerates no repeater at all, so `repeater-row-properties.test.ts`
784784
// is blind here through a DARK control that is TRUE and CORRECT — the shape
785-
// round 5 wrote down. Seven children sit enumerated and outside it.
785+
// round 5 wrote down. Seven children sit enumerated and outside it — 9
786+
// since #26835, when the `refusals` record's `message` / `status` joined.
786787
expect(HOOK_ENUMERATED.covered.length, 'hookForm enumerates no repeater row properties').toBe(0);
787-
expect(HOOK_ENUMERATED.skipped.length).toBe(7);
788+
expect(HOOK_ENUMERATED.skipped.length).toBe(9);
788789
for (const child of SHARED_BODY_CHILDREN) expect(HOOK_ENUMERATED.skipped).toContain(`body.${child}`);
789790
expect(HOOK_ENUMERATED.skipped).toContain('retryPolicy.maxRetries');
790791
expect(HOOK_ENUMERATED.skipped).toContain('retryPolicy.backoffMs');

‎packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -937,6 +937,18 @@ export const jaJPMetadataForms: NonNullable<TranslationData['metadataForms']> =
937937
label: "メモリ(MB)",
938938
helpText: "呼び出しごとのメモリ上限(MB、最大 256)"
939939
},
940+
refusals: {
941+
label: "拒否カタログ",
942+
helpText: "本体がスローする SCREAMING_SNAKE のコード(例: CLEAN_VERSION_REQUIRED)をキーとする拒否カタログ。呼び出し元には宣言したメッセージ(その言語に翻訳)と宣言したステータスが返ります。JavaScript(L2)本体のみ"
943+
},
944+
"refusals.message": {
945+
label: "メッセージ",
946+
helpText: "本体がこのコードをスローしたときにユーザーが読む文 — プレーンテキスト、プレースホルダーなし"
947+
},
948+
"refusals.status": {
949+
label: "ステータス",
950+
helpText: "拒否の HTTP ステータス(400–499)。未指定: 400"
951+
},
940952
handler: {
941953
label: "ハンドラー",
942954
helpText: "ハンドラー関数名(非推奨 — `body` を推奨)"

‎packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1233,7 +1233,9 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => {
12331233
// 690 since #26602: the dataset form's measures repeater offers `polarity`
12341234
// (which direction of a KPI delta is good news) — one row label, authored
12351235
// in all three locales.
1236-
expect(translated.length, `${locale} positive control`).toBe(690);
1236+
// 693 since #26835: the hook form's `refusals` record and its `message` /
1237+
// `status` entry fields — three row labels, authored in all three locales.
1238+
expect(translated.length, `${locale} positive control`).toBe(693);
12371239
}
12381240
// ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the
12391241
// all-three predicate returns 0 while the per-locale one returns 1, so the

‎packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -937,6 +937,18 @@ export const zhCNMetadataForms: NonNullable<TranslationData['metadataForms']> =
937937
label: "内存(MB)",
938938
helpText: "单次调用内存上限(MB,最大 256)"
939939
},
940+
refusals: {
941+
label: "拒绝目录",
942+
helpText: "拒绝目录,以正文抛出的 SCREAMING_SNAKE 错误码为键(例如 CLEAN_VERSION_REQUIRED)。调用方读到声明的提示语(按其语言翻译)和声明的状态码。仅适用于 JavaScript(L2)正文"
943+
},
944+
"refusals.message": {
945+
label: "提示语",
946+
helpText: "正文抛出此错误码时用户读到的句子——纯文本,不含占位符"
947+
},
948+
"refusals.status": {
949+
label: "状态码",
950+
helpText: "拒绝的 HTTP 状态码(400–499)。不填:400"
951+
},
940952
handler: {
941953
label: "处理器",
942954
helpText: "处理器函数名(已废弃——建议使用 body)"

‎packages/spec/src/data/hook.form.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { defineForm } from '../ui/view.zod';
88
* Hooks intercept the data lifecycle. The body field is a composite
99
* (`{ language, source, capabilities?, timeoutMs?, memoryMb? }`) whose
1010
* `source` renders in a Monaco code editor with language-appropriate
11-
* syntax highlighting.
11+
* syntax highlighting. The body's refusal catalogue (`refusals`) sits beside it.
1212
*/
1313
export const hookForm = defineForm({
1414
schemaId: 'hook',
@@ -46,6 +46,19 @@ export const hookForm = defineForm({
4646
{ field: 'memoryMb', type: 'number', helpText: 'Per-invocation memory cap (MB, max 256)' },
4747
],
4848
},
49+
// The catalogue is a record keyed by the code, and an entry has no
50+
// member that echoes its key — so the row takes the key-only record
51+
// control, which never writes the key into the entry (#26835).
52+
{
53+
field: 'refusals',
54+
type: 'record',
55+
widget: 'key-only-record',
56+
helpText: 'Refusal catalogue, keyed by the SCREAMING_SNAKE code the body throws (e.g. CLEAN_VERSION_REQUIRED). The caller reads the declared message, translated into their language, with the declared status. JavaScript (L2) bodies only',
57+
fields: [
58+
{ field: 'message', type: 'textarea', required: true, helpText: 'The sentence the user reads when the body throws this code — plain text, with no placeholders' },
59+
{ field: 'status', type: 'number', helpText: 'HTTP status of the refusal (400–499). Absent: 400' },
60+
],
61+
},
4962
],
5063
},
5164
{

0 commit comments

Comments
 (0)