From e648435150e381823f79b30ba1fddf1edd40f3cf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 18:38:26 +0000 Subject: [PATCH] =?UTF-8?q?fix(spec):=20app=20=E8=A1=A8=E5=8D=95=E6=91=98?= =?UTF-8?q?=E6=8E=89=E5=85=AB=E4=B8=AA=E5=B7=B2=E9=80=80=E5=BD=B9=E7=9A=84?= =?UTF-8?q?=E5=A2=93=E7=A2=91=E9=94=AE=E8=BE=93=E5=85=A5,#3786=20=E5=AF=B9?= =?UTF-8?q?=E8=B4=A6=E9=97=A8=E6=94=B9=E5=88=A4=E3=80=8C=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=E5=8F=AF=E6=8E=88=E6=9D=83=E9=9D=A2=E3=80=8D(#5280)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `app.form.ts` 仍在给作者渲染八个 `retiredKey()` 墓碑键的输入控件 —— `version` / `homePageId` / `objects` / `apis` / `sharing` / `embed` / `mobileNavigation` / `aria`(issue 只点了后三个,全量扫出八个)。墓碑是 `z.never().optional()`,填进去不是静默丢弃,是整条 save 硬失败并抛出该键 的退役处方。八个控件全部移除,按套件惯例在原位留注释说明能力去向。 真正的价值在门这一侧:#3786 的对账断言用 `key ∈ shape` 判定「Zod 接受该 键」。在 `retiredKey()` 出现之前这两件事同义,之后不是了 —— 墓碑**按设计** 把键留在 walked shape 里(退役套件的 liveness 行正因为键还在才保留),于是 八个墓碑键对该断言永远为真,门绿着而作者拿到 422。 谓词收紧为「∈ shape 且不是墓碑」,双向都改: - form-only 方向拆成两条独立断言,失败信息能分清「未声明→静默丢弃」与 「墓碑→硬失败」,后者更响也更早; - zod-only 方向把墓碑键排除出「必须提供或立 ledger」的集合 —— 墓碑键的唯一 正确处置就是不提供。此前 `object.fields.conditionalRequired` 不被要求回填 纯属被一条无关的 `subset` ledger 条目挡住,是运气不是设计; - ledger 的 `omit`/`subset` 自检改用 authorable 键计数,避免墓碑撑住一条 已失效的豁免。 检测器判 **schema 节点**(optional 内层是否 `z.never`),不匹配键名 —— 键名 清单本身就是 #3786 要消灭的手抄列表。这是 `build-schemas.ts` 里 JSON Schema 侧 `isRetired()`(`{ not: {} }`)的 zod 侧孪生。四条合成 schema 上的自检把 机制钉住(含 union 成员仍 live 时不算退役的安全方向),不依赖任何当期墓碑, 免得墓碑随版本退场后这条门悄悄变空转。 RED-first 实证:谓词收紧、表单未改时,`app` 那条用例红,恰好点名八个键; 摘掉输入后 41/41 绿。 表单输入变动会带走 i18n 抽取键,四个 metadata-forms bundle 按 merge 模式 重生成(纯删除 8 组);两个分区描述原文仍在宣传已退役能力(zh-CN 的 basics/navigation 甚至点名「版本」「首页」),按 bundle 值可手写的惯例一并 订正为真实语义。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01ErbEDVAg1No9gdg1pgDAGB --- .changeset/app-form-tombstone-controls.md | 37 ++++ .../en.metadata-forms.generated.ts | 35 +--- .../es-ES.metadata-forms.generated.ts | 35 +--- .../ja-JP.metadata-forms.generated.ts | 35 +--- .../zh-CN.metadata-forms.generated.ts | 39 +--- .../metadata-form-zod-reconciliation.test.ts | 181 +++++++++++++++++- packages/spec/src/ui/app.form.ts | 53 ++++- 7 files changed, 263 insertions(+), 152 deletions(-) create mode 100644 .changeset/app-form-tombstone-controls.md diff --git a/.changeset/app-form-tombstone-controls.md b/.changeset/app-form-tombstone-controls.md new file mode 100644 index 0000000000..630da6783b --- /dev/null +++ b/.changeset/app-form-tombstone-controls.md @@ -0,0 +1,37 @@ +--- +"@objectstack/spec": patch +"@objectstack/platform-objects": patch +--- + +fix(spec): stop offering retired `app` keys in the metadata form, and make the reconciliation gate see tombstones (#5280) + +The `app` authoring form rendered **eight** controls for keys `AppSchema` had +already retired to `retiredKey()` tombstones in 17.0.0 — `version`, `homePageId`, +`objects`, `apis`, `sharing`, `embed`, `mobileNavigation` and `aria`. A tombstone +is `z.never().optional()`, so filling one of those controls did not lose the +value quietly: it failed the **entire save** with the key's removal +prescription. The controls are gone, each with a comment in place naming where +the capability went (`manifest.version`; the first `navigation` item by `order` +plus `isDefault`; `defineStack({ objects })`; `defineStack({ apis })`; +`FormView.sharing` for both public access and embedding; the component that +renders the DOM node for `aria`). + +Nothing about the contract changes — every one of these keys was already +rejected at parse. What changes is that an author is no longer shown a control +that can only produce a 422. + +**The reconciliation gate now judges the right thing.** #3786's +`metadata-form-zod-reconciliation.test.ts` asked whether an offered key was +`∈ shape`. That was the same question as "may the author write this" until +`retiredKey()` existed: a tombstone **deliberately stays in the shape** so the +removal can carry its own upgrade prescription, so every one of those eight keys +read as "the Zod accepts it" and the gate stayed green over all of them. It now +asserts `∈ shape` **and not a tombstone**, in both directions — a retired key +may not be offered, and its absence needs no ledger entry to excuse it. The +detector reads the schema node (`z.never()` under the optional wrapper), never a +list of key names, mirroring `isRetired()` on the JSON-Schema side of +`build-schemas.ts`. The next `retiredKey()` retirement that forgets a form now +fails this test instead of reaching an author. + +Retiring an authorable key already required pruning its form input; that step is +now enforced rather than remembered. 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 a907eb829d..923e2f166b 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,7 +937,7 @@ export const enMetadataForms: NonNullable = { }, content: { label: "Content", - description: "Objects and APIs this app uses." + description: "Ambient assistant binding. Objects and endpoints are declared on the stack, not the app." }, branding: { label: "Branding", @@ -945,7 +945,7 @@ export const enMetadataForms: NonNullable = { }, access_and_sharing: { label: "Access & sharing", - description: "Who can access this app and how it can be embedded." + description: "Who can access this app. Public access and embedding are per form view, not app-level." } }, fields: { @@ -959,9 +959,6 @@ export const enMetadataForms: NonNullable = { description: { label: "Description" }, - version: { - label: "Version" - }, icon: { label: "Icon", helpText: "Lucide icon name (e.g. \"users\", \"briefcase\")" @@ -981,22 +978,6 @@ export const enMetadataForms: NonNullable = { label: "Areas", helpText: "Group items into collapsible areas" }, - homePageId: { - label: "Home Page Id", - helpText: "Landing page when app opens" - }, - mobileNavigation: { - label: "Mobile Navigation", - helpText: "Bottom tab bar config for mobile" - }, - objects: { - label: "Objects", - helpText: "Object names this app exposes" - }, - apis: { - label: "Apis", - helpText: "API endpoint definitions" - }, defaultAgent: { label: "Default Agent", helpText: "AI agent for the ambient assistant button" @@ -1008,18 +989,6 @@ export const enMetadataForms: NonNullable = { requiredPermissions: { label: "Required Permissions", helpText: "Permissions needed to access this app" - }, - sharing: { - label: "Sharing", - helpText: "Public/internal/restricted access control" - }, - embed: { - label: "Embed", - helpText: "iFrame embed configuration" - }, - aria: { - label: "Aria", - helpText: "Accessibility labels" } } }, 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 36d649cae8..12dbda63fc 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,7 +937,7 @@ export const esESMetadataForms: NonNullable = }, content: { label: "Contenido", - description: "Objetos y API que usa esta app." + description: "Asignación del asistente ambiental. Los objetos y endpoints se declaran en el stack, no en la app." }, branding: { label: "Marca", @@ -945,7 +945,7 @@ export const esESMetadataForms: NonNullable = }, access_and_sharing: { label: "Acceso y uso compartido", - description: "Quién puede acceder a esta app y cómo puede incrustarse." + description: "Quién puede acceder a esta app. El acceso público y la incrustación son por vista de formulario, no a nivel de app." } }, fields: { @@ -959,9 +959,6 @@ export const esESMetadataForms: NonNullable = description: { label: "Descripción" }, - version: { - label: "Versión" - }, icon: { label: "Icono", helpText: "Nombre de icono Lucide (p. ej. \"users\", \"briefcase\")" @@ -981,22 +978,6 @@ export const esESMetadataForms: NonNullable = label: "Áreas", helpText: "Agrupa elementos en áreas plegables" }, - homePageId: { - label: "ID de página inicial", - helpText: "Página de inicio al abrir la app" - }, - mobileNavigation: { - label: "Navegación móvil", - helpText: "Configuración de barra de pestañas inferior para móvil" - }, - objects: { - label: "Permisos de objeto", - helpText: "Nombres de objeto que expone esta app" - }, - apis: { - label: "API", - helpText: "Definiciones de endpoints API" - }, defaultAgent: { label: "Agente predeterminado", helpText: "Agente de IA para el botón de asistente ambiental" @@ -1008,18 +989,6 @@ export const esESMetadataForms: NonNullable = requiredPermissions: { label: "Permisos requeridos", helpText: "Permisos necesarios para acceder a esta app" - }, - sharing: { - label: "Compartición", - helpText: "Control de acceso público/interno/restringido" - }, - embed: { - label: "Incrustación", - helpText: "Configuración de incrustación iFrame" - }, - aria: { - label: "Accesibilidad", - helpText: "Etiquetas de accesibilidad" } } }, 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 3b57001a82..60ed4462e7 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,7 +937,7 @@ export const jaJPMetadataForms: NonNullable = }, content: { label: "コンテンツ", - description: "このアプリが使用するオブジェクトと API。" + description: "アンビエントアシスタントの割り当て。オブジェクトとエンドポイントはアプリではなくスタックで宣言します。" }, branding: { label: "ブランディング", @@ -945,7 +945,7 @@ export const jaJPMetadataForms: NonNullable = }, access_and_sharing: { label: "アクセスと共有", - description: "このアプリにアクセス可能なユーザーと埋め込み方法。" + description: "このアプリにアクセスできるユーザー。公開アクセスと埋め込みはアプリ単位ではなくフォームビュー単位です。" } }, fields: { @@ -959,9 +959,6 @@ export const jaJPMetadataForms: NonNullable = description: { label: "説明" }, - version: { - label: "バージョン" - }, icon: { label: "アイコン", helpText: "Lucide アイコン名(例: \"users\", \"briefcase\")" @@ -981,22 +978,6 @@ export const jaJPMetadataForms: NonNullable = label: "領域", helpText: "項目を折りたたみ可能なエリアにグループ化" }, - homePageId: { - label: "ホームページ ID", - helpText: "アプリ起動時のランディングページ" - }, - mobileNavigation: { - label: "モバイルナビゲーション", - helpText: "モバイル用ボトムタブバー設定" - }, - objects: { - label: "オブジェクト権限", - helpText: "このアプリが公開するオブジェクト名" - }, - apis: { - label: "API", - helpText: "API エンドポイント定義" - }, defaultAgent: { label: "既定エージェント", helpText: "常駐アシスタントボタン用 AI エージェント" @@ -1008,18 +989,6 @@ export const jaJPMetadataForms: NonNullable = requiredPermissions: { label: "必要な権限", helpText: "このアプリへのアクセスに必要な権限" - }, - sharing: { - label: "共有", - helpText: "公開/内部/制限付きアクセス制御" - }, - embed: { - label: "埋め込み", - helpText: "iFrame 埋め込み設定" - }, - aria: { - label: "アクセシビリティ", - helpText: "アクセシビリティラベル" } } }, 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 405fd721b8..a4d4246317 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 @@ -929,15 +929,15 @@ export const zhCNMetadataForms: NonNullable = sections: { basics: { label: "基础信息", - description: "标识、版本与图标" + description: "应用标识与启用状态" }, navigation: { label: "导航", - description: "菜单结构与首页" + description: "侧边栏菜单项与分区分组" }, content: { label: "内容", - description: "对象、API 与默认智能体" + description: "环境助手绑定。对象与接口在 stack 上声明,不在 app 上" }, branding: { label: "品牌", @@ -945,7 +945,7 @@ export const zhCNMetadataForms: NonNullable = }, access_and_sharing: { label: "访问与共享", - description: "权限、共享与嵌入" + description: "谁可以访问此应用。公开访问与嵌入按表单视图授予,不是应用级开关" } }, fields: { @@ -959,9 +959,6 @@ export const zhCNMetadataForms: NonNullable = description: { label: "描述" }, - version: { - label: "版本" - }, icon: { label: "图标", helpText: "Lucide 图标名(如 users、briefcase)" @@ -981,22 +978,6 @@ export const zhCNMetadataForms: NonNullable = label: "区域", helpText: "将菜单项组织为可折叠分组" }, - homePageId: { - label: "首页 ID", - helpText: "应用打开时跳转的页面" - }, - mobileNavigation: { - label: "移动端导航", - helpText: "移动端底部 Tab 栏配置" - }, - objects: { - label: "对象权限", - helpText: "此应用暴露的对象名" - }, - apis: { - label: "API", - helpText: "API 端点定义" - }, defaultAgent: { label: "默认智能体", helpText: "右下角浮动助手按钮调用的 AI 智能体" @@ -1008,18 +989,6 @@ export const zhCNMetadataForms: NonNullable = requiredPermissions: { label: "所需权限", helpText: "访问该应用需要的权限" - }, - sharing: { - label: "共享", - helpText: "公开 / 内部 / 受限的访问控制" - }, - embed: { - label: "嵌入", - helpText: "iFrame 嵌入配置" - }, - aria: { - label: "无障碍", - helpText: "无障碍标签与角色" } } }, 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 22ca905ecd..628a6bdd50 100644 --- a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts +++ b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts @@ -34,13 +34,38 @@ * checked below for non-vacuity and for still resolving on both sides, the * #4045 / #4040 ledger discipline. * + * ## "Accepts" is not "is in the shape" (#5280) + * + * The original predicate for the form-only direction was `key ∈ shape`. That was + * the same question as "the author may write this key" right up until + * `retiredKey()` (`shared/retired-key.ts`) existed — and then it stopped being. + * A tombstone **deliberately keeps the key in the walked shape** (the retirement + * kit's liveness ledger says so in as many words: the row stays *because* the + * key stays), while typing it `z.never().optional()`. So a tombstoned key reads + * as "the Zod accepts it" to `key ∈ shape`, and this gate stayed green over + * eight `app` form controls whose every value was **hard-rejected** on save. + * + * That is the louder of the two failure modes, and it arrives earlier: an + * undeclared key is silently stripped (or, on a `.strict()` schema, rejected as + * unknown), whereas a tombstoned key fails the parse outright with the removal + * prescription — a 422 the author should never have been able to provoke, + * because the control should not have been on screen. Both are asserted below, + * separately, so a failure names which one it is. + * + * The detector judges the **schema node** (`z.never()` under the optional + * wrapper), never the key's name — the zod-side twin of `isRetired()` in + * `scripts/build-schemas.ts`, which asks the same question of the emitted JSON + * Schema (`{ "not": {} }`, Zod's rendering of `z.never()`). + * * @see control-flow-form-zod-ledger.test.ts — same pattern for the flow designer */ import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; import { METADATA_FORM_REGISTRY } from './metadata-form-registry'; import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; +import { retiredKey } from '../shared/retired-key'; // ──────────────────────────────────────────────────────────────────────────── // Ledger — deliberate zod-only omissions. `omit` names one key; `subset` @@ -131,6 +156,58 @@ function keysOf(schema: unknown): string[] | null { return null; } +/** + * Is this property node a **tombstone** — `retiredKey()`, i.e. + * `z.never().optional().describe(…)`? + * + * Judged on the node, not on the key's name: a name list would have to be + * hand-maintained here, which is the very hand-copied-list defect #3786 exists + * to abolish. `unwrap` peels the `optional` (and any other wrapper a future + * tombstone spelling adds), leaving the `never` for the shape test — the same + * fact `scripts/build-schemas.ts` reads on the JSON-Schema side as `{ not: {} }`. + */ +function isRetiredNode(prop: unknown): boolean { + const u = unwrap(prop); + const d = u?.def ?? u?._def; + return d?.type === 'never'; +} + +/** + * Is `key` unwritable at `schema` — declared, but only as a tombstone? + * + * A union needs care in the safe direction: one member may tombstone the key + * while another still declares it live, and an author may legally write the + * live member's shape. So a key counts as retired only when **every** member + * that declares it tombstones it. + */ +function isRetiredAt(schema: unknown, key: string): boolean { + const u = unwrap(schema); + const d = u?.def ?? u?._def; + if (d?.type === 'union' || d?.type === 'discriminated_union') { + let declaredSomewhere = false; + for (const option of d.options ?? []) { + if (!keysOf(option)?.includes(key)) continue; + declaredSomewhere = true; + if (!isRetiredAt(option, key)) return false; + } + return declaredSomewhere; + } + if (d?.type === 'object') { + const prop = (d.shape ?? u.shape ?? {})[key]; + return prop !== undefined && isRetiredNode(prop); + } + return false; +} + +/** + * Keys an author may actually write: declared **and** not a tombstone. This is + * what `keysOf` was being used as before #5280, and what it never was. + */ +function authorableKeysOf(schema: unknown): string[] | null { + const keys = keysOf(schema); + return keys ? keys.filter((k) => !isRetiredAt(schema, k)) : null; +} + /** The sub-schema stored under `key`, looking through union members. */ function subSchemaOf(schema: unknown, key: string): unknown { const u = unwrap(schema); @@ -201,17 +278,26 @@ describe('metadata form ↔ Zod reconciliation (#3786)', () => { } }); - it.each(TYPES)('%s: every field the form offers is a key the Zod accepts', (type) => { + it.each(TYPES)('%s: every field the form offers is a key the author may write', (type) => { const root = getMetadataTypeSchema(type); const rootKeys = keysOf(root); expect(rootKeys, `${type}: root schema is not key-bearing`).toBeTruthy(); + const offered = topLevelFields(METADATA_FORM_REGISTRY[type]); // An offered key the schema does not declare is silently stripped on save — // the author fills the control and the value never lands. expect( - topLevelFields(METADATA_FORM_REGISTRY[type]).filter((f) => !rootKeys!.includes(f)), + offered.filter((f) => !rootKeys!.includes(f)), `${type}: offered by the form but not declared by the Zod (saved value is dropped)`, ).toEqual([]); + + // An offered key the schema TOMBSTONES is worse than dropped: `retiredKey()` + // is `z.never()`, so filling the control fails the whole save with the + // removal prescription. The control must not exist (#5280). + expect( + offered.filter((f) => isRetiredAt(root, f)), + `${type}: offered by the form but RETIRED in the Zod (retiredKey tombstone — filling the control hard-fails the save). Delete the form entry and leave a comment naming the retirement`, + ).toEqual([]); }); it.each(TYPES)('%s: every hand-written nested list matches its sub-schema', (type) => { @@ -230,10 +316,20 @@ describe('metadata form ↔ Zod reconciliation (#3786)', () => { `${type}.${path}: offered by the form but not declared by the Zod (saved value is dropped)`, ).toEqual([]); + expect( + offered.filter((k) => isRetiredAt(sub, k)), + `${type}.${path}: offered by the form but RETIRED in the Zod (retiredKey tombstone — filling the control hard-fails the save). Delete the form entry and leave a comment naming the retirement`, + ).toEqual([]); + if (isSubset(type, path)) continue; const excused = omittedAt(type, path); + // A tombstoned key needs no ledger entry to excuse its absence — the + // *only* correct thing to do with it is not offer it. Demanding one back + // (or a ledger row for it) is this gate's blind spot inverted: before + // #5280 the sole thing keeping `object.fields.conditionalRequired` off + // this list was an unrelated `subset` entry, i.e. luck. expect( - subKeys!.filter((k) => !offered.includes(k) && !excused.includes(k)), + subKeys!.filter((k) => !offered.includes(k) && !excused.includes(k) && !isRetiredAt(sub, k)), `${type}.${path}: accepted by the Zod but unauthorable in the form — offer it, or add a ledger entry`, ).toEqual([]); } @@ -250,20 +346,30 @@ describe('metadata form ↔ Zod reconciliation (#3786)', () => { const list = lists.find((l) => l.path === entry.path); expect(list, `${entry.type}.${entry.path}: no hand-written list at this path any more`).toBeDefined(); - const subKeys = keysOf(subSchemaOf(root, entry.path)); + const sub = subSchemaOf(root, entry.path); + const subKeys = keysOf(sub); expect(subKeys, `${entry.type}.${entry.path}: sub-schema is not key-bearing any more`).toBeTruthy(); if (entry.kind === 'omit') { - expect(subKeys, `${entry.type}.${entry.path}.${entry.key}: not in the Zod any more`).toContain(entry.key); + // Authorable, not merely present: an `omit` whose key has since been + // TOMBSTONED is excusing an omission that is now mandatory, and the + // entry has to go — otherwise the ledger's own "still resolves" check + // is what keeps a dead excuse alive. + expect( + authorableKeysOf(sub), + `${entry.type}.${entry.path}.${entry.key}: not an authorable key any more — removed, or retired to a tombstone (a retired key is excused automatically). Drop the ledger entry`, + ).toContain(entry.key); expect( list!.offered, `${entry.type}.${entry.path}.${entry.key}: the form offers it now — drop the ledger entry`, ).not.toContain(entry.key); } else { - // A `subset` that covers everything is no longer a subset. + // A `subset` that covers everything is no longer a subset — counted over + // AUTHORABLE keys, so a tombstone left in the shape cannot prop up an + // entry whose real coverage gap has closed. expect( - subKeys!.filter((k) => !list!.offered.includes(k)).length, - `${entry.type}.${entry.path}: the form now covers the whole schema — drop the ledger entry`, + authorableKeysOf(sub)!.filter((k) => !list!.offered.includes(k)).length, + `${entry.type}.${entry.path}: the form now covers the whole authorable schema — drop the ledger entry`, ).toBeGreaterThan(0); } } @@ -277,3 +383,62 @@ describe('metadata form ↔ Zod reconciliation (#3786)', () => { } }); }); + +// ──────────────────────────────────────────────────────────────────────────── +// The tombstone predicate itself (#5280). +// +// Pinned against a SYNTHETIC schema rather than against whichever live schema +// happens to carry a tombstone today: tombstones age out (`retired-key.ts` says +// ~two majors), and a self-test anchored to one would either rot or, worse, go +// quietly vacuous the release its anchor is deleted — leaving a gate that +// filters nothing and still reports green. +// ──────────────────────────────────────────────────────────────────────────── + +describe('retiredKey tombstones are not authoring surface (#5280)', () => { + const probe = z.object({ + gone: retiredKey('`Probe.gone` was removed in @objectstack/spec 17.0.0. Delete the key.'), + live: z.string().optional(), + }); + + it('a tombstone stays in the shape — which is exactly why `key ∈ shape` was the wrong predicate', () => { + expect(keysOf(probe)).toEqual(['gone', 'live']); + expect(isRetiredAt(probe, 'gone')).toBe(true); + expect(isRetiredAt(probe, 'live')).toBe(false); + expect(authorableKeysOf(probe)).toEqual(['live']); + }); + + it('and the value it rejects fails the parse outright, carrying its prescription', () => { + // The half of the story the old assertion's wording ("silently stripped on + // save") could not describe: this one is loud, and earlier. + const result = probe.safeParse({ gone: 'anything', live: 'ok' }); + expect(result.success).toBe(false); + expect(result.error?.issues.map((i) => i.message).join('\n')).toContain('was removed in @objectstack/spec'); + expect(probe.safeParse({ live: 'ok' }).success).toBe(true); + }); + + it('detects by schema node, not by key name', () => { + // A key NAMED like a retirement but declared live is authorable; a key with + // an unremarkable name that is `z.never()` is not. Nothing here may depend + // on a hand-maintained list of retired names — that list is the #3786 defect. + const named = z.object({ + sharing: z.string().optional(), + ordinary: retiredKey('`Named.ordinary` was removed in @objectstack/spec 17.0.0. Delete the key.'), + }); + expect(isRetiredAt(named, 'sharing')).toBe(false); + expect(isRetiredAt(named, 'ordinary')).toBe(true); + }); + + it('a key still declared live by one union member stays authorable', () => { + // Safe direction: an author may write the live member's shape, so offering + // the key is not a defect even though another member tombstones it. + const union = z.union([ + z.object({ + kind: z.literal('a'), + shared: retiredKey('`A.shared` was removed in @objectstack/spec 17.0.0. Delete the key.'), + }), + z.object({ kind: z.literal('b'), shared: z.string().optional() }), + ]); + expect(keysOf(union)).toEqual(['kind', 'shared']); + expect(isRetiredAt(union, 'shared')).toBe(false); + }); +}); diff --git a/packages/spec/src/ui/app.form.ts b/packages/spec/src/ui/app.form.ts index 3acd4a0c7c..77007beca5 100644 --- a/packages/spec/src/ui/app.form.ts +++ b/packages/spec/src/ui/app.form.ts @@ -1,5 +1,23 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +/** + * Authoring layout for the `app` metadata type. + * + * Eight controls used to sit here for keys `AppSchema` had already retired to + * `retiredKey()` tombstones in 17.0.0 (#5280). A tombstone is + * `z.never().optional()`: it keeps the key in the schema's shape on purpose — + * so the removal can carry its own upgrade prescription — while rejecting every + * value. Offering one is therefore worse than the #3786 drift this file's + * reconciliation gate was built for: the author does not lose the value + * silently, the whole save fails with a 422 for a control that should not have + * been on screen. + * + * Each removal is noted in place below with where the capability went. The gate + * now judges `key ∈ shape AND not a tombstone`, so the next `retiredKey()` + * retirement that forgets this file fails + * `metadata-form-zod-reconciliation.test.ts` instead of reaching an author. + */ + import { defineForm } from './view.zod'; export const appForm = defineForm({ @@ -14,7 +32,9 @@ export const appForm = defineForm({ { field: 'name', type: 'text', required: true, colSpan: 1, helpText: 'snake_case, unique' }, { field: 'label', type: 'text', required: true, colSpan: 1 }, { field: 'description', type: 'textarea', colSpan: 2 }, - { field: 'version', type: 'text', colSpan: 1 }, + // `version` removed: tombstoned on AppSchema in 17.0.0 (2026-06 liveness + // audit) — an app is versioned by its owning package's `manifest.version`, + // and a second per-app number had no reader to disagree with. { field: 'icon', type: 'text', colSpan: 1, helpText: 'Lucide icon name (e.g. "users", "briefcase")' }, { field: 'active', type: 'boolean', colSpan: 1 }, { field: 'isDefault', type: 'boolean', colSpan: 1, helpText: 'Make this the default app for new users' }, @@ -26,16 +46,24 @@ export const appForm = defineForm({ fields: [ { field: 'navigation', type: 'composite', helpText: 'Nav tree — recursive structure' }, { field: 'areas', type: 'repeater', helpText: 'Group items into collapsible areas' }, - { field: 'homePageId', type: 'text', helpText: 'Landing page when app opens' }, - { field: 'mobileNavigation', type: 'composite', helpText: 'Bottom tab bar config for mobile' }, + // `homePageId` removed: tombstoned in 17.0.0 (#4667, #4709, ADR-0049) — + // an app's landing page IS its first `navigation` item by `order`, and + // the root landing follows `isDefault`. Reorder the nav instead. + // `mobileNavigation` removed: tombstoned in 17.0.0 (2026-06 liveness + // audit) — fully unimplemented, no renderer including `packages/mobile` + // ever read it. The block returns if a real mobile navigation ships. ], }, { label: 'Content', - description: 'Objects and APIs this app uses.', + description: 'Ambient assistant binding. Objects and endpoints are declared on the stack, not the app.', fields: [ - { field: 'objects', widget: 'object-selector', multiple: true, helpText: 'Object names this app exposes' }, - { field: 'apis', type: 'composite', helpText: 'API endpoint definitions' }, + // `objects` removed: tombstoned in 17.0.0 (2026-06 liveness audit) — + // objects belong to the stack (`defineStack({ objects })`) and an app + // reaches them through its navigation items (`collectNavObjects`). + // `apis` removed: tombstoned in 17.0.0 — declarative endpoints are + // declared one level up as well (`defineStack({ apis })`), where they + // execute from protocol 17 under the ADR-0121 publish gates (#5040). { field: 'defaultAgent', type: 'text', helpText: "Platform agent for the ambient assistant ('ask' by default; 'build' for authoring surfaces)" }, ], }, @@ -48,14 +76,19 @@ export const appForm = defineForm({ }, { label: 'Access & sharing', - description: 'Who can access this app and how it can be embedded.', + description: 'Who can access this app. Public access and embedding are per form view, not app-level.', collapsible: true, collapsed: true, fields: [ { field: 'requiredPermissions', widget: 'string-tags', helpText: 'Permissions needed to access this app' }, - { field: 'sharing', type: 'composite', helpText: 'Public/internal/restricted access control' }, - { field: 'embed', type: 'composite', helpText: 'iFrame embed configuration' }, - { field: 'aria', type: 'composite', helpText: 'Accessibility labels' }, + // `sharing` / `embed` removed: both tombstoned in 17.0.0 (2026-06 + // liveness audit / ADR-0049) — no public-app or iframe route ever read + // the app-level blocks, so authoring them created a false security + // impression. The live surface is `FormView.sharing` (public data + // collection), authored per form view. + // `aria` removed: tombstoned in 17.0.0 — no renderer read app-level ARIA + // attributes; declare `aria` on the component/widget that renders the + // DOM node. ], }, ],