Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/26835-hook-form-refusals-key-only-record.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/spec': patch
'@objectstack/platform-objects': 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.
- **`@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.
7 changes: 6 additions & 1 deletion packages/cli/test/i18n-flow-refusal-coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -937,6 +937,18 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
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`)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -937,6 +937,18 @@ export const esESMetadataForms: NonNullable<TranslationData['metadataForms']> =
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`)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -937,6 +937,18 @@ export const jaJPMetadataForms: NonNullable<TranslationData['metadataForms']> =
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` を推奨)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1233,7 +1233,9 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => {
// 690 since #26602: the dataset form's measures repeater offers `polarity`
// (which direction of a KPI delta is good news) — one row label, authored
// in all three locales.
expect(translated.length, `${locale} positive control`).toBe(690);
// 693 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(693);
}
// ⭐ 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -937,6 +937,18 @@ export const zhCNMetadataForms: NonNullable<TranslationData['metadataForms']> =
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)"
Expand Down
15 changes: 14 additions & 1 deletion packages/spec/src/data/hook.form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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' },
],
},
],
},
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -331,15 +331,6 @@ const LEDGER: ReadonlyArray<OmitEntry | SubsetEntry> = [
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
Expand Down
Loading
Loading