From ac0a9c6c707d725306c29bf9ccb081f846098713 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 07:36:27 +0000 Subject: [PATCH 1/3] feat(app-shell): a key-only record control, and the Studio hook form offers refusals with it Claude-Session: https://claude.ai/code/session_01JQwxbaFd8s4eYs6HGFghK3 Co-authored-by: Claude --- packages/spec/src/data/hook.form.ts | 15 +- .../metadata-form-zod-reconciliation.test.ts | 9 -- .../src/views/metadata-admin/SchemaForm.tsx | 135 ++++++++++++++---- ui/packages/i18n/src/namespaces/studio/en.ts | 1 + ui/packages/i18n/src/namespaces/studio/zh.ts | 1 + 5 files changed, 124 insertions(+), 37 deletions(-) diff --git a/packages/spec/src/data/hook.form.ts b/packages/spec/src/data/hook.form.ts index 2d21c410f54..bed8c2aa62b 100644 --- a/packages/spec/src/data/hook.form.ts +++ b/packages/spec/src/data/hook.form.ts @@ -8,7 +8,7 @@ import { defineForm } from '../ui/view.zod'; * Hooks intercept the data lifecycle. The body field is a composite * (`{ language, source, capabilities?, timeoutMs?, memoryMb? }`) whose * `source` renders in a Monaco code editor with language-appropriate - * syntax highlighting. + * syntax highlighting. The body's refusal catalogue (`refusals`) sits beside it. */ export const hookForm = defineForm({ schemaId: 'hook', @@ -46,6 +46,19 @@ export const hookForm = defineForm({ { field: 'memoryMb', type: 'number', helpText: 'Per-invocation memory cap (MB, max 256)' }, ], }, + // The catalogue is a record keyed by the code, and an entry has no + // member that echoes its key — so the row takes the key-only record + // control, which never writes the key into the entry (#26835). + { + field: 'refusals', + type: 'record', + widget: 'key-only-record', + 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', + fields: [ + { field: 'message', type: 'textarea', required: true, helpText: 'The sentence the user reads when the body throws this code — plain text, with no placeholders' }, + { field: 'status', type: 'number', helpText: 'HTTP status of the refusal (400–499). Absent: 400' }, + ], + }, ], }, { diff --git a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts index 1ed3c53acdd..8d23c62903d 100644 --- a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts +++ b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts @@ -331,15 +331,6 @@ const LEDGER: ReadonlyArray = [ key: 'requires', why: "platform-written, never authored — the schema's own words: `derived from the source at save — omit it`. The key exists only on an `html` / `jsx` page, where, on a server with the deployment's SDUI manifest, the save door stamps the compiled list and refuses a written list that disagrees (`page-requires-disagrees-with-source`); on every other kind the parse refuses it; and the Studio page editor drops the key on every save. A control would invite the list the describe tells every author to omit", }, - // #26170 — a hook's refusal catalogue is a record keyed by the code, and an - // entry has no member that echoes its key. - { - kind: 'omit', - type: 'hook', - path: ROOT_PATH, - key: 'refusals', - why: "no record control fits it — the form's `record` editor stamps each entry's key into the entry under `keyField` (`{ ...entry, [keyField]: key }`), and a refusal entry is closed at `message` / `status`: the key IS the code, so a stamped `code` (or the default `name`) is refused by `HookRefusalSchema`. The catalogue is authored beside the body it serves, in the hook's source; offering it needs a key-only record control, a form-face addition rather than a reconciliation", - }, // #22158 — the print declaration (ruling B′ on #8346) lands spec-first: // `planned` in the liveness ledger, read by no renderer until card ② (the // console's print rendering), which is also where the ruling puts its diff --git a/ui/packages/app-shell/src/views/metadata-admin/SchemaForm.tsx b/ui/packages/app-shell/src/views/metadata-admin/SchemaForm.tsx index 9c92bf6f7ef..892c144b4c1 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/SchemaForm.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/SchemaForm.tsx @@ -2906,6 +2906,40 @@ function RepeaterField({ /* ----- RecordField — Record editor -------------------------- */ +/** + * The `widget` a `type: 'record'` form row declares to get the KEY-ONLY face + * of {@link RecordField} (objectstack#26835). + * + * The default face mirrors each entry's key into the entry (`keyField.field`, + * `name` unless declared). That is right for a map of self-describing items + * (an object's `fields`), and wrong for a map whose entries are closed objects + * with no member that echoes the key: there the stamped key is an unknown key + * the schema refuses. A hook's `refusals` catalogue is that shape — the key IS + * the refusal code, and an entry is `{ message, status? }` and nothing else. + * + * The key-only face edits the same `Record`: add, rename and + * remove keys, edit each entry's fields, reorder by drag — and never writes the + * key into the value. A key is checked against the record's own served key + * schema (`propertyNames.pattern`, which the server derives from the spec's key + * schema), so the rule is the contract's and is never restated here; + * `keyField.field` / `regex` / `immutable` are not read (there is no stamped + * member, and a key is always renamable), while its `label` / `placeholder` / + * `helpText` still name the key column. + * + * Opt-in is by declaration on the form row (`widget: 'key-only-record'`), so + * any record field can take it and no metadata type is named here. + */ +export const KEY_ONLY_RECORD_WIDGET = 'key-only-record'; + +/** + * The key rule a record's served schema declares, or `null` when it declares + * none. Read for the key-only face only — see {@link KEY_ONLY_RECORD_WIDGET}. + */ +function servedRecordKeyPattern(schema: JsonSchema | undefined): RegExp | null { + const pattern = (schema?.propertyNames as JsonSchema | undefined)?.pattern; + return typeof pattern === 'string' ? new RegExp(pattern) : null; +} + /** * Editor for `type: 'record'` form fields. The value is a name-keyed map * (`Record`) where insertion order is display order. @@ -2918,7 +2952,8 @@ function RepeaterField({ * per-row sub-fields, similar to RepeaterField's card layout. * * The key is mirrored into the item as a property (default name: 'name') - * so downstream consumers can treat each item as self-describing. + * so downstream consumers can treat each item as self-describing — except + * on the key-only face, which never writes it ({@link KEY_ONLY_RECORD_WIDGET}). * * See ADR-0007. */ @@ -2977,6 +3012,11 @@ function RecordField({ const [keyError, setKeyError] = React.useState(null); const [dragKey, setDragKey] = React.useState(null); const [dropTarget, setDropTarget] = React.useState(null); + // Key-only face: the key being renamed and its typed text. A rename moves the + // entry only once the text is a valid key, so an invalid intermediate + // spelling (`CLEAN_` on the way to `CLEAN_VERSION`) stays typeable. + const [keyDraft, setKeyDraft] = React.useState<{ key: string; text: string } | null>(null); + const [renameError, setRenameError] = React.useState(null); // Delegate to a registered widget if the form spec asked for one // explicitly (e.g. `widget: 'airtable'`). The widget owns the entire UI. if (widget) { @@ -3004,10 +3044,14 @@ function RecordField({ } // Inline fallback — card list with a key column + sub-fields. - const keyProp = keyField?.field ?? 'name'; - const keyLabel = keyField?.label ?? prettify(keyProp); - const keyRegex = keyField?.regex ? new RegExp(keyField.regex) : null; - const keyImmutable = keyField?.immutable !== false; // default true + const keyOnly = widget === KEY_ONLY_RECORD_WIDGET; + // The member the key is mirrored into; none on the key-only face. + const keyProp = keyOnly ? undefined : keyField?.field ?? 'name'; + const keyLabel = keyField?.label ?? (keyProp ? prettify(keyProp) : t('engine.form.key', locale)); + const keyRegex = keyOnly + ? servedRecordKeyPattern(schema) + : keyField?.regex ? new RegExp(keyField.regex) : null; + const keyImmutable = !keyOnly && keyField?.immutable !== false; // default true const record = value && typeof value === 'object' && !Array.isArray(value) ? (value as Record>) @@ -3031,26 +3075,42 @@ function RecordField({ } emit(next); }; - const renameItem = (oldKey: string, newKey: string) => { - if (newKey === oldKey) return; + const renameItem = (oldKey: string, newKey: string): boolean => { + if (newKey === oldKey) return true; + const setError = keyOnly ? setRenameError : setKeyError; + if (keyOnly && !newKey) { + setError(t('engine.form.keyRequired', locale)); + return false; + } if (record[newKey]) { - setKeyError(tFormat('engine.form.keyExists', locale, { key: newKey })); - return; + setError(tFormat('engine.form.keyExists', locale, { key: newKey })); + return false; } if (keyRegex && !keyRegex.test(newKey)) { - setKeyError(tFormat('engine.form.keyPattern', locale, { pattern: String(keyRegex) })); - return; + setError(tFormat('engine.form.keyPattern', locale, { pattern: String(keyRegex) })); + return false; } const next: Record> = {}; for (const [k, v] of entries) { if (k === oldKey) { - next[newKey] = { ...v, [keyProp]: newKey }; + next[newKey] = keyProp ? { ...v, [keyProp]: newKey } : v; } else { next[k] = v; } } - setKeyError(null); + setError(null); emit(next); + return true; + }; + // Key-only face: hold the typed text, move the entry once it is a valid key. + const editKey = (key: string, text: string) => { + const trimmed = text.trim(); + if (renameItem(key, trimmed)) { + setKeyDraft(null); + if (trimmed !== key && openKey === key) setOpenKey(trimmed); + } else { + setKeyDraft({ key, text }); + } }; const addItem = () => { const trimmed = pendingKey.trim(); @@ -3066,8 +3126,11 @@ function RecordField({ setKeyError(tFormat('engine.form.keyPattern', locale, { pattern: String(keyRegex) })); return; } - const blank: Record = { [keyProp]: trimmed }; - specs.forEach((s) => { blank[s.field] = undefined; }); + const blank: Record = {}; + if (keyProp) { + blank[keyProp] = trimmed; + specs.forEach((s) => { blank[s.field] = undefined; }); + } emit({ ...record, [trimmed]: blank }); setPendingKey(''); setKeyError(null); @@ -3158,18 +3221,36 @@ function RecordField({ {isOpen && (
- renameItem(key, String(v ?? '').trim())} - /> + {keyProp ? ( + renameItem(key, String(v ?? '').trim())} + /> + ) : ( + // The key-only face's key row: `key` is the row's name only — + // it is never a member of the entry, so it never reaches `row`. + editKey(key, String(v ?? ''))} + /> + )} {specs.map((s) => { const visibility = readVisibility(s); if (visibility && !evaluatePredicate(visibility, buildPredicateCtx(row, hostScope))) return null; diff --git a/ui/packages/i18n/src/namespaces/studio/en.ts b/ui/packages/i18n/src/namespaces/studio/en.ts index 6dcc35bcc29..16bfc7403b6 100644 --- a/ui/packages/i18n/src/namespaces/studio/en.ts +++ b/ui/packages/i18n/src/namespaces/studio/en.ts @@ -1644,6 +1644,7 @@ const STUDIO_EN: Record = { 'engine.form.masterDetailSchemaError': 'master-detail widget requires items.properties on the JSON schema (or an anyOf branch that has them).', 'engine.form.missingField': 'Field {field} declared in form layout but missing from schema. Skipping.', 'engine.form.fallbackJson': 'widget {widget} — falling back to JSON until a custom renderer is registered.', + 'engine.form.key': 'Key', 'engine.form.keyRequired': 'Key is required', 'engine.form.keyExists': 'Key "{key}" already exists', 'engine.form.keyPattern': 'Key must match {pattern}', diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts index 9aaa1b909e9..809db2f234a 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -1459,6 +1459,7 @@ const STUDIO_ZH: Record = { 'engine.form.masterDetailSchemaError': 'master-detail 组件要求 JSON Schema 上存在 items.properties(或 anyOf 分支包含该结构)。', 'engine.form.missingField': '表单布局中声明了字段 {field},但 schema 中不存在,已跳过。', 'engine.form.fallbackJson': '组件 {widget} 尚未注册自定义渲染器,暂时使用 JSON 编辑。', + 'engine.form.key': '键名', 'engine.form.keyRequired': '键名必填', 'engine.form.keyExists': '键名 "{key}" 已存在', 'engine.form.keyPattern': '键名必须匹配 {pattern}', From 6fd5eb4c898da2cd75072f4f325f7fa0f6a9be8e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 07:47:12 +0000 Subject: [PATCH 2/3] test(app-shell): pin the key-only record control and the hook form's refusals round trip Claude-Session: https://claude.ai/code/session_01JQwxbaFd8s4eYs6HGFghK3 Co-authored-by: Claude --- ...6835-hook-form-refusals-key-only-record.md | 16 ++ .../SchemaForm.keyOnlyRecord-26835.test.tsx | 202 ++++++++++++++++++ 2 files changed, 218 insertions(+) create mode 100644 .changeset/26835-hook-form-refusals-key-only-record.md create mode 100644 ui/packages/app-shell/src/views/metadata-admin/SchemaForm.keyOnlyRecord-26835.test.tsx diff --git a/.changeset/26835-hook-form-refusals-key-only-record.md b/.changeset/26835-hook-form-refusals-key-only-record.md new file mode 100644 index 00000000000..1a0031e6c2b --- /dev/null +++ b/.changeset/26835-hook-form-refusals-key-only-record.md @@ -0,0 +1,16 @@ +--- +'@objectstack/spec': patch +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +feat(app-shell): a key-only record control, and the Studio hook form offers `refusals` with it + +Clause-②: no +Metadata-protocol: none + +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. + +- **`@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. +- **`@objectstack/spec`.** `hookForm` offers `refusals` with that control. `HookSchema` is unchanged. +- **`@object-ui/i18n`.** One new Studio string, `engine.form.key` (en, zh), which names the key column when the form declares no key label. diff --git a/ui/packages/app-shell/src/views/metadata-admin/SchemaForm.keyOnlyRecord-26835.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/SchemaForm.keyOnlyRecord-26835.test.tsx new file mode 100644 index 00000000000..bb44e1b7af9 --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/SchemaForm.keyOnlyRecord-26835.test.tsx @@ -0,0 +1,202 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The key-only record control, and the Studio hook form's `refusals` row that + * takes it (objectstack#26835). + * + * A hook's refusal catalogue is `Record` with each + * entry CLOSED (`HookRefusalSchema`): the key is the code, and no member echoes + * it. The default `record` face mirrors each key into its entry (`name` unless + * `keyField.field` says otherwise), which that schema refuses — so the form + * could not offer the catalogue at all. The key-only face edits the same map + * and never writes the key into the value. + * + * Everything the control reads comes from the spec, never restated here: the + * form row is `hookForm`'s own, the field schema is the served derivation of + * `HookSchema` (the server's `z.toJSONSchema(…, { io: 'input' })`), and the key + * rule is that schema's `propertyNames.pattern`, which the spec derives from + * `HOOK_REFUSAL_CODE_PATTERN`. + */ + +import * as React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent } from '@testing-library/react'; +import { z } from 'zod'; +import { HookSchema, hookForm, HOOK_REFUSAL_CODE_PATTERN } from '@objectstack/spec/data'; +import { SchemaForm, KEY_ONLY_RECORD_WIDGET } from './SchemaForm'; + +afterEach(cleanup); + +const HOOK_JSON_SCHEMA = z.toJSONSchema(HookSchema as unknown as z.ZodType, { + io: 'input', + unrepresentable: 'any', +}) as Record; + +type FormRow = { field?: string; type?: string; widget?: string; fields?: unknown[] }; + +/** The hook form's own `refusals` row — read from `hookForm`, never restated. */ +function refusalsRow(): FormRow { + for (const section of hookForm.sections ?? []) { + for (const f of (section.fields ?? []) as Array) { + if (typeof f !== 'string' && f.field === 'refusals') return f; + } + } + throw new Error('hookForm offers no `refusals` row'); +} + +/** A form of the one row under test, so the body's code editor stays out of the render. */ +function formOf(row: FormRow) { + return { type: 'simple', sections: [{ label: 'Refusals', fields: [row] }] } as never; +} + +/** A hook every other key of which `HookSchema` accepts: a sandboxed js body. */ +const HOOK_BASE = { + name: 'sign_contract', + object: 'contract', + events: ['beforeUpdate'], + body: { language: 'js', source: 'return;' }, +}; + +function Harness({ + row, + initial, + sink, +}: { + row: FormRow; + initial: Record; + sink: { value: Record }; +}) { + const [value, setValue] = React.useState>(initial); + return ( + { + sink.value = next; + setValue(next); + }} + /> + ); +} + +function renderHook(row: FormRow, initial: Record = HOOK_BASE) { + const sink = { value: initial }; + render(); + return sink; +} + +function addKey(code: string) { + fireEvent.change(screen.getByPlaceholderText('Key'), { target: { value: code } }); + fireEvent.click(screen.getByRole('button', { name: 'Add' })); +} + +function refusalsOf(sink: { value: Record }) { + return sink.value.refusals as Record> | undefined; +} + +describe('the hook form offers `refusals` with the key-only record control (objectstack#26835)', () => { + it('declares the row as a record that opts into the key-only face', () => { + const row = refusalsRow(); + expect(row.type).toBe('record'); + expect(row.widget).toBe(KEY_ONLY_RECORD_WIDGET); + }); + + it('serves the key rule the control reads, derived from the spec code spelling', () => { + const refusals = (HOOK_JSON_SCHEMA.properties as Record>).refusals; + expect((refusals.propertyNames as { pattern?: string }).pattern).toBe(HOOK_REFUSAL_CODE_PATTERN.source); + }); +}); + +describe('the key-only record control (objectstack#26835)', () => { + it('adds an entry without writing its key into the entry', () => { + const sink = renderHook(refusalsRow()); + addKey('CLEAN_VERSION_REQUIRED'); + expect(refusalsOf(sink)).toEqual({ CLEAN_VERSION_REQUIRED: {} }); + fireEvent.change(screen.getByLabelText(/Message/), { target: { value: 'Sign a clean version.' } }); + expect(refusalsOf(sink)).toEqual({ CLEAN_VERSION_REQUIRED: { message: 'Sign a clean version.' } }); + }); + + it('refuses a key that breaks the served key rule, and leaves the value as it was', () => { + const sink = renderHook(refusalsRow()); + addKey('clean_version'); + expect(refusalsOf(sink)).toBeUndefined(); + expect(screen.getByText(`Key must match ${String(HOOK_REFUSAL_CODE_PATTERN)}`)).toBeInTheDocument(); + }); + + it('a rename moves the entry, in place and unstamped, through an invalid intermediate spelling', () => { + const sink = renderHook(refusalsRow(), { + ...HOOK_BASE, + refusals: { + FIRST: { message: 'First.' }, + DRAFT: { message: 'Drafts cannot be signed.', status: 409 }, + LAST: { message: 'Last.' }, + }, + }); + fireEvent.click(screen.getByText('DRAFT')); + const keyInput = screen.getByDisplayValue('DRAFT'); + // `DRAFT_` breaks the rule: the text stays typeable, the value does not move. + fireEvent.change(keyInput, { target: { value: 'DRAFT_' } }); + expect(screen.getByDisplayValue('DRAFT_')).toBeInTheDocument(); + expect(Object.keys(refusalsOf(sink)!)).toEqual(['FIRST', 'DRAFT', 'LAST']); + fireEvent.change(screen.getByDisplayValue('DRAFT_'), { target: { value: 'DRAFT_STATE' } }); + expect(Object.keys(refusalsOf(sink)!)).toEqual(['FIRST', 'DRAFT_STATE', 'LAST']); + expect(refusalsOf(sink)!.DRAFT_STATE).toEqual({ message: 'Drafts cannot be signed.', status: 409 }); + // The renamed entry stays open under its new key. + expect(screen.getByDisplayValue('DRAFT_STATE')).toBeInTheDocument(); + }); + + it('a rename onto a key the record already holds is refused', () => { + const sink = renderHook(refusalsRow(), { + ...HOOK_BASE, + refusals: { FIRST: { message: 'First.' }, SECOND: { message: 'Second.' } }, + }); + fireEvent.click(screen.getByText('SECOND')); + fireEvent.change(screen.getByDisplayValue('SECOND'), { target: { value: 'FIRST' } }); + expect(Object.keys(refusalsOf(sink)!)).toEqual(['FIRST', 'SECOND']); + expect(screen.getByText('Key "FIRST" already exists')).toBeInTheDocument(); + }); + + it('a hook authored with two refusals saves a body HookSchema accepts as written, and reloads into the same rows', () => { + const sink = renderHook(refusalsRow()); + addKey('CLEAN_VERSION_REQUIRED'); + fireEvent.change(screen.getByLabelText(/Message/), { target: { value: 'A current clean version is required before signing.' } }); + fireEvent.change(screen.getByLabelText(/Status/), { target: { value: '422' } }); + addKey('ALREADY_SIGNED'); + fireEvent.change(screen.getByLabelText(/Message/), { target: { value: 'This contract is already signed.' } }); + + const saved = JSON.parse(JSON.stringify(sink.value)) as Record; + const parsed = HookSchema.safeParse(saved); + expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true); + expect(JSON.stringify(parsed.data!.refusals)).toBe(JSON.stringify(saved.refusals)); + expect(saved.refusals).toEqual({ + CLEAN_VERSION_REQUIRED: { message: 'A current clean version is required before signing.', status: 422 }, + ALREADY_SIGNED: { message: 'This contract is already signed.' }, + }); + + cleanup(); + renderHook(refusalsRow(), saved); + const headers = screen.getAllByText(/^(CLEAN_VERSION_REQUIRED|ALREADY_SIGNED)$/).map((el) => el.textContent); + expect(headers).toEqual(['CLEAN_VERSION_REQUIRED', 'ALREADY_SIGNED']); + fireEvent.click(screen.getByText('CLEAN_VERSION_REQUIRED')); + expect(screen.getByLabelText(/Message/)).toHaveValue('A current clean version is required before signing.'); + expect(screen.getByLabelText(/Status/)).toHaveValue(422); + }); +}); + +describe('the default record face is unchanged (objectstack#26835)', () => { + it('still mirrors the key into the entry when no widget is declared', () => { + const { widget: _widget, ...generic } = refusalsRow(); + const sink = renderHook(generic); + fireEvent.change(screen.getByPlaceholderText('Name'), { target: { value: 'CLEAN_VERSION_REQUIRED' } }); + fireEvent.click(screen.getByRole('button', { name: 'Add' })); + fireEvent.change(screen.getByLabelText(/Message/), { target: { value: 'Sign a clean version.' } }); + expect(refusalsOf(sink)!.CLEAN_VERSION_REQUIRED).toEqual({ name: 'CLEAN_VERSION_REQUIRED', message: 'Sign a clean version.' }); + // …which is exactly the body the refusal entry's closed schema refuses. + const parsed = HookSchema.safeParse(JSON.parse(JSON.stringify(sink.value))); + expect(parsed.success).toBe(false); + expect(parsed.error!.issues).toEqual([ + expect.objectContaining({ code: 'unrecognized_keys', keys: ['name'], path: ['refusals', 'CLEAN_VERSION_REQUIRED'] }), + ]); + }); +}); From ecfa182f5865808fd4148ca9cdb272d9391debfa Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 09:28:35 +0000 Subject: [PATCH 3/3] fix(platform-objects): the generated metadata-form bundles carry the hook form's refusals rows Regenerated with scripts/check-i18n-bundles.mjs --write and translated in zh-CN, ja-JP and es-ES; the three census pins that count hook-form children and translated row labels move with the new rows, and the cli flow-refusal coverage pin is scoped to the flows bucket. Claude-Session: https://claude.ai/code/session_01JQwxbaFd8s4eYs6HGFghK3 Co-authored-by: Claude --- .../26835-hook-form-refusals-key-only-record.md | 2 ++ packages/cli/test/i18n-flow-refusal-coverage.test.ts | 7 ++++++- .../action-body-panel-echo-decisions.test.ts | 6 +++++- .../apps/translations/en.metadata-forms.generated.ts | 12 ++++++++++++ .../translations/es-ES.metadata-forms.generated.ts | 12 ++++++++++++ .../hook-execution-panel-echo-decisions.test.ts | 5 +++-- .../translations/ja-JP.metadata-forms.generated.ts | 12 ++++++++++++ .../object-lifecycle-panel-echo-decisions.test.ts | 4 +++- .../translations/zh-CN.metadata-forms.generated.ts | 12 ++++++++++++ 9 files changed, 67 insertions(+), 5 deletions(-) diff --git a/.changeset/26835-hook-form-refusals-key-only-record.md b/.changeset/26835-hook-form-refusals-key-only-record.md index 1a0031e6c2b..9bed4f913ec 100644 --- a/.changeset/26835-hook-form-refusals-key-only-record.md +++ b/.changeset/26835-hook-form-refusals-key-only-record.md @@ -1,5 +1,6 @@ --- '@objectstack/spec': patch +'@objectstack/platform-objects': patch '@object-ui/app-shell': patch '@object-ui/i18n': patch --- @@ -13,4 +14,5 @@ A hook built in Studio can now declare its refusal catalogue. The hook form show - **`@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. - **`@objectstack/spec`.** `hookForm` offers `refusals` with that control. `HookSchema` is unchanged. +- **`@objectstack/platform-objects`.** The generated metadata-form bundles carry the three new hook-form rows, translated in zh-CN, ja-JP and es-ES. - **`@object-ui/i18n`.** One new Studio string, `engine.form.key` (en, zh), which names the key column when the form declares no key label. diff --git a/packages/cli/test/i18n-flow-refusal-coverage.test.ts b/packages/cli/test/i18n-flow-refusal-coverage.test.ts index 0f7542f27b0..3f74ccc47bd 100644 --- a/packages/cli/test/i18n-flow-refusal-coverage.test.ts +++ b/packages/cli/test/i18n-flow-refusal-coverage.test.ts @@ -79,8 +79,13 @@ describe('the coverage gate demands the refusal (#22450)', () => { flows: [quoteGeneration], translations: [{ 'zh-CN': { ...(flows ? { flows } : {}) } }], }); + // Scoped to the flows bucket: the metadata-form catalogue has `refusals` keys + // of its own since the hook form offers its refusal catalogue (#26835), and + // this synthetic tree translates no metadata form. const zhRefusals = (config: any) => - computeI18nCoverage(config).issues.filter((i) => i.locale === 'zh-CN' && i.key.includes('.refusals.')); + computeI18nCoverage(config).issues.filter( + (i) => i.locale === 'zh-CN' && i.key.startsWith('flows.') && i.key.includes('.refusals.'), + ); it('reports the untranslated refusal in the flow bucket', () => { const issues = zhRefusals(tree()); diff --git a/packages/platform-objects/src/apps/translations/action-body-panel-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/action-body-panel-echo-decisions.test.ts index a3e9535f4b8..20a80a21f8b 100644 --- a/packages/platform-objects/src/apps/translations/action-body-panel-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/action-body-panel-echo-decisions.test.ts @@ -701,11 +701,15 @@ describe('#19403 round 5 — the blind spot, FIFTH shape: one schema, two forms, // it asserts `carrying.has('hook') === false`, and that assertion is TRUE // and CORRECT — `hookForm` enumerates no repeater at all — while seven // children sit enumerated and outside it. + // 9 since #26835: the hook form offers the `refusals` record, whose two + // entry fields (`message`, `status`) are enumerated the same way. expect(HOOK_ENUMERATED.covered.length, 'hookForm enumerates no repeater row properties').toBe(0); - expect(HOOK_ENUMERATED.skipped.length).toBe(7); + expect(HOOK_ENUMERATED.skipped.length).toBe(9); for (const child of SHARED_BODY_CHILDREN) expect(HOOK_ENUMERATED.skipped).toContain(`body.${child}`); expect(HOOK_ENUMERATED.skipped).toContain('retryPolicy.maxRetries'); expect(HOOK_ENUMERATED.skipped).toContain('retryPolicy.backoffMs'); + expect(HOOK_ENUMERATED.skipped).toContain('refusals.message'); + expect(HOOK_ENUMERATED.skipped).toContain('refusals.status'); // // ⇒ one composite, two forms, two different greens: a lit control that // passes on a sibling repeater, and a dark control that is right about diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index 2c4187f2884..0d2af302601 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -937,6 +937,18 @@ export const enMetadataForms: NonNullable = { label: "Memory Mb", helpText: "Per-invocation memory cap (MB, max 256)" }, + refusals: { + label: "Refusals", + 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" + }, + "refusals.message": { + label: "Message", + helpText: "The sentence the user reads when the body throws this code — plain text, with no placeholders" + }, + "refusals.status": { + label: "Status", + helpText: "HTTP status of the refusal (400–499). Absent: 400" + }, handler: { label: "Handler", helpText: "Handler function name (deprecated — prefer `body`)" diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 0a1eeb3ceb7..3db0a5de625 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -937,6 +937,18 @@ export const esESMetadataForms: NonNullable = label: "Memoria (MB)", helpText: "Límite de memoria por invocación (MB, máx. 256)" }, + refusals: { + label: "Rechazos", + 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)" + }, + "refusals.message": { + label: "Mensaje", + helpText: "La frase que lee el usuario cuando el cuerpo lanza este código — texto plano, sin marcadores" + }, + "refusals.status": { + label: "Estado", + helpText: "Estado HTTP del rechazo (400–499). Si se omite: 400" + }, handler: { label: "Manejador", helpText: "Nombre de función manejadora (obsoleto — preferir `body`)" diff --git a/packages/platform-objects/src/apps/translations/hook-execution-panel-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/hook-execution-panel-echo-decisions.test.ts index 882fc797ce9..f5a07ca0b6b 100644 --- a/packages/platform-objects/src/apps/translations/hook-execution-panel-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/hook-execution-panel-echo-decisions.test.ts @@ -782,9 +782,10 @@ describe('#19403 round 6 — the composite blind spot, hook side, CLOSED', () => it('the derivation reproduces round 5\'s measurement of what the repeater pin cannot see', () => { // `hookForm` enumerates no repeater at all, so `repeater-row-properties.test.ts` // is blind here through a DARK control that is TRUE and CORRECT — the shape - // round 5 wrote down. Seven children sit enumerated and outside it. + // round 5 wrote down. Seven children sit enumerated and outside it — 9 + // since #26835, when the `refusals` record's `message` / `status` joined. expect(HOOK_ENUMERATED.covered.length, 'hookForm enumerates no repeater row properties').toBe(0); - expect(HOOK_ENUMERATED.skipped.length).toBe(7); + expect(HOOK_ENUMERATED.skipped.length).toBe(9); for (const child of SHARED_BODY_CHILDREN) expect(HOOK_ENUMERATED.skipped).toContain(`body.${child}`); expect(HOOK_ENUMERATED.skipped).toContain('retryPolicy.maxRetries'); expect(HOOK_ENUMERATED.skipped).toContain('retryPolicy.backoffMs'); diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index e7fe0665702..6004e7b83b8 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -937,6 +937,18 @@ export const jaJPMetadataForms: NonNullable = label: "メモリ(MB)", helpText: "呼び出しごとのメモリ上限(MB、最大 256)" }, + refusals: { + label: "拒否カタログ", + helpText: "本体がスローする SCREAMING_SNAKE のコード(例: CLEAN_VERSION_REQUIRED)をキーとする拒否カタログ。呼び出し元には宣言したメッセージ(その言語に翻訳)と宣言したステータスが返ります。JavaScript(L2)本体のみ" + }, + "refusals.message": { + label: "メッセージ", + helpText: "本体がこのコードをスローしたときにユーザーが読む文 — プレーンテキスト、プレースホルダーなし" + }, + "refusals.status": { + label: "ステータス", + helpText: "拒否の HTTP ステータス(400–499)。未指定: 400" + }, handler: { label: "ハンドラー", helpText: "ハンドラー関数名(非推奨 — `body` を推奨)" diff --git a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts index 4ff100e4b22..aee2e03a2b8 100644 --- a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts @@ -1230,7 +1230,9 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => { // each with its type-level display pair, authored in all three locales. // 689 since #26619: the action form's `disabledReason` row (why a greyed // action is unavailable) — one row label, authored in all three locales. - expect(translated.length, `${locale} positive control`).toBe(689); + // 692 since #26835: the hook form's `refusals` record and its `message` / + // `status` entry fields — three row labels, authored in all three locales. + expect(translated.length, `${locale} positive control`).toBe(692); } // ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the // all-three predicate returns 0 while the per-locale one returns 1, so the diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index cc7e5b31c6c..f9b8229f55e 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -937,6 +937,18 @@ export const zhCNMetadataForms: NonNullable = label: "内存(MB)", helpText: "单次调用内存上限(MB,最大 256)" }, + refusals: { + label: "拒绝目录", + helpText: "拒绝目录,以正文抛出的 SCREAMING_SNAKE 错误码为键(例如 CLEAN_VERSION_REQUIRED)。调用方读到声明的提示语(按其语言翻译)和声明的状态码。仅适用于 JavaScript(L2)正文" + }, + "refusals.message": { + label: "提示语", + helpText: "正文抛出此错误码时用户读到的句子——纯文本,不含占位符" + }, + "refusals.status": { + label: "状态码", + helpText: "拒绝的 HTTP 状态码(400–499)。不填:400" + }, handler: { label: "处理器", helpText: "处理器函数名(已废弃——建议使用 body)"