diff --git a/.changeset/notification-action-embed-config-retired.md b/.changeset/notification-action-embed-config-retired.md new file mode 100644 index 0000000000..3600c8aa91 --- /dev/null +++ b/.changeset/notification-action-embed-config-retired.md @@ -0,0 +1,52 @@ +--- +'@objectstack/spec': major +--- + +退役 `NotificationActionSchema` / `EmbedConfigSchema`——两个从来没有授权门的 `./ui` 词汇表形状(ADR-0049 enforce-or-remove,#5015) + +## FROM → TO + +| 移除 | 改为 | +|---|---| +| `NotificationActionSchema` / `NotificationAction`(`@objectstack/spec/ui`) | **无替代形状**。删掉 import 和值。通知的呈现词汇仍在:`NotificationTypeSchema` / `NotificationSeveritySchema` / `NotificationPositionSchema` 三个枚举原样保留 | +| `EmbedConfigSchema` / `EmbedConfig`(`@objectstack/spec/ui`) | **无替代形状**。删掉 import 和值。表单的公开访问由**活门** `FormView.sharing`(`SharingConfigSchema`)授予,该门未受本次变更影响 | + +一行修复:删除 import 与值本身 —— 没有任何一份元数据源码需要改写,因为这两个形状**从来就没有键可以写进去**。升级后再 import 是 TS2305。 + +## 为什么是移除而不是收紧 + +这一档比「声明了但没人读」还要低一级:**连键都没有**。#4001 批 14 在 2026-08-03 对这两个形状做了三条独立测量,本次退役在 `origin/main` 上把三条全部重跑,每条都带同一次运行内通过的阳性对照: + +1. **承载键** —— `packages/spec/src` 里没有任何 schema 声明这两个类型的键。`ui/notification.zod` 的非测试 importer 只有 barrel;`ui/sharing.zod` 的是 barrel 加 `ui/view.zod.ts`,而后者点名的是它的**兄弟** `SharingConfigSchema`。匹配按 specifier **解析**而非子串比对 —— 仓里有两个 `sharing.zod`,子串法会把 `stack.zod.ts` 误记为 UI 那个的 importer。 +2. **图可达性** —— 从 24 个 metadata-type root 加 `defineStack` 的 `ObjectStackSchema` 做 BFS(`build-schemas.ts` 自己的走法,含 derived-clone 桥接)两个都走不到,而 `Page` / `Action` / `DashboardWidget` / `Webhook` 以及 `SharingConfig` 本身在同一次运行里全部 `root-graph`;注入一个合成承载键后两个都翻成 `root-graph`。 +3. **调用点** —— objectstack / cloud / objectui 三个仓里,除各自单测外零 `.parse()`。 + +所以没人写得进去,也从来没有东西校验过它们:这正是 #3950 记录的形状 —— 一个没有消费者的导出 schema 会被当成能力来读,而 ADR-0033 的 AI 作者会把 `EmbedConfigSchema` 出现在发布包里当作「平台支持 iframe 嵌入」的证据。 + +批 14 **故意没有**用 `.strict()` 收紧它们:strict 是一次 **parse** 的属性,对没人 parse 的形状收紧什么也不强制,只会留下*「一个被精确校验的死槽位 —— 更有说服力的谎言」*(#4583)。批 14 把判定挂成 #5015,本次是该判定的执行,裁决 REMOVE(2026-08-04)。 + +两个形状各自都是**上一层退役留下的孤儿**,这也是它们成为孤儿的原因: + +- `NotificationAction` 在 #4610 失去了两个 wrapper(`NotificationSchema` / `NotificationConfigSchema`,因零消费者被删); +- `EmbedConfig` 在 17.0.0 失去了它的键 —— 2026-06 liveness audit 退役了 `App.embed`(从来没有 iframe 路由读过它),该键至今作为 `retiredKey()` 墓碑立在 `app.zod.ts`。也就是说**写了那个键的作者早就会撞到处方**,本次删掉的是比键活得更久的值形状。 + +## ⚠️ 范围:按 SCHEMA 退役,不是按文件 + +两个模块都**存活**,并且都保留活导出: + +- `ui/sharing.zod` 保留 `SharingConfigSchema` —— 这是一个**活门**:`FormViewSchema.sharing` 承载它,`rest-server.ts` 真的读 `sharing.allowAnonymous` / `sharing.publicLink` 来挂匿名表单路由,两个示例应用都在写。**公开表单分享不受影响**; +- `ui/notification.zod` 保留三个呈现枚举。 + +`packages/spec/src/ui/notification-embed-retirement.test.ts` 把两侧都钉住:缺席按 resolved symbol identity across 每个 public entry 断言,**存活侧同样是承重的** —— 一次连文件一起删掉的「退役」能满足全部缺席断言,却会摧毁正在工作的面。 + +## 运行时行为 + +字节级不变:从来没有一个 notification action 被从元数据里解析出来,也从来没有 iframe 路由读过 embed config。 + +## 生成物基线的删除是有意的 + +`json-schema.manifest.json` 少 2 个 key,`authorable-surface.json` 少 10 行,`api-surface.json` 少 4 个导出。这是**整 def 删除**路线的预期读数(而非枚举值收窄那种四张 ratchet 全无变化的形态):`#2978` manifest ratchet 先开火要求有意删除 manifest key,删完重跑后 per-key ratchet 自行判定为 #4650 路径 3(`def no longer emitted by this build`)。 + +## objectui 侧 + +objectui 的 `animation-notification-spec-parity.test.tsx` 把 `NotificationActionSchema.shape.variant` 当**词汇表**读(不是 parse),用来双向 pin 它自己手写的 `NotificationActionButton` 接口。这恰好说明「有消费者」不等于「有授权门」。该 pin 会在 objectui 刷新本依赖时失去 spec 侧锚点,适配在 objectui 侧单独跟进 —— 本次变更不碰 objectui。 diff --git a/content/docs/references/ui/notification.mdx b/content/docs/references/ui/notification.mdx index 0e6a9d275f..155317e62e 100644 --- a/content/docs/references/ui/notification.mdx +++ b/content/docs/references/ui/notification.mdx @@ -16,28 +16,13 @@ Defines the visual presentation style of the notification. ## TypeScript Usage ```typescript -import { NotificationActionSchema, NotificationPositionSchema, NotificationSeveritySchema, NotificationTypeSchema } from '@objectstack/spec/ui'; -import type { NotificationAction, NotificationPosition, NotificationSeverity, NotificationType } from '@objectstack/spec/ui'; +import { NotificationPositionSchema, NotificationSeveritySchema, NotificationTypeSchema } from '@objectstack/spec/ui'; +import type { NotificationPosition, NotificationSeverity, NotificationType } from '@objectstack/spec/ui'; // Validate data -const result = NotificationActionSchema.parse(data); +const result = NotificationPositionSchema.parse(data); ``` ---- - -## NotificationAction - -Notification action button - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **label** | `string` | ✅ | Action button label | -| **action** | `string` | ✅ | Action identifier to execute | -| **variant** | `Enum<'primary' \| 'secondary' \| 'link'>` | ✅ | Button variant style | - - --- ## NotificationPosition diff --git a/content/docs/references/ui/sharing.mdx b/content/docs/references/ui/sharing.mdx index b6e4873884..6717d81456 100644 --- a/content/docs/references/ui/sharing.mdx +++ b/content/docs/references/ui/sharing.mdx @@ -9,11 +9,17 @@ description: Sharing protocol schemas Sharing & Embedding Protocol -Public-link sharing and iframe-embed configuration. The module name is +Public-link sharing of a form view. The module name is plural for historical -plural, but the two shapes below are in **opposite** postures, and #4001 批 14 +reasons: it once held two shapes in **opposite** postures, and #4001 批 14 -measured why rather than assuming a file-level verdict: +measured them per SCHEMA rather than assuming a file-level verdict — + +`SharingConfigSchema` a live authoring door, `EmbedConfigSchema` no door at + +all. That measurement is what let the two be disposed of separately, and the + +asymmetry survives as the reason this file reads the way it does: - `SharingConfigSchema` has a **live authoring door**. `FormViewSchema.sharing` @@ -27,13 +33,17 @@ matches. Both example apps author it (`app-showcase` `inquiry.view.ts`, `app-crm` `lead.view.ts`). It is `strictObject` as of #4001 批 14. -- `EmbedConfigSchema` has **no door at all** — see its own block below. +- `EmbedConfigSchema` was **REMOVED** at #5015 (ADR-0049 enforce-or-remove) — + +see the block below where it stood. -That split is the point. The ledger's classification question is *"who writes +The ledger's classification question is *"who writes this schema's input?"*, -this schema's input?"*, and it is answered per SCHEMA, not per file; before +and it is answered per SCHEMA, not per file; before 批 14 this file's row -批 14 this file's row carried one verdict for both. +carried one verdict for both, and a file-level verdict would have been wrong + +in one direction or the other whichever way it fell. **Source:** `packages/spec/src/ui/sharing.zod.ts` @@ -42,30 +52,13 @@ this schema's input?"*, and it is answered per SCHEMA, not per file; before ## TypeScript Usage ```typescript -import { EmbedConfigSchema, SharingConfigSchema } from '@objectstack/spec/ui'; -import type { EmbedConfig, SharingConfig } from '@objectstack/spec/ui'; +import { SharingConfigSchema } from '@objectstack/spec/ui'; +import type { SharingConfig } from '@objectstack/spec/ui'; // Validate data -const result = EmbedConfigSchema.parse(data); +const result = SharingConfigSchema.parse(data); ``` ---- - -## EmbedConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **enabled** | `boolean` | ✅ | Enable iframe embedding | -| **allowedOrigins** | `string[]` | optional | Allowed iframe parent origins (e.g. ["https://example.com"]) | -| **width** | `string` | ✅ | Embed width (CSS value) | -| **height** | `string` | ✅ | Embed height (CSS value) | -| **showHeader** | `boolean` | ✅ | Show interface header in embed | -| **showNavigation** | `boolean` | ✅ | Show navigation in embed | -| **responsive** | `boolean` | ✅ | Enable responsive resizing | - - --- ## SharingConfig diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index 72f2ba3c47..20eb02f0b7 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -21,9 +21,9 @@ regenerate. | Measure | Value | |---|---| | Triaged directories | 5 | -| Object sites in them | 476 | -| Still-open (strip) sites | 221 | -| Files carrying at least one | 36 | +| Object sites in them | 474 | +| Still-open (strip) sites | 219 | +| Files carrying at least one | 34 | Remaining strip sites by class: @@ -32,7 +32,7 @@ Remaining strip sites by class: | authorable — the ruling's forced scope | 15 | | unresolved — needs a per-schema verdict | 33 | | wire / open — out of forced scope | 104 | -| no door — no carrier, ADR-0049 territory | 38 | +| no door — no carrier, ADR-0049 territory | 36 | | no gate — carrier live, no parse | 31 | ## Posture, per triaged directory @@ -43,12 +43,12 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `ui/` | 192 | 112 | 5 | 0 | 75 | +| `ui/` | 190 | 112 | 5 | 0 | 73 | | `data/` | 162 | 54 | 1 | 0 | 107 | | `automation/` | 75 | 49 | 0 | 0 | 26 | | `security/` | 20 | 7 | 0 | 0 | 13 | | `studio/` | 27 | 27 | 0 | 0 | 0 | -| **total** | **476** | **249** | **6** | **0** | **221** | +| **total** | **474** | **249** | **6** | **0** | **219** | ## File-level triage — site counts @@ -71,17 +71,16 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `dnd.zod.ts` | 4 | | `i18n.zod.ts` | 6 | | `keyboard.zod.ts` | 4 | -| `notification.zod.ts` | 1 | | `offline.zod.ts` | 3 | | `page.zod.ts` | 7 | | `report.zod.ts` | 3 | | `responsive.zod.ts` | 4 | -| `sharing.zod.ts` | 2 | +| `sharing.zod.ts` | 1 | | `theme.zod.ts` | 6 | | `touch.zod.ts` | 7 | | `view.zod.ts` | 51 | | `widget.zod.ts` | 9 | -| **total** | **192** | +| **total** | **190** | ### `data/` — sites @@ -161,7 +160,7 @@ over it is here. ### `ui/` — open -**75 strip of 192**, in 13 file(s). +**73 strip of 190**, in 11 file(s). | File | Strip | Sites | |---|---|---| @@ -172,20 +171,18 @@ over it is here. | `dnd.zod.ts` | 4 | 4 | | `i18n.zod.ts` | 5 | 6 | | `keyboard.zod.ts` | 4 | 4 | -| `notification.zod.ts` | 1 | 1 | | `offline.zod.ts` | 3 | 3 | -| `sharing.zod.ts` | 1 | 2 | | `touch.zod.ts` | 7 | 7 | | `view.zod.ts` | 5 | 51 | | `widget.zod.ts` | 9 | 9 | -| **total** | **75** | **192** | +| **total** | **73** | **190** | | Bucket | Sites | |---|---| | authorable — the ruling's forced scope | 6 | | unresolved — needs a per-schema verdict | 0 | | wire / open — out of forced scope | 0 | -| no door — no carrier, ADR-0049 territory | 38 | +| no door — no carrier, ADR-0049 territory | 36 | | no gate — carrier live, no parse | 31 | ### `data/` — open diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index 3455d25b26..bc4469107e 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -74,11 +74,17 @@ flipped both of its no-door shapes to `root-graph`. 批 14 then found the thing 批 13's five whole-file verdicts could not show: the class is per SCHEMA like every other, and a file can **split across it**. -`ui/sharing.zod.ts` holds one live door (`SharingConfig` — carried by +`ui/sharing.zod.ts` held one live door (`SharingConfig` — carried by `FormViewSchema.sharing`, and `rest-server.ts` mounts the anonymous form routes on `sharing.allowAnonymous` + `sharing.publicLink`) beside one shape nothing in the -repo so much as names (`EmbedConfig`). A file-level verdict would have been wrong -in one direction or the other, whichever way it fell. +repo so much as named (`EmbedConfig`). A file-level verdict would have been wrong +in one direction or the other, whichever way it fell — tightening a dead slot, or +leaving the live anonymous-access door open. + +The two halves have since been disposed of separately, which is the split's real +vindication: `SharingConfig` was closed by 批 14 and is still live, and +`EmbedConfig` was REMOVED at #5015 under ADR-0049. Read the example as history +now — the file no longer splits, because the half that made it split is gone. 批 15 then found that the answer splits again, and that the split decides the follow-up. Both `no door` and `no gate` fail the same measurement — no parse, so @@ -633,8 +639,9 @@ sites left to be a verdict about. | `dataset.zod.ts` | authorable | **strict as of #4001 批 14 — 0 strip sites remain.** `DatasetSchema` was strict from the ADR-0021 cutover while the two shapes carrying the actual semantic contract — `DatasetDimension`, `DatasetMeasure` (+ `.derived`) — were not. Curated against the sibling this module's own header names, `data/analytics.zod.ts`'s Cube layer: a Cube metric's `type` IS its aggregation, so `{ name: 'revenue', type: 'sum', field: 'amount' }` parsed clean and computed a `count`; `sql` gets guidance rather than an alias, because aiming `SUM(amount)` at `field` is finding 7's trap | | `animation.zod.ts` / `dnd.zod.ts` / `keyboard.zod.ts` / `touch.zod.ts` / `offline.zod.ts` | ~~authorable (p)~~ **no door** | **no authoring door (measured, #4001 批 13)** — the `(p)` resolved NEGATIVE and the row is kept only so the arithmetic stays complete. Three independent measurements on 2026-08-03: (1) nothing under `packages/spec/src` imports these modules except the `ui/index.ts` barrel, so no schema anywhere declares a carrier key for them; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema` — the closure `build-schemas.ts` uses for the #4650 deletion check — reaches none of the 22 sites, while its three positive controls (`PageSchema`, batch 11's `WebhookSchema`, batch 10's `StateMachineSchema`) all resolve `root-graph` in the same run; (3) no `.parse()` / `.safeParse()` on any of them exists in `objectstack`, `objectui` or the example apps outside their own unit tests — objectui re-exports the inferred TYPES only and says so (#2561). `.strict()` is a property of a PARSE and there is no parse, so closing them would enforce nothing and would spend a v17 breaking change to leave *"a precisely validated dead slot — the more convincing lie"* (the #4583 row below). The live question is ADR-0049 enforce-or-remove, filed as **#4988**; each file's header comment and its test file carry the same verdict (the batch 12 three-places standard). **Do not reschedule these as strictness work** — that is what the `(p)` was for, and it has been answered | | `report.zod.ts` | authorable | **strict as of #4001 批 14 — 0 strip sites remain.** `ReportSchema` was already strict; `ReportSortSchema` and `JoinedReportBlockSchema` were not. The order key is the THIRD spelling of "sort" an author meets (`SortNodeSchema`'s `{field, order}`, the widget's flat `sortBy`/`sortOrder`, this `{by, direction}`), and the mappings run in opposite directions, so none is inferrable. ⚠️ `ReportSchema`'s OWN alias table carries a live false prescription (`filter` → `filters`, a key it also rejects; the real key is `runtimeFilter`) — out of 批 14's scope, filed as #5013 and pinned as a known defect in `strictness-batch14.test.ts` so the list cannot outlive it | -| `notification.zod.ts` | authorable (p) | **#4610 dropped two sites** — the `./ui` `Notification` (toast/banner instance) and `NotificationConfig` (toaster global config) shapes were removed: zero importers in all three repos, and both shadowed live names owned elsewhere (`./api` owns the inbox row). What remains is `NotificationActionSchema` — and 批 14 measured it as **`no door`**, the fourth class: no carrier key (the barrel is its only importer), unreachable in a 6860-node BFS from the 24 metadata-type roots + `defineStack` (four positive controls passed in the same run; an injected carrier flipped it), and zero `.parse()` outside its own test. objectui consumes its `.shape.variant` as a VOCABULARY, never parsing an authored payload — which is exactly why closing it would buy nothing. Not tightened; ADR-0049 verdict filed as #5015 | -| `sharing.zod.ts` | **split** | The first row in this ledger to carry two verdicts, and the reason the classification question is per SCHEMA rather than per file. `SharingConfigSchema` is a **live door** — `FormViewSchema.sharing` carries it, `rest-server.ts` mounts the anonymous form routes on `sharing.allowAnonymous` + `sharing.publicLink`, and both example apps author it — **strict as of #4001 批 14**. `EmbedConfigSchema` is **`no door`**: nothing in the repo so much as names the symbol, BFS-unreachable, zero parse. Not tightened; ADR-0049 verdict filed as #5015 | +| `sharing.zod.ts` | authorable | **Was this ledger's first `split` row — one file, two verdicts — and #5015 resolved the dead half, so the split is now history rather than a live classification.** `SharingConfigSchema` is a **live door** and is all that remains: `FormViewSchema.sharing` carries it, `rest-server.ts` mounts the anonymous form routes on `sharing.allowAnonymous` + `sharing.publicLink`, and both example apps author it (`app-showcase` `inquiry.view.ts`, `app-crm` `lead.view.ts`) — **strict as of #4001 批 14**. `EmbedConfigSchema` was the other verdict, **`no door`**: nothing in the repo so much as named the symbol, BFS-unreachable, zero parse. It was not tightened — *"a precisely-validated dead slot is the more convincing lie"* (#4583) — and the ADR-0049 call filed as #5015 came back **REMOVE** (2026-08-04); the shape is gone. Keep the split on the record even though the file no longer needs it: it is why the classification question is asked per SCHEMA rather than per file, and a file-level verdict here would have been wrong in one direction or the other whichever way it fell — either tightening a dead slot or leaving the live anonymous-access door open | + +`notification.zod.ts` had a row here (`authorable (p)`, resolved to **`no door`** at #4001 批 14) until #5015 retired `NotificationActionSchema` under ADR-0049 enforce-or-remove. The file survives and still exports its three presentation enums (`NotificationType` / `NotificationSeverity` / `NotificationPosition`, which objectui's toaster reads as a vocabulary) — but those are `z.enum`s, so the file now has **zero object sites** and nothing left for this ledger to classify. #4610 had already dropped two sites from it by deleting the `Notification` / `NotificationConfig` wrappers for having zero consumers; removing the action shape they would have carried is the end of that same thread. Worth keeping the trail: the row's value was never its site count but its demonstration that *having a consumer is not having an authoring door* — objectui read `NotificationActionSchema.shape.variant` as a vocabulary the whole time the shape was unreachable and unparsed. ### `data/` — file-level triage @@ -826,9 +833,9 @@ next person to open that file will look. | `dnd.zod.ts` | **no door** | ⛔ same as `touch` — #4988 | | `keyboard.zod.ts` | **no door** | ⛔ same as `touch` — #4988 | | `offline.zod.ts` | **no door** | ⛔ same as `touch` — #4988 | -| `sharing.zod.ts` | **no door** | 批 14: `SharingConfig` was a live door and is **closed**; the 1 left is `EmbedConfigSchema`, which no module in the repo even names (BFS-unreachable, zero parse). **This row shrinks without disappearing** — the first `no door` floor, the same read the `Class` column already has to carry for `flow`'s and `etl`'s wire floors. ADR-0049 verdict: #5015 | | `app.zod.ts` | verify | **批 19 ran the check and it came back NEGATIVE — no posture change, and the row's `Class` is held at `verify` deliberately (see below).** `BaseNavItemSchema`. The instruction here was to confirm the members' strictness was not already covering it before touching; it is, and the premise this row carried was wrong twice. (1) **The members do not `.extend()` the base — they spread `...BaseNavItemSchema.shape`.** That is a different mechanism, and the difference is the whole of finding 16: `.extend()` clones INHERIT the base's posture (which is how closing two `view` authoring schemas silently closed the Studio round-trip overlay), while a `...shape` spread copies the per-key schemas into a FRESH `z.object` whose posture is its own. Measured in both directions rather than read off the source, because *"closing the base closes the members"* and *"closing the base is a no-op"* are opposite claims: `strictBase.extend({…})` rejects an unknown key, `z.object({...strictBase.shape})` accepts it, `z.object({...openBase.shape}).strict()` rejects it. (2) **All nine branches already apply their own `.strict()`** with the curated `navItemUnknownKeyError` — asserted per branch through the real door (`AppSchema.navigation`, a `discriminatedUnion` on `type`), with a positive control (every base-contributed key, incl. `requiresService` which no branch declares itself, is ACCEPTED) and a negative control (an undeclared key is REJECTED) in the same run. The base is also module-private and has zero `.parse()` anywhere, so `.strict()` here would be a property of a parse that does not exist. Closing it is therefore a guaranteed no-op, and #4583 is explicit that a no-op closure is not neutral. ⚠️ **The open question is the VOCABULARY, not the measurement** — which is why the `Class` cell was not changed, since it is machine-read and a guess here would be published as a confident subtotal. The two-axis table above resolves carrier-absent + parse-absent to `no door`, whose prescribed follow-up is ADR-0049 retirement — and that prescription is *destructive* here: the vocabulary is fully ALIVE and fully GATED at nine consumers, so retiring the base would delete nine branches' shared keys. `no gate` is wrong for the mirror reason (the gate exists, at the members). `authorable` is the `FormFieldBaseSchema` precedent one row over in `view.zod.ts` — but that base really is `.extend()`ed, so closing it WOULD change behaviour, and calling this one `authorable` invites exactly the later sweep that "finishes the job" on a shape nothing parses. None of the eight enumerated verdicts is honest for a shape that is neither a door nor dead, and adding a ninth changes a machine-read contract — so the decision is the maintainer's (**#5249**). Recorded in three places (the `BaseNavItemSchema` JSDoc + `app-strictness-batch19.test.ts` + this row); the pin includes a guard that fails if any branch ever stops rejecting unknown keys, which is the one change that would make this verdict need re-taking | -| `notification.zod.ts` | **no door** | 批 14: `NotificationActionSchema` reclassified, not tightened — no carrier key, BFS-unreachable, zero parse; objectui reads its `.shape` as a vocabulary. ADR-0049 verdict: #5015 | + +`sharing.zod.ts` and `notification.zod.ts` left this table at **#5015** by a route no other row has taken: not by being CLOSED, but by having their remaining sites REMOVED. Both were `no door` — ADR-0049 territory, explicitly out of this ratchet's scope — and the enforce-or-remove call came back REMOVE, so `EmbedConfigSchema` and `NotificationActionSchema` are gone rather than strict. Read the reverse pin carefully here, because it fires on zero either way and cannot tell the two routes apart: the `sharing.zod.ts` row said in as many words that it *"shrinks without disappearing — the first `no door` floor"*, and that was true right up until the floor was retired out from under it. A deliberate floor and a retired one look identical from the count; only the `Class` column and this paragraph separate them. `sharing.zod.ts` keeps its TRIAGE row above, because `SharingConfigSchema` is still there and still strict — the file is closed, not empty. `notification.zod.ts` keeps no row anywhere: it has zero object sites left. `responsive.zod.ts` left this table at **批 13** (#4001) on reverse-pin evidence — it reached 0 strip, the gate went red on the row still being there, and the row @@ -937,9 +944,12 @@ strictness batch is scheduled — the ratchet is very nearly done here, and what remains open is overwhelmingly work for OTHER issues: - **`no door`** — `touch`, `animation`, `dnd`, `keyboard` and `offline` from - 批 13; `sharing.zod.ts`'s `EmbedConfig` and `notification.zod.ts`'s - `NotificationAction` from 批 14; `widget.zod.ts` plus `i18n.zod.ts`'s - remainder from 批 16 (#4988, #5015, #5055). + 批 13; `widget.zod.ts` plus `i18n.zod.ts`'s remainder from 批 16 (#4988, + #5055). 批 14's two — `sharing.zod.ts`'s `EmbedConfig` and + `notification.zod.ts`'s `NotificationAction` — are **no longer open**: #5015 + answered their ADR-0049 call REMOVE and both shapes are gone, which is what a + `no door` row is supposed to end in. They are the first entries in this list + to be closed by retirement rather than by a batch. - **`no gate`** — `chart.zod.ts`'s remaining pair from 批 15, plus **all of `component.zod.ts` from 批 17** (#5068). That single row is the campaign's largest reclassification and the reason this subtotal fell by 29 without one diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index fc179cbae0..46740c233b 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -202,6 +202,8 @@ The same widget drill retires four more keys (#5010): the action trio `actionUrl Finally, the theme token scales retire (#5021, ADR-0049): `typography.fontSize`, `typography.fontWeight`, `typography.lineHeight`, `typography.letterSpacing`, `typography.fontFamily.heading`, `typography.fontFamily.mono`, `animation` and `zIndex`. These are the reverse of the usual inert key and the distinction is the point: the theme engine DID emit them — `--font-size-*`, `--font-weight-*`, `--line-height-*`, `--letter-spacing-*`, `--duration-*`, `--timing-*`, `--z-*`, `--font-heading`, `--font-mono` all reached the document exactly as authored — and no first-party component or stylesheet has ever read one, so a declared type scale was real CSS that styled nothing. That is why the earlier theme sweep (#3494) left them standing: its criterion was "never emitted", and these are emitted. `colors`, `borderRadius`, `shadows` and `typography.fontFamily.base` have live consumers and are untouched. The prescription is `customVars`, which emits `--: ` verbatim — so a tenant stylesheet that really was reading `--z-modal` reproduces it byte for byte and loses no capability. The conversion DELETES the keys and emits a notice per key rather than auto-populating `customVars`: a rewrite would hand back two dozen variables that still nothing reads, turning a dead semantic slot into a dead literal one. Deciding which of them you actually consume is yours to make; the notice names each one. Retired from the load path with the other keys that misdescribed themselves. +It closes the enforce-or-remove line with two `./ui` vocabulary shapes that never had a key to be written into (#5015): `NotificationActionSchema` / `NotificationAction` and `EmbedConfigSchema` / `EmbedConfig`. This is the class BELOW a declared-but-unread key — there was no key at all. No schema anywhere declared a carrier, a BFS from all 24 metadata-type roots plus `ObjectStackSchema` reached neither (with `Page` / `Action` / `DashboardWidget` / `Webhook` / `SharingConfig` as positive controls in the same run, and a synthetic carrier flipping both), and no repo parsed either outside its own unit test. Each was left behind by an earlier retirement one level up: the notification action by #4610, which deleted the two wrapper shapes that could have carried it, and the embed config by 17.0.0's own `App.embed` tombstone. #4001 批 14 measured both and deliberately declined to close them with `.strict()`, because strictness on a shape nothing parses enforces nothing and only makes a dead slot look load-bearing (#4583); #5015 is that deferred call, answered REMOVE. Nothing is applied for you and nothing needs to be — there is no key in any source to rewrite; the change is visible only as TS2305 on an import. ⚠️ Read the scope precisely, because one of the two modules SPLITS: `ui/sharing.zod` keeps `SharingConfigSchema` and it stays LIVE — `FormView.sharing` carries it and `rest-server.ts` mounts the anonymous form routes on `allowAnonymous` + `publicLink`, so public form sharing is untouched — and `ui/notification.zod` keeps `NotificationType` / `NotificationSeverity` / `NotificationPosition`. Only the two named shapes go. + ### Mechanical (applied for you) | Conversion | Surface | Change | Load window | @@ -320,6 +322,9 @@ Finally, the theme token scales retire (#5021, ADR-0049): `typography.fontSize`, - **`declarative-apis-endpoints-live`** — `stack.apis[] (every declared ApiEndpoint — REVIEW REQUIRED BEFORE UPGRADING)` → the same declarations, re-read as LIVE HTTP routes: `path` moved under `/api/v1/apps//`, and every entry that declares `authRequired: false` re-confirmed as an intentionally anonymous endpoint carrying `rateLimit: { enabled: true, … }` - Why not automatic: This is the one protocol-17 entry that turns metadata ON rather than off, so read it as a SECURITY review item and not as a rename. Before 17 the declarative endpoint surface executed NOTHING: no route was mounted for a declared `path`, no matcher existed, and every key — `authRequired` included — parsed green and gated nothing (#4936, which refused a non-empty `apis:` outright for exactly that reason). Protocol 17 ships the executor (#5040) and narrows that refusal to a per-endpoint publish gate: an endpoint that PASSES the gate is mounted and serves real traffic as soon as the stack is published. So an `apis:` block written against an older major — or one restored from a pre-#4936 source, or authored from a doc that predates the refusal — changes meaning without changing a byte: what used to be inert documentation becomes an execution entry point into the data and automation pipelines. Nothing about that transition can be applied mechanically, because the judgment it needs is "did the author of this endpoint mean for the internet to reach it?" — and the one key where a wrong answer is unrecoverable is `authRequired`. Its schema default is `true`, so an omission is SAFE and needs no review; an EXPLICIT `authRequired: false` is the only thing that opens anonymous access, and under ADR-0121 D6 it now also requires an armed `rateLimit` (`enabled: true` — the key defaults to `false`, so a budget written without it meters nothing) or the stack refuses to publish. Grep every `apis:` entry for `authRequired: false` before you upgrade, delete the ones that were never meant to be public, and arm a budget on the ones that were. The path move is the mechanical-looking half and is still yours: ADR-0121 D1/D2 confine a declared path to your own namespace carve-out (`/api/v1/apps//…`), the namespace comes from an explicit `manifest.namespace` with no derivation fallback, and the subpath is the only part you name — rewriting it for you would silently change a URL third parties call. - Done when: You have READ every entry of every `apis:` block, not just the ones that fail to publish. Concretely: (1) each declared `path` is `/api/v1/apps//` and the stack declares that `manifest.namespace` explicitly; (2) every entry declaring `authRequired: false` is one you INTEND to be reachable without a session, and each carries `rateLimit: { enabled: true, windowMs, maxRequests }` — entries that were not intended to be anonymous have the key removed so the safe default (`true`) applies; (3) `objectstack validate` passes, which also proves no endpoint declares a shape 17.x cannot execute (`type: script` / `proxy`, mapping `transform`, an `object_operation` missing `objectParams`, `cacheTtl` on a non-GET method, `inputMapping` on find/get/delete, or two endpoints claiming one METHOD + path); and (4) after publishing, each endpoint answers as you expect — an anonymous request to a session-only endpoint returns 401 rather than data. +- **`ui-notification-action-embed-config-retired`** — `ui.notificationAction / ui.embedConfig` → (removed — there is no replacement shape, because there was never a key to write either into. Delete the import and the value. Notification presentation is still described by the surviving `NotificationType` / `NotificationSeverity` / `NotificationPosition` vocabulary; public access to a form is granted by the LIVE `FormView.sharing` block (`SharingConfig`), which is untouched. Notification action buttons as metadata, and iframe embedding, return via the enforce route of ADR-0049 through a new ADR — carrier key and renderer first, vocabulary second) + - Why not automatic: Both shapes were published `@objectstack/spec/ui` vocabulary with NO AUTHORING DOOR. #4001 批 14 measured them three ways on 2026-08-03 and this retirement re-ran all three against `origin/main` before removing anything, each with a positive control that passed in the same run: (1) CARRIER — no schema in `packages/spec/src` declared a key of either type (`ui/notification.zod`'s only non-test importer was the barrel; `ui/sharing.zod`'s were the barrel and `ui/view.zod.ts`, which names its SIBLING `SharingConfigSchema`), measured by resolving specifiers rather than substring-matching, because the repo holds two `sharing.zod` modules and a substring test miscredits `stack.zod.ts` to the UI one; (2) REACHABILITY — a BFS from the 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema`, over `build-schemas.ts`'s own walk including its derived-clone bridge, never reached either, while `Page` / `Action` / `DashboardWidget` / `Webhook` and `SharingConfig` itself all resolved `root-graph` in the same run and an injected synthetic carrier flipped both; (3) PARSE — zero `.parse()` in objectstack, cloud or objectui outside their own unit tests. So nobody could author one and nothing ever validated one: the #3950 shape, an exported schema with no consumer read as a capability, and the ADR-0033 trap where an AI author takes `EmbedConfigSchema` in the published bundle as proof the platform serves iframes. Neither is stored metadata and neither has a carrier, so no `sys_metadata` row can hold one and there is no source for the D2 chain to rewrite; this entry is the D3 record. 批 14 deliberately did NOT close them with `.strict()` — strictness is a property of a PARSE, and closing a shape nothing parses buys only "a precisely-validated dead slot, the more convincing lie" (#4583) — and filed the disposition as #5015, ruled REMOVE on 2026-08-04. Each was orphaned by an earlier retirement one level up: `NotificationAction` lost its wrappers at #4610 (`NotificationSchema` / `NotificationConfigSchema`, deleted for zero consumers), and `EmbedConfig` lost its key at 17.0.0 when the 2026-06 liveness audit retired `App.embed` (no iframe route ever read it) — that key still stands as a `retiredKey()` tombstone in `app.zod.ts`, so an author who wrote the KEY already meets a prescription; this removes the value shape that outlived it. ⚠️ The retirement is per SCHEMA, not per file: `ui/sharing.zod` KEEPS `SharingConfigSchema`, a live door carried by `FormViewSchema.sharing` and read by `rest-server.ts` to mount the anonymous form routes, and `ui/notification.zod` keeps its three presentation enums. objectui consumed `NotificationActionSchema.shape.variant` as a VOCABULARY (never a parse) to pin its own hand-written `NotificationActionButton` interface — which is exactly why "has a consumer" never meant "has an authoring door" here; that pin is adapted objectui-side when it refreshes this dependency. ADR-0049, #5015. + - Done when: No code imports `NotificationActionSchema`, `NotificationAction`, `EmbedConfigSchema` or `EmbedConfig` from `@objectstack/spec` or `@objectstack/spec/ui` — both are TS2305 after upgrade, on every public entry (pinned by resolved symbol identity in `notification-embed-retirement.test.ts`). The same pin asserts the SURVIVORS in the same run, and that half is equally load-bearing: `NotificationTypeSchema` / `NotificationSeveritySchema` / `NotificationPositionSchema` and `SharingConfigSchema` must still be exported from `./ui`, and both modules must still load — a retirement that deleted either file would satisfy the absence half while destroying working surface. Nothing regresses at runtime, because nothing ever ran: no notification action was ever parsed from metadata and no iframe route ever read an embed config. Public form sharing is unaffected — `FormView.sharing` still gates the anonymous endpoints on `allowAnonymous` + `publicLink`. --- diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 75e3d8ca7c..b23595550f 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -3269,8 +3269,6 @@ "ElementRecordPickerPropsSchema (const)", "ElementTextInputPropsSchema (const)", "ElementTextPropsSchema (const)", - "EmbedConfig (type)", - "EmbedConfigSchema (const)", "EvictionPolicy (type)", "EvictionPolicySchema (const)", "ExpandViewResult (interface)", @@ -3351,8 +3349,6 @@ "NavigationItemInput (type)", "NavigationItemSchema (const)", "NavigationModeSchema (const)", - "NotificationAction (type)", - "NotificationActionSchema (const)", "NotificationPosition (type)", "NotificationPositionSchema (const)", "NotificationSeverity (type)", diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index cfde5a17ef..400095e829 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -7289,13 +7289,6 @@ "ui/ElementTextProps:aria", "ui/ElementTextProps:content", "ui/ElementTextProps:variant", - "ui/EmbedConfig:allowedOrigins", - "ui/EmbedConfig:enabled", - "ui/EmbedConfig:height", - "ui/EmbedConfig:responsive", - "ui/EmbedConfig:showHeader", - "ui/EmbedConfig:showNavigation", - "ui/EmbedConfig:width", "ui/FocusManagement:arrowNavigation", "ui/FocusManagement:focusTrap", "ui/FocusManagement:focusVisible", @@ -7583,9 +7576,6 @@ "ui/NavigationContribution:group", "ui/NavigationContribution:items", "ui/NavigationContribution:priority", - "ui/NotificationAction:action", - "ui/NotificationAction:label", - "ui/NotificationAction:variant", "ui/NumberFormat:currency", "ui/NumberFormat:maximumFractionDigits", "ui/NumberFormat:minimumFractionDigits", diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json index 3a36673ca7..47bf185eda 100644 --- a/packages/spec/json-schema.manifest.json +++ b/packages/spec/json-schema.manifest.json @@ -1520,7 +1520,6 @@ "ui/ElementRecordPickerProps", "ui/ElementTextInputProps", "ui/ElementTextProps", - "ui/EmbedConfig", "ui/EvictionPolicy", "ui/FocusManagement", "ui/FocusTrapConfig", @@ -1559,7 +1558,6 @@ "ui/NavigationContribution", "ui/NavigationItem", "ui/NavigationMode", - "ui/NotificationAction", "ui/NotificationPosition", "ui/NotificationSeverity", "ui/NotificationType", diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 2a34c461f1..67aa753e37 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -552,6 +552,13 @@ "migrationId": "declarative-apis-endpoints-live", "toMajor": 17, "rationale": "This is the one protocol-17 entry that turns metadata ON rather than off, so read it as a SECURITY review item and not as a rename. Before 17 the declarative endpoint surface executed NOTHING: no route was mounted for a declared `path`, no matcher existed, and every key — `authRequired` included — parsed green and gated nothing (#4936, which refused a non-empty `apis:` outright for exactly that reason). Protocol 17 ships the executor (#5040) and narrows that refusal to a per-endpoint publish gate: an endpoint that PASSES the gate is mounted and serves real traffic as soon as the stack is published. So an `apis:` block written against an older major — or one restored from a pre-#4936 source, or authored from a doc that predates the refusal — changes meaning without changing a byte: what used to be inert documentation becomes an execution entry point into the data and automation pipelines. Nothing about that transition can be applied mechanically, because the judgment it needs is \"did the author of this endpoint mean for the internet to reach it?\" — and the one key where a wrong answer is unrecoverable is `authRequired`. Its schema default is `true`, so an omission is SAFE and needs no review; an EXPLICIT `authRequired: false` is the only thing that opens anonymous access, and under ADR-0121 D6 it now also requires an armed `rateLimit` (`enabled: true` — the key defaults to `false`, so a budget written without it meters nothing) or the stack refuses to publish. Grep every `apis:` entry for `authRequired: false` before you upgrade, delete the ones that were never meant to be public, and arm a budget on the ones that were. The path move is the mechanical-looking half and is still yours: ADR-0121 D1/D2 confine a declared path to your own namespace carve-out (`/api/v1/apps//…`), the namespace comes from an explicit `manifest.namespace` with no derivation fallback, and the subpath is the only part you name — rewriting it for you would silently change a URL third parties call." + }, + { + "surface": "ui.notificationAction / ui.embedConfig", + "replacement": "(removed — there is no replacement shape, because there was never a key to write either into. Delete the import and the value. Notification presentation is still described by the surviving `NotificationType` / `NotificationSeverity` / `NotificationPosition` vocabulary; public access to a form is granted by the LIVE `FormView.sharing` block (`SharingConfig`), which is untouched. Notification action buttons as metadata, and iframe embedding, return via the enforce route of ADR-0049 through a new ADR — carrier key and renderer first, vocabulary second)", + "migrationId": "ui-notification-action-embed-config-retired", + "toMajor": 17, + "rationale": "Both shapes were published `@objectstack/spec/ui` vocabulary with NO AUTHORING DOOR. #4001 批 14 measured them three ways on 2026-08-03 and this retirement re-ran all three against `origin/main` before removing anything, each with a positive control that passed in the same run: (1) CARRIER — no schema in `packages/spec/src` declared a key of either type (`ui/notification.zod`'s only non-test importer was the barrel; `ui/sharing.zod`'s were the barrel and `ui/view.zod.ts`, which names its SIBLING `SharingConfigSchema`), measured by resolving specifiers rather than substring-matching, because the repo holds two `sharing.zod` modules and a substring test miscredits `stack.zod.ts` to the UI one; (2) REACHABILITY — a BFS from the 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema`, over `build-schemas.ts`'s own walk including its derived-clone bridge, never reached either, while `Page` / `Action` / `DashboardWidget` / `Webhook` and `SharingConfig` itself all resolved `root-graph` in the same run and an injected synthetic carrier flipped both; (3) PARSE — zero `.parse()` in objectstack, cloud or objectui outside their own unit tests. So nobody could author one and nothing ever validated one: the #3950 shape, an exported schema with no consumer read as a capability, and the ADR-0033 trap where an AI author takes `EmbedConfigSchema` in the published bundle as proof the platform serves iframes. Neither is stored metadata and neither has a carrier, so no `sys_metadata` row can hold one and there is no source for the D2 chain to rewrite; this entry is the D3 record. 批 14 deliberately did NOT close them with `.strict()` — strictness is a property of a PARSE, and closing a shape nothing parses buys only \"a precisely-validated dead slot, the more convincing lie\" (#4583) — and filed the disposition as #5015, ruled REMOVE on 2026-08-04. Each was orphaned by an earlier retirement one level up: `NotificationAction` lost its wrappers at #4610 (`NotificationSchema` / `NotificationConfigSchema`, deleted for zero consumers), and `EmbedConfig` lost its key at 17.0.0 when the 2026-06 liveness audit retired `App.embed` (no iframe route ever read it) — that key still stands as a `retiredKey()` tombstone in `app.zod.ts`, so an author who wrote the KEY already meets a prescription; this removes the value shape that outlived it. ⚠️ The retirement is per SCHEMA, not per file: `ui/sharing.zod` KEEPS `SharingConfigSchema`, a live door carried by `FormViewSchema.sharing` and read by `rest-server.ts` to mount the anonymous form routes, and `ui/notification.zod` keeps its three presentation enums. objectui consumed `NotificationActionSchema.shape.variant` as a VOCABULARY (never a parse) to pin its own hand-written `NotificationActionButton` interface — which is exactly why \"has a consumer\" never meant \"has an authoring door\" here; that pin is adapted objectui-side when it refreshes this dependency. ADR-0049, #5015." } ], "removed": [] @@ -1163,6 +1170,13 @@ "migrationId": "declarative-apis-endpoints-live", "toMajor": 17, "rationale": "This is the one protocol-17 entry that turns metadata ON rather than off, so read it as a SECURITY review item and not as a rename. Before 17 the declarative endpoint surface executed NOTHING: no route was mounted for a declared `path`, no matcher existed, and every key — `authRequired` included — parsed green and gated nothing (#4936, which refused a non-empty `apis:` outright for exactly that reason). Protocol 17 ships the executor (#5040) and narrows that refusal to a per-endpoint publish gate: an endpoint that PASSES the gate is mounted and serves real traffic as soon as the stack is published. So an `apis:` block written against an older major — or one restored from a pre-#4936 source, or authored from a doc that predates the refusal — changes meaning without changing a byte: what used to be inert documentation becomes an execution entry point into the data and automation pipelines. Nothing about that transition can be applied mechanically, because the judgment it needs is \"did the author of this endpoint mean for the internet to reach it?\" — and the one key where a wrong answer is unrecoverable is `authRequired`. Its schema default is `true`, so an omission is SAFE and needs no review; an EXPLICIT `authRequired: false` is the only thing that opens anonymous access, and under ADR-0121 D6 it now also requires an armed `rateLimit` (`enabled: true` — the key defaults to `false`, so a budget written without it meters nothing) or the stack refuses to publish. Grep every `apis:` entry for `authRequired: false` before you upgrade, delete the ones that were never meant to be public, and arm a budget on the ones that were. The path move is the mechanical-looking half and is still yours: ADR-0121 D1/D2 confine a declared path to your own namespace carve-out (`/api/v1/apps//…`), the namespace comes from an explicit `manifest.namespace` with no derivation fallback, and the subpath is the only part you name — rewriting it for you would silently change a URL third parties call." + }, + { + "surface": "ui.notificationAction / ui.embedConfig", + "replacement": "(removed — there is no replacement shape, because there was never a key to write either into. Delete the import and the value. Notification presentation is still described by the surviving `NotificationType` / `NotificationSeverity` / `NotificationPosition` vocabulary; public access to a form is granted by the LIVE `FormView.sharing` block (`SharingConfig`), which is untouched. Notification action buttons as metadata, and iframe embedding, return via the enforce route of ADR-0049 through a new ADR — carrier key and renderer first, vocabulary second)", + "migrationId": "ui-notification-action-embed-config-retired", + "toMajor": 17, + "rationale": "Both shapes were published `@objectstack/spec/ui` vocabulary with NO AUTHORING DOOR. #4001 批 14 measured them three ways on 2026-08-03 and this retirement re-ran all three against `origin/main` before removing anything, each with a positive control that passed in the same run: (1) CARRIER — no schema in `packages/spec/src` declared a key of either type (`ui/notification.zod`'s only non-test importer was the barrel; `ui/sharing.zod`'s were the barrel and `ui/view.zod.ts`, which names its SIBLING `SharingConfigSchema`), measured by resolving specifiers rather than substring-matching, because the repo holds two `sharing.zod` modules and a substring test miscredits `stack.zod.ts` to the UI one; (2) REACHABILITY — a BFS from the 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema`, over `build-schemas.ts`'s own walk including its derived-clone bridge, never reached either, while `Page` / `Action` / `DashboardWidget` / `Webhook` and `SharingConfig` itself all resolved `root-graph` in the same run and an injected synthetic carrier flipped both; (3) PARSE — zero `.parse()` in objectstack, cloud or objectui outside their own unit tests. So nobody could author one and nothing ever validated one: the #3950 shape, an exported schema with no consumer read as a capability, and the ADR-0033 trap where an AI author takes `EmbedConfigSchema` in the published bundle as proof the platform serves iframes. Neither is stored metadata and neither has a carrier, so no `sys_metadata` row can hold one and there is no source for the D2 chain to rewrite; this entry is the D3 record. 批 14 deliberately did NOT close them with `.strict()` — strictness is a property of a PARSE, and closing a shape nothing parses buys only \"a precisely-validated dead slot, the more convincing lie\" (#4583) — and filed the disposition as #5015, ruled REMOVE on 2026-08-04. Each was orphaned by an earlier retirement one level up: `NotificationAction` lost its wrappers at #4610 (`NotificationSchema` / `NotificationConfigSchema`, deleted for zero consumers), and `EmbedConfig` lost its key at 17.0.0 when the 2026-06 liveness audit retired `App.embed` (no iframe route ever read it) — that key still stands as a `retiredKey()` tombstone in `app.zod.ts`, so an author who wrote the KEY already meets a prescription; this removes the value shape that outlived it. ⚠️ The retirement is per SCHEMA, not per file: `ui/sharing.zod` KEEPS `SharingConfigSchema`, a live door carried by `FormViewSchema.sharing` and read by `rest-server.ts` to mount the anonymous form routes, and `ui/notification.zod` keeps its three presentation enums. objectui consumed `NotificationActionSchema.shape.variant` as a VOCABULARY (never a parse) to pin its own hand-written `NotificationActionButton` interface — which is exactly why \"has a consumer\" never meant \"has an authoring door\" here; that pin is adapted objectui-side when it refreshes this dependency. ADR-0049, #5015." } ], "removed": [] diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 000c65bfd0..1d32c2de89 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -911,7 +911,27 @@ const step17: MigrationStep = { + '`customVars`: a rewrite would hand back two dozen variables that still nothing reads, ' + 'turning a dead semantic slot into a dead literal one. Deciding which of them you actually ' + 'consume is yours to make; the notice names each one. Retired from the load path with the ' - + 'other keys that misdescribed themselves.', + + 'other keys that misdescribed themselves.\n\n' + + 'It closes the enforce-or-remove line with two `./ui` vocabulary shapes that never ' + + 'had a key to be written into (#5015): `NotificationActionSchema` / `NotificationAction` ' + + 'and `EmbedConfigSchema` / `EmbedConfig`. This is the class BELOW a declared-but-unread ' + + 'key — there was no key at all. No schema anywhere declared a carrier, a BFS from all 24 ' + + 'metadata-type roots plus `ObjectStackSchema` reached neither (with `Page` / `Action` / ' + + '`DashboardWidget` / `Webhook` / `SharingConfig` as positive controls in the same run, and ' + + 'a synthetic carrier flipping both), and no repo parsed either outside its own unit test. ' + + 'Each was left behind by an earlier retirement one level up: the notification action by ' + + '#4610, which deleted the two wrapper shapes that could have carried it, and the embed ' + + 'config by 17.0.0\'s own `App.embed` tombstone. #4001 批 14 measured both and deliberately ' + + 'declined to close them with `.strict()`, because strictness on a shape nothing parses ' + + 'enforces nothing and only makes a dead slot look load-bearing (#4583); #5015 is that ' + + 'deferred call, answered REMOVE. Nothing is applied for you and nothing needs to be — ' + + 'there is no key in any source to rewrite; the change is visible only as TS2305 on an ' + + 'import. ⚠️ Read the scope precisely, because one of the two modules SPLITS: ' + + '`ui/sharing.zod` keeps `SharingConfigSchema` and it stays LIVE — `FormView.sharing` ' + + 'carries it and `rest-server.ts` mounts the anonymous form routes on ' + + '`allowAnonymous` + `publicLink`, so public form sharing is untouched — and ' + + '`ui/notification.zod` keeps `NotificationType` / `NotificationSeverity` / ' + + '`NotificationPosition`. Only the two named shapes go.', conversionIds: [ 'action-execute-to-target', 'field-conditionalRequired-to-requiredWhen', @@ -1631,6 +1651,68 @@ const step17: MigrationStep = { + '(4) after publishing, each endpoint answers as you expect — an anonymous request to ' + 'a session-only endpoint returns 401 rather than data.', }, + { + id: 'ui-notification-action-embed-config-retired', + surface: 'ui.notificationAction / ui.embedConfig', + replacement: + '(removed — there is no replacement shape, because there was never a key to write ' + + 'either into. Delete the import and the value. Notification presentation is still ' + + 'described by the surviving `NotificationType` / `NotificationSeverity` / ' + + '`NotificationPosition` vocabulary; public access to a form is granted by the LIVE ' + + '`FormView.sharing` block (`SharingConfig`), which is untouched. Notification action ' + + 'buttons as metadata, and iframe embedding, return via the enforce route of ADR-0049 ' + + 'through a new ADR — carrier key and renderer first, vocabulary second)', + reason: + 'Both shapes were published `@objectstack/spec/ui` vocabulary with NO AUTHORING DOOR. ' + + '#4001 批 14 measured them three ways on 2026-08-03 and this retirement re-ran all ' + + 'three against `origin/main` before removing anything, each with a positive control ' + + 'that passed in the same run: (1) CARRIER — no schema in `packages/spec/src` declared ' + + 'a key of either type (`ui/notification.zod`\'s only non-test importer was the ' + + 'barrel; `ui/sharing.zod`\'s were the barrel and `ui/view.zod.ts`, which names its ' + + 'SIBLING `SharingConfigSchema`), measured by resolving specifiers rather than ' + + 'substring-matching, because the repo holds two `sharing.zod` modules and a substring ' + + 'test miscredits `stack.zod.ts` to the UI one; (2) REACHABILITY — a BFS from the 24 ' + + 'metadata-type roots plus `defineStack`\'s `ObjectStackSchema`, over ' + + '`build-schemas.ts`\'s own walk including its derived-clone bridge, never reached ' + + 'either, while `Page` / `Action` / `DashboardWidget` / `Webhook` and `SharingConfig` ' + + 'itself all resolved `root-graph` in the same run and an injected synthetic carrier ' + + 'flipped both; (3) PARSE — zero `.parse()` in objectstack, cloud or objectui outside ' + + 'their own unit tests. So nobody could author one and nothing ever validated one: ' + + 'the #3950 shape, an exported schema with no consumer read as a capability, and the ' + + 'ADR-0033 trap where an AI author takes `EmbedConfigSchema` in the published bundle ' + + 'as proof the platform serves iframes. Neither is stored metadata and neither has a ' + + 'carrier, so no `sys_metadata` row can hold one and there is no source for the D2 ' + + 'chain to rewrite; this entry is the D3 record. 批 14 deliberately did NOT close them ' + + 'with `.strict()` — strictness is a property of a PARSE, and closing a shape nothing ' + + 'parses buys only "a precisely-validated dead slot, the more convincing lie" (#4583) ' + + '— and filed the disposition as #5015, ruled REMOVE on 2026-08-04. Each was orphaned ' + + 'by an earlier retirement one level up: `NotificationAction` lost its wrappers at ' + + '#4610 (`NotificationSchema` / `NotificationConfigSchema`, deleted for zero ' + + 'consumers), and `EmbedConfig` lost its key at 17.0.0 when the 2026-06 liveness audit ' + + 'retired `App.embed` (no iframe route ever read it) — that key still stands as a ' + + '`retiredKey()` tombstone in `app.zod.ts`, so an author who wrote the KEY already ' + + 'meets a prescription; this removes the value shape that outlived it. ⚠️ The ' + + 'retirement is per SCHEMA, not per file: `ui/sharing.zod` KEEPS `SharingConfigSchema`, ' + + 'a live door carried by `FormViewSchema.sharing` and read by `rest-server.ts` to mount ' + + 'the anonymous form routes, and `ui/notification.zod` keeps its three presentation ' + + 'enums. objectui consumed `NotificationActionSchema.shape.variant` as a VOCABULARY ' + + '(never a parse) to pin its own hand-written `NotificationActionButton` interface — ' + + 'which is exactly why "has a consumer" never meant "has an authoring door" here; that ' + + 'pin is adapted objectui-side when it refreshes this dependency. ADR-0049, #5015.', + acceptanceCriteria: + 'No code imports `NotificationActionSchema`, `NotificationAction`, `EmbedConfigSchema` ' + + 'or `EmbedConfig` from `@objectstack/spec` or `@objectstack/spec/ui` — both are ' + + 'TS2305 after upgrade, on every public entry (pinned by resolved symbol identity in ' + + '`notification-embed-retirement.test.ts`). The same pin asserts the SURVIVORS in the ' + + 'same run, and that half is equally load-bearing: `NotificationTypeSchema` / ' + + '`NotificationSeveritySchema` / `NotificationPositionSchema` and `SharingConfigSchema` ' + + 'must still be exported from `./ui`, and both modules must still load — a retirement ' + + 'that deleted either file would satisfy the absence half while destroying working ' + + 'surface. Nothing regresses at runtime, because nothing ever ran: no notification ' + + 'action was ever parsed from metadata and no iframe route ever read an embed config. ' + + 'Public form sharing is unaffected — `FormView.sharing` still gates the anonymous ' + + 'endpoints on `allowAnonymous` + `publicLink`.', + }, ], }; diff --git a/packages/spec/src/ui/index.ts b/packages/spec/src/ui/index.ts index 95a63578c7..ef6c555fd0 100644 --- a/packages/spec/src/ui/index.ts +++ b/packages/spec/src/ui/index.ts @@ -39,6 +39,15 @@ export * from './touch.zod'; export * from './offline.zod'; export * from './keyboard.zod'; export * from './animation.zod'; +// `notification.zod` still exports the three presentation enums +// (`NotificationType` / `NotificationSeverity` / `NotificationPosition`); +// `NotificationActionSchema` / `NotificationAction` were REMOVED at #5015 per +// ADR-0049 enforce-or-remove — no carrier key, unreachable from every metadata +// root, zero parse. See the block in that module. export * from './notification.zod'; export * from './dnd.zod'; +// `sharing.zod` still exports the LIVE `SharingConfigSchema` (carried by +// `FormViewSchema.sharing`; `rest-server.ts` gates the anonymous form routes on +// it). `EmbedConfigSchema` / `EmbedConfig` were REMOVED at #5015 per ADR-0049 — +// one file, two verdicts. See the block in that module. export * from './sharing.zod'; diff --git a/packages/spec/src/ui/notification-embed-retirement.test.ts b/packages/spec/src/ui/notification-embed-retirement.test.ts new file mode 100644 index 0000000000..cd68e698d7 --- /dev/null +++ b/packages/spec/src/ui/notification-embed-retirement.test.ts @@ -0,0 +1,193 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; + +// ─── [#5015] `NotificationActionSchema` + `EmbedConfigSchema` are REMOVED ──── +// +// ADR-0049 enforce-or-remove, ruled REMOVE on 2026-08-04. Both shapes were +// published vocabulary with **no authoring door**: no schema anywhere declared a +// carrier key for either, neither was reachable by a BFS from the 24 +// metadata-type roots plus `defineStack`'s `ObjectStackSchema`, and no +// `.parse()` on either existed in objectstack, cloud or objectui outside their +// own unit tests. #4001 批 14 measured that and deliberately did NOT tighten +// them — closing a shape nothing parses only buys *"a precisely-validated dead +// slot, the more convincing lie"* (#4583) — and filed the disposition as #5015. +// This is that disposition, executed. +// +// The two arrived at orphanhood by the same route, one level apart: +// - `NotificationAction` lost its wrappers at #4610 (`NotificationSchema` / +// `NotificationConfigSchema`, deleted for zero consumers); +// - `EmbedConfig` lost its key at 17.0.0 (`App.embed`, retired by the 2026-06 +// liveness audit and still standing as a `retiredKey()` tombstone in +// `app.zod.ts` — the value shape simply outlived the key). +// An exported schema with no consumer is read as a capability (#3950), so the +// value shapes follow the keys. +// +// ⚠️ SCOPE: this is a per-SCHEMA retirement, not a per-file one. Both modules +// SURVIVE and keep live exports — `notification.zod` its three presentation +// enums, `sharing.zod` the live `SharingConfigSchema` door that +// `FormViewSchema.sharing` carries and `rest-server.ts` gates the anonymous form +// routes on. The survival assertions below are as load-bearing as the absence +// ones: a retirement that took `SharingConfigSchema` with it would break public +// form sharing, and "the names are gone" alone cannot tell the two apart. +// +// Why THIS pin and not a type-level one: #4642 established that a compile-time +// conditional-type assertion in this package is a no-op (the package tsconfig +// excludes `**/*.test.ts`, and vitest never enables `typecheck`), so an +// `Assert< Equal< … > >` here would be decoration. The load-bearing pin is the +// TypeScript compiler-API program below, which resolves the REAL export surface +// of every public entry from `package.json`'s exports map and asserts each +// retired name has zero holders — by symbol identity, not by grepping text. +// +// Every `not`-shaped assertion carries an anti-vacuity guard, because the +// failure mode of an absence pin is passing for the wrong reason (a path typo, +// an entry that stops resolving, an empty enumeration). +describe('[#5015] NotificationAction / EmbedConfig removal — no entry exports either name', () => { + /** The two schema names + the type aliases they published. */ + const RETIRED = [ + 'NotificationActionSchema', + 'NotificationAction', + 'EmbedConfigSchema', + 'EmbedConfig', + ] as const; + + /** + * Live neighbours that must SURVIVE — three from `notification.zod` (the + * presentation vocabulary objectui's toaster reads) and the live sharing door + * beside the shape that was removed. These do double duty: they prove the + * probe finds names when names are there, and they pin the scope of the + * retirement to the two shapes it was ruled over. + */ + const SURVIVES = [ + 'NotificationTypeSchema', + 'NotificationSeveritySchema', + 'NotificationPositionSchema', + 'SharingConfigSchema', + ] as const; + + it('resolves the export surface: the retired names have ZERO holders across every public entry', async () => { + const ts = (await import('typescript')).default; + const { resolve, dirname } = await import('node:path'); + const { fileURLToPath } = await import('node:url'); + const { readFileSync } = await import('node:fs'); + + const specDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); + // Every public entry point, read from package.json's exports map so a future + // entry cannot silently escape the absence assertions below. + const pkg = JSON.parse(readFileSync(resolve(specDir, 'package.json'), 'utf8')) as { + exports: Record; + }; + const entries: Record = {}; + for (const sub of Object.keys(pkg.exports)) { + if (sub === '.') entries[sub] = resolve(specDir, 'src/index.ts'); + else if (/^\.\/[a-z-]+$/.test(sub)) entries[sub] = resolve(specDir, `src/${sub.slice(2)}/index.ts`); + // './openapi.json' / './package.json' are not TypeScript entry points. + } + // Anti-vacuity (1): the enumeration found the real surface — including the + // entry that owned both retired names, and the root barrel re-exporting it. + for (const needed of ['.', './ui']) { + expect(Object.keys(entries), `exports map must include ${needed}`).toContain(needed); + } + expect(Object.keys(entries).length).toBeGreaterThan(10); + + const program = ts.createProgram(Object.values(entries), { + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + skipLibCheck: true, + noEmit: true, + }); + const checker = program.getTypeChecker(); + + const exportsOf = (sub: string) => { + const sf = program.getSourceFile(entries[sub]); + const moduleSym = sf && checker.getSymbolAtLocation(sf); + // Anti-vacuity (2): without this guard a resolution failure would make + // every absence assertion below pass for free — exactly how a gate goes + // dormant (#4642). + expect(moduleSym, `${sub} module symbol must resolve`).toBeTruthy(); + return checker.getExportsOfModule(moduleSym!); + }; + + /** Every entry that exports `name` — for a removal this must be []. */ + const holdersOf = (name: string): string[] => { + const out: string[] = []; + for (const sub of Object.keys(entries)) { + if (exportsOf(sub).some((e) => e.getName() === name)) out.push(sub); + } + return out; + }; + + // Anti-vacuity (3): `holdersOf` really finds holders when a name IS + // exported — proven on the surviving neighbours in the very same modules, so + // `[]` below means "absent", not "the probe is broken". This is the guard + // that distinguishes a correct retirement from a deleted `ui` barrel. + for (const name of SURVIVES) { + expect(holdersOf(name), `${name} must SURVIVE this retirement`).toContain('./ui'); + } + const uiNames = exportsOf('./ui').map((e) => e.getName()); + expect(uiNames.length, './ui must still export a non-trivial surface').toBeGreaterThan(40); + + // The removal itself: NO public entry exports either name — not the old + // owner, and not some other entry that might "helpfully" adopt them (a + // re-export can lie about the domain even when the symbol is honest). + // Exact equality with `[]`, so a partial move cannot slip through. + for (const name of RETIRED) { + expect(holdersOf(name), `${name} must have zero holders`).toEqual([]); + } + }); + + it('keeps the runtime namespaces consistent with the compiler view', async () => { + const ui = await import('./index'); + const root = await import('../index'); + + // Absence, at both entries. `./ui` is the load-bearing one — it is where + // both shapes actually lived. The root barrel is checked too, so that no + // future edit "helpfully" adopts a retired name there. + for (const [label, ns] of [['./ui', ui], ['.', root]] as const) { + for (const name of RETIRED) { + expect(name in ns, `${label} must not export ${name}`).toBe(false); + } + } + + // Survival is asserted ONLY on `./ui`, and the asymmetry is deliberate: + // the root barrel is a CURATED surface that re-exports just the `defineX` + // factories from `./ui`, never the schema vocabulary. So the four survivors + // were never on `.` and neither were the two retired names — which means + // the root's absence check above is weak evidence on its own, and asserting + // survival there would be asserting something that was never true. (Written + // this way after the first draft asserted survival on both entries and this + // suite went red on `. must still export NotificationTypeSchema` — the + // anti-vacuity guard catching its own author.) + for (const name of SURVIVES) { + expect(name in ui, `./ui must still export ${name}`).toBe(true); + } + expect(Object.keys(ui).length).toBeGreaterThan(40); + }); + + it('the modules SURVIVE — this is a per-schema retirement, not a per-file one', async () => { + // The #4834 precedent removed a whole module and pinned that the path no + // longer imports. Here the opposite is the correct assertion: both modules + // must still load, because each still owns live exports. Deleting either + // file would satisfy every absence assertion above while destroying working + // surface — `SharingConfigSchema` most of all. + const notification = await import('./notification.zod'); + const sharing = await import('./sharing.zod'); + + expect(Object.keys(notification)).toEqual( + expect.arrayContaining([ + 'NotificationTypeSchema', + 'NotificationSeveritySchema', + 'NotificationPositionSchema', + ]), + ); + expect(Object.keys(notification)).not.toContain('NotificationActionSchema'); + expect(Object.keys(sharing)).toContain('SharingConfigSchema'); + expect(Object.keys(sharing)).not.toContain('EmbedConfigSchema'); + + // And the surviving door still WORKS — behaviour, not just presence. Its + // 批 14 strictness is asserted in `strictness-batch14.test.ts`; this is the + // narrower claim that the retirement did not hollow it out. + const parsed = sharing.SharingConfigSchema.parse({ enabled: true, allowAnonymous: true }); + expect(parsed.allowAnonymous).toBe(true); + }); +}); diff --git a/packages/spec/src/ui/notification.test.ts b/packages/spec/src/ui/notification.test.ts index b45a2b3e7d..aa492cea0c 100644 --- a/packages/spec/src/ui/notification.test.ts +++ b/packages/spec/src/ui/notification.test.ts @@ -3,11 +3,9 @@ import { NotificationTypeSchema, NotificationSeveritySchema, NotificationPositionSchema, - NotificationActionSchema, type NotificationType, type NotificationSeverity, type NotificationPosition, - type NotificationAction, } from './notification.zod'; describe('NotificationTypeSchema', () => { @@ -52,39 +50,19 @@ describe('NotificationPositionSchema', () => { }); }); -describe('NotificationActionSchema', () => { - it('should accept a valid action', () => { - const action: NotificationAction = { label: 'Undo', action: 'undo', variant: 'primary' }; - const result = NotificationActionSchema.parse(action); - expect(result.label).toBe('Undo'); - expect(result.action).toBe('undo'); - expect(result.variant).toBe('primary'); - }); - - it('should default variant to primary', () => { - const result = NotificationActionSchema.parse({ label: 'Retry', action: 'retry' }); - expect(result.variant).toBe('primary'); - }); - - it('should reject missing label', () => { - expect(() => NotificationActionSchema.parse({ action: 'undo' })).toThrow(); - }); - - it('should reject missing action', () => { - expect(() => NotificationActionSchema.parse({ label: 'Undo' })).toThrow(); - }); -}); +// `NotificationActionSchema` had a describe block here until #5015 removed the +// schema (ADR-0049 enforce-or-remove — no carrier key, unreachable from every +// metadata root, zero parse outside this file). Its absence is pinned by symbol +// identity in `notification-embed-retirement.test.ts`, not by the silence here. describe('Type exports', () => { it('should have valid type exports', () => { const type: NotificationType = 'toast'; const severity: NotificationSeverity = 'info'; const position: NotificationPosition = 'top_right'; - const action: NotificationAction = { label: 'OK', action: 'confirm', variant: 'primary' }; expect(type).toBeDefined(); expect(severity).toBeDefined(); expect(position).toBeDefined(); - expect(action).toBeDefined(); }); // Pin: this module no longer declares the bare Notification(Config) names. diff --git a/packages/spec/src/ui/notification.zod.ts b/packages/spec/src/ui/notification.zod.ts index f5241374b8..22b7f5dec3 100644 --- a/packages/spec/src/ui/notification.zod.ts +++ b/packages/spec/src/ui/notification.zod.ts @@ -1,7 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { z } from 'zod'; -import { I18nLabelSchema } from './i18n.zod'; /** * Notification Type Schema @@ -46,53 +45,50 @@ export const NotificationPositionSchema = lazySchema(() => z.enum([ export type NotificationPosition = z.infer; -/** - * Notification Action Schema - * Defines an interactive action button within a notification. - * - * ⛔ **DELIBERATELY NOT `strictObject` — this shape has NO AUTHORING DOOR** - * (#4001 批 14, ADR-0078 completeness gate). It is a **vocabulary**, consumed by - * reading its `.shape`, never by parsing an authored payload. Three independent - * measurements on 2026-08-03, each with a positive control that passed in the - * same run: - * - * 1. **Carrier key** — nothing in `packages/spec/src` imports this schema except - * the `ui/index.ts` barrel. No metadata type declares a notification-actions - * key; the `./ui` "notification instance" and "notification system config" - * wrappers that once could have carried it were deleted in #4610 for having - * zero consumers (see the note at the bottom of this file). - * 2. **Graph reachability** — BFS from the 24 metadata-type roots plus - * `defineStack`'s `ObjectStackSchema` (the closure `build-schemas.ts` uses for - * the #4650 deletion check) visits 6860 nodes and never reaches it. Controls - * `PageSchema` / `ActionSchema` / `DashboardWidgetSchema` / `WebhookSchema` - * were all `root-graph` in the same run, and injecting a synthetic carrier - * flipped this schema to `root-graph` — so "unreachable" is a fact about the - * graph, not a broken instrument. - * 3. **Call sites** — no `.parse()` in framework or objectui outside this - * module's own `notification.test.ts`. objectui's use is the opposite of a - * parse: `animation-notification-spec-parity.test.tsx` reads - * `NotificationActionSchema.shape.variant` to pin its own hand-written - * `NotificationActionButton` interface against this enum, both ways. That pin - * depends on the SHAPE and is unaffected by the posture — which is precisely - * why closing the shape would buy nothing. - * - * `.strict()` is a property of a PARSE, and nothing parses this. Closing it - * would spend a v17 breaking change to make the file look finished and leave a - * precisely-validated dead slot — *"the more convincing lie"* (#4583). The - * verdict this shape actually needs is ADR-0049 enforce-or-remove; filed as #5015 - * and recorded in the strictness ledger's `no door` class. - * - * The pin in `notification.test.ts` goes RED the moment anyone gives this shape - * a carrier key — at which point it becomes authorable and this comment is wrong. - */ -export const NotificationActionSchema = lazySchema(() => z.object({ - label: I18nLabelSchema.describe('Action button label'), - action: z.string().describe('Action identifier to execute'), - variant: z.enum(['primary', 'secondary', 'link']).default('primary') - .describe('Button variant style'), -}).describe('Notification action button')); - -export type NotificationAction = z.infer; +// [#5015] `NotificationActionSchema` / `NotificationAction` were REMOVED per +// ADR-0049 enforce-or-remove, ruled REMOVE on 2026-08-04. +// +// The shape declared an interactive action button inside a notification — +// `{ label, action, variant }` — and had **no authoring door anywhere**. #4001 +// 批 14 measured it three ways on 2026-08-03, each with a positive control that +// passed in the same run, and this retirement re-ran all three against +// `origin/main` before removing anything: +// +// 1. **Carrier key** — no schema in `packages/spec/src` declared a +// notification-actions key; the barrel was this module's only non-test +// importer. The `./ui` "notification instance" and "notification system +// config" wrappers that could have carried it were themselves deleted in +// #4610 for having zero consumers (see the note below), which is what left +// this shape an orphan. +// 2. **Graph reachability** — BFS from the 24 metadata-type roots plus +// `defineStack`'s `ObjectStackSchema` (the closure `build-schemas.ts` uses +// for the #4650 deletion check, including its derived-clone bridge) never +// reached it, while `Page` / `Action` / `DashboardWidget` / `Webhook` / +// `SharingConfig` all resolved `root-graph` in the same run and injecting a +// synthetic carrier flipped it to `root-graph`. +// 3. **Call sites** — zero `.parse()` in objectstack, cloud or objectui +// outside this module's own unit test. +// +// So nobody could author one and nothing ever validated one: it was a published +// vocabulary with no recipient, which #3950 records as the shape an AI author +// reads as a capability. `.strict()` was deliberately NOT applied instead — +// strictness is a property of a PARSE, and closing a shape nothing parses only +// buys *"a precisely-validated dead slot, the more convincing lie"* (#4583). +// +// What stays: the three presentation enums above (`NotificationType` / +// `NotificationSeverity` / `NotificationPosition`). objectui consumes them as a +// vocabulary for its own toaster, and they are unaffected. +// +// objectui's `animation-notification-spec-parity.test.tsx` read +// `NotificationActionSchema.shape.variant` to pin its hand-written +// `NotificationActionButton` interface. That is a vocabulary read, not a parse, +// and it is why "has a consumer" never meant "has an authoring door" here — the +// pin loses its spec-side anchor when objectui refreshes this dependency, and +// adapting it is tracked on the objectui side. +// +// If notification actions are ever wanted as METADATA, they return via the +// enforce route of ADR-0049 — a carrier key on a real metadata type, with a +// renderer that reads it, in one change. Vocabulary second, never first. // [#4610] `NotificationSchema` / `Notification` and `NotificationConfigSchema` // / `NotificationConfig` were removed from this module (dual-source cleanup, diff --git a/packages/spec/src/ui/sharing.test.ts b/packages/spec/src/ui/sharing.test.ts index cb77d1f05c..24c74b7769 100644 --- a/packages/spec/src/ui/sharing.test.ts +++ b/packages/spec/src/ui/sharing.test.ts @@ -1,9 +1,7 @@ import { describe, it, expect } from 'vitest'; import { SharingConfigSchema, - EmbedConfigSchema, type SharingConfig, - type EmbedConfig, } from './sharing.zod'; // --------------------------------------------------------------------------- @@ -61,54 +59,11 @@ describe('SharingConfigSchema', () => { }); }); -// --------------------------------------------------------------------------- -// EmbedConfigSchema -// --------------------------------------------------------------------------- -describe('EmbedConfigSchema', () => { - it('should accept empty config with defaults', () => { - const config: EmbedConfig = EmbedConfigSchema.parse({}); - expect(config.enabled).toBe(false); - expect(config.width).toBe('100%'); - expect(config.height).toBe('600px'); - expect(config.showHeader).toBe(true); - expect(config.showNavigation).toBe(false); - expect(config.responsive).toBe(true); - expect(config.allowedOrigins).toBeUndefined(); - }); - - it('should accept full embed config', () => { - const config = EmbedConfigSchema.parse({ - enabled: true, - allowedOrigins: ['https://example.com', 'https://partner.com'], - width: '800px', - height: '500px', - showHeader: false, - showNavigation: true, - responsive: false, - }); - - expect(config.enabled).toBe(true); - expect(config.allowedOrigins).toEqual(['https://example.com', 'https://partner.com']); - expect(config.width).toBe('800px'); - expect(config.height).toBe('500px'); - expect(config.showHeader).toBe(false); - expect(config.showNavigation).toBe(true); - expect(config.responsive).toBe(false); - }); - - it('should accept config with only origin restrictions', () => { - const config = EmbedConfigSchema.parse({ - enabled: true, - allowedOrigins: ['https://mysite.com'], - }); - expect(config.allowedOrigins).toEqual(['https://mysite.com']); - }); - - it('should accept empty allowedOrigins array', () => { - const config = EmbedConfigSchema.parse({ - enabled: true, - allowedOrigins: [], - }); - expect(config.allowedOrigins).toEqual([]); - }); -}); +// `EmbedConfigSchema` had a describe block here until #5015 removed the schema +// (ADR-0049 enforce-or-remove — no carrier key anywhere, unreachable from every +// metadata root, zero parse outside this file; its would-be carrier `App.embed` +// was itself retired in 17.0.0). Its absence is pinned by symbol identity in +// `notification-embed-retirement.test.ts`, not by the silence here. +// +// `SharingConfigSchema` above is deliberately untouched — it is a LIVE door +// (`FormViewSchema.sharing`, read by `rest-server.ts`). One file, two verdicts. diff --git a/packages/spec/src/ui/sharing.zod.ts b/packages/spec/src/ui/sharing.zod.ts index dac360ecd6..876667d5b0 100644 --- a/packages/spec/src/ui/sharing.zod.ts +++ b/packages/spec/src/ui/sharing.zod.ts @@ -5,9 +5,12 @@ * * Sharing & Embedding Protocol * - * Public-link sharing and iframe-embed configuration. The module name is - * plural, but the two shapes below are in **opposite** postures, and #4001 批 14 - * measured why rather than assuming a file-level verdict: + * Public-link sharing of a form view. The module name is plural for historical + * reasons: it once held two shapes in **opposite** postures, and #4001 批 14 + * measured them per SCHEMA rather than assuming a file-level verdict — + * `SharingConfigSchema` a live authoring door, `EmbedConfigSchema` no door at + * all. That measurement is what let the two be disposed of separately, and the + * asymmetry survives as the reason this file reads the way it does: * * - `SharingConfigSchema` has a **live authoring door**. `FormViewSchema.sharing` * carries it (`view.zod.ts`), `view` is a metadata-type root, and the runtime @@ -15,11 +18,13 @@ * when `sharing.allowAnonymous === true` and a `sharing.publicLink` slug * matches. Both example apps author it (`app-showcase` `inquiry.view.ts`, * `app-crm` `lead.view.ts`). It is `strictObject` as of #4001 批 14. - * - `EmbedConfigSchema` has **no door at all** — see its own block below. + * - `EmbedConfigSchema` was **REMOVED** at #5015 (ADR-0049 enforce-or-remove) — + * see the block below where it stood. * - * That split is the point. The ledger's classification question is *"who writes - * this schema's input?"*, and it is answered per SCHEMA, not per file; before - * 批 14 this file's row carried one verdict for both. + * The ledger's classification question is *"who writes this schema's input?"*, + * and it is answered per SCHEMA, not per file; before 批 14 this file's row + * carried one verdict for both, and a file-level verdict would have been wrong + * in one direction or the other whichever way it fell. */ import { z } from 'zod'; @@ -97,50 +102,41 @@ export const SharingConfigSchema = lazySchema(() => strictObject({ .describe('Allow access without authentication'), })); -/** - * Embed Config Schema - * Configuration for iframe embedding of an app, page, or form. - * Supports origin restrictions, display options, and responsive sizing. - * - * ⛔ **DELIBERATELY NOT `strictObject` — this shape has NO AUTHORING DOOR** - * (#4001 批 14, ADR-0078 completeness gate). Three independent measurements on - * 2026-08-03, each with a positive control that passed in the same run: - * - * 1. **Carrier key** — nothing in `packages/spec/src` imports this schema except - * the `ui/index.ts` barrel. No metadata type declares an `embed` key; the one - * sibling in this file (`SharingConfigSchema`) is carried by - * `FormViewSchema.sharing`, and this one is carried by nothing. - * 2. **Graph reachability** — BFS from the 24 metadata-type roots plus - * `defineStack`'s `ObjectStackSchema` (the closure `build-schemas.ts` uses for - * the #4650 deletion check) visits 6860 nodes and never reaches it. Controls - * `PageSchema` / `ActionSchema` / `DashboardWidgetSchema` / `WebhookSchema` - * were all `root-graph` in the same run, and injecting a synthetic carrier - * flipped this schema to `root-graph` — so "unreachable" is a fact about the - * graph, not a broken instrument. - * 3. **Call sites** — no `.parse()` anywhere in framework or objectui outside - * this module's own `sharing.test.ts` and objectui's export-surface pin, - * which parses a literal it wrote itself. - * - * `.strict()` is a property of a PARSE, and nothing parses this. Closing it - * would spend a v17 breaking change to make the file look finished and leave a - * precisely-validated dead slot — *"the more convincing lie"* (#4583). The - * verdict this shape actually needs is ADR-0049 enforce-or-remove; filed as #5015 - * and recorded in the strictness ledger's `no door` class. - * - * The pin in `sharing.test.ts` goes RED the moment anyone gives this shape a - * carrier key — at which point it becomes authorable and this comment is wrong. - */ -export const EmbedConfigSchema = lazySchema(() => z.object({ - enabled: z.boolean().default(false).describe('Enable iframe embedding'), - allowedOrigins: z.array(z.string()).optional() - .describe('Allowed iframe parent origins (e.g. ["https://example.com"])'), - width: z.string().optional().default('100%').describe('Embed width (CSS value)'), - height: z.string().optional().default('600px').describe('Embed height (CSS value)'), - showHeader: z.boolean().optional().default(true).describe('Show interface header in embed'), - showNavigation: z.boolean().optional().default(false).describe('Show navigation in embed'), - responsive: z.boolean().optional().default(true).describe('Enable responsive resizing'), -})); +// [#5015] `EmbedConfigSchema` / `EmbedConfig` were REMOVED per ADR-0049 +// enforce-or-remove, ruled REMOVE on 2026-08-04. +// +// The shape described iframe embedding of an app, page or form — `enabled`, +// `allowedOrigins`, `width` / `height`, `showHeader` / `showNavigation`, +// `responsive` — and **nothing in the repo so much as named it**. #4001 批 14 +// measured it three ways on 2026-08-03 with positive controls; this retirement +// re-ran all three against `origin/main` first: +// +// 1. **Carrier key** — this module's only importers were the `ui/index.ts` +// barrel and `ui/view.zod.ts`, and `view.zod.ts` names +// `SharingConfigSchema` specifically. No schema anywhere declared an +// `embed` key of this type. (Specifier RESOLUTION, not substring matching: +// the repo holds two `sharing.zod` modules, and a substring test miscredits +// `stack.zod.ts` / `security/index.ts` as importers of this one.) +// 2. **Graph reachability** — BFS from the 24 metadata-type roots plus +// `defineStack`'s `ObjectStackSchema` never reached it, while +// `SharingConfigSchema` — its own sibling in this file — resolved +// `root-graph` in the same run alongside `Page` / `Action` / +// `DashboardWidget` / `Webhook`, and a synthetic carrier flipped it. +// 3. **Call sites** — zero `.parse()` in objectstack, cloud or objectui +// outside this module's own unit test. +// +// Why it was an orphan is worth recording, because it explains the asymmetry +// this file kept: the key that would have carried it, `App.embed`, was itself +// retired in 17.0.0 (2026-06 liveness audit / ADR-0049 — no iframe route ever +// read it) and survives one file over as a `retiredKey()` tombstone in +// `app.zod.ts`. The value shape simply outlived its key. An exported schema with +// no consumer is read as a capability (#3950), so it goes with it. +// +// ⚠️ `SharingConfigSchema` above is UNTOUCHED and is a LIVE door — see the module +// header. One file, two verdicts; that split is the point, and it is per SCHEMA. +// +// Embedding, if it is ever built, returns via the enforce route of ADR-0049: +// a route that honours the origins first, the vocabulary second. // Type Exports export type SharingConfig = z.infer; -export type EmbedConfig = z.infer; diff --git a/packages/spec/src/ui/strictness-batch14.test.ts b/packages/spec/src/ui/strictness-batch14.test.ts index 7da3d8c00f..bb966c9264 100644 --- a/packages/spec/src/ui/strictness-batch14.test.ts +++ b/packages/spec/src/ui/strictness-batch14.test.ts @@ -12,9 +12,13 @@ * * 1. **Closure** — each newly-strict shape rejects an undeclared key and the * rejection is USEFUL (names the surface, echoes the key, prescribes). - * 2. **No-door pins** — for `NotificationActionSchema` / `EmbedConfigSchema`, - * an assertion that goes RED the moment either gains a carrier key, which is - * the event that would make this batch's "do not tighten" verdict wrong. + * 2. **The split's surviving half** — the importer instrument that told the two + * apart, plus the live-carrier pin for `SharingConfigSchema`. This used to be + * a pair of no-door pins over `NotificationActionSchema` / `EmbedConfigSchema` + * that would go RED if either gained a carrier key; #5015 answered that + * verdict as ADR-0049 REMOVE and both shapes are gone, so the absence pins + * live in `notification-embed-retirement.test.ts` where they can actually + * fail. See the block's own header for why they are not restated here. * 3. **Prescription integrity** — every alias target this batch added is a key * the schema really accepts (ledger finding 12: *never suggest a key the * schema cannot accept*), checked by parsing the prescribed key, not by @@ -30,8 +34,7 @@ import ts from 'typescript'; import { z } from 'zod'; import { ActionParamSchema, ActionSchema as ActionSchemaForAudit } from './action.zod'; -import { NotificationActionSchema } from './notification.zod'; -import { SharingConfigSchema, EmbedConfigSchema } from './sharing.zod'; +import { SharingConfigSchema } from './sharing.zod'; import { ReportSortSchema, JoinedReportBlockSchema, ReportSchema as ReportSchemaForAudit } from './report.zod'; import { DatasetDimensionSchema, @@ -485,55 +488,76 @@ function importersOf(targetRel: string): string[] { return out.sort(); } -describe('批 14 — the two no-door shapes stay unclosed, and say so', () => { +describe('批 14 — the file that split, after #5015 retired its dead half', () => { + // ── What this block is now, and why it is not just deleted ──────────────── + // + // 批 14 measured eleven strip sites and reclassified two — `NotificationAction` + // and `EmbedConfig` — as `no door`, pinning them here so the verdict would go + // RED if either ever gained a carrier key. #5015 answered that verdict the + // other way: ADR-0049 enforce-or-remove, ruled REMOVE, and both shapes are + // gone. + // + // Their ABSENCE pins moved to `notification-embed-retirement.test.ts`, which + // asserts it by resolved symbol identity across every public entry. They are + // deliberately NOT restated here as `expect(names).toEqual([])`: with the + // schemas deleted, such an assertion passes because nothing is produced rather + // than because the logic holds — a pin that cannot fail is worse than none, + // since it reads as coverage (the trap PR #5046 documented). + // + // What survives here is the half that is still about a LIVE shape: the + // instrument that told the two apart in the first place, and the carrier pin + // for `SharingConfigSchema`. That carrier is the whole reason this file was + // the ledger's first one-row-two-verdicts case, so losing it with the dead + // half would delete the evidence for the surviving verdict. + it('the importer pin sees all three import forms and does not confuse same-named modules', () => { // Self-test first: an assertion about "who imports X" is worthless if the // matcher only knows one spelling. `ui/i18n.zod` is imported with // `import … from` by several ui modules AND re-exported by the barrel with // `export * from`. const i18n = importersOf('ui/i18n.zod'); - expect(i18n).toContain('ui/index.ts'); // export * from - expect(i18n).toContain('ui/notification.zod.ts'); // import … from + expect(i18n).toContain('ui/index.ts'); // export * from + expect(i18n).toContain('ui/view.zod.ts'); // import … from expect(i18n.length).toBeGreaterThan(2); // And the discriminating case: two `sharing.zod` modules exist. Resolution, - // not substring matching, is what keeps them apart. + // not substring matching, is what keeps them apart — a substring test + // miscredits `stack.zod.ts` and `security/index.ts` to the UI module. expect(importersOf('security/sharing.zod')).toContain('stack.zod.ts'); expect(importersOf('ui/sharing.zod')).not.toContain('stack.zod.ts'); }); - it('ui/notification.zod has exactly one importer — the barrel. Nothing can carry NotificationAction', () => { - expect(importersOf('ui/notification.zod')).toEqual(['ui/index.ts']); - }); - - it('ui/sharing.zod is imported by the barrel and view.zod — which carries SharingConfig, NOT EmbedConfig', () => { + it('SharingConfig keeps the live carrier that made this file split (#5015 took the other half)', () => { + // The carrier is specific, and that asymmetry IS the 批 14 reclassification: + // the form view names `SharingConfigSchema`. `EmbedConfigSchema` was named by + // nothing, which is what #5015 acted on. expect(importersOf('ui/sharing.zod')).toEqual(['ui/index.ts', 'ui/view.zod.ts']); - // The carrier is specific, and that asymmetry IS the reclassification: the - // form view names `SharingConfigSchema`, and no module anywhere names - // `EmbedConfigSchema`. Pin the symbol, not the file. - const namesEmbed = specModules() - .filter((f) => !f.endsWith('sharing.zod.ts')) - .filter((f) => /\bEmbedConfigSchema\b/.test(fs.readFileSync(f, 'utf8'))) - .map((f) => path.relative(SPEC_SRC, f).split(path.sep).join('/')); - expect(namesEmbed).toEqual([]); expect(fs.readFileSync(path.join(SPEC_SRC, 'ui/view.zod.ts'), 'utf8')) .toContain('SharingConfigSchema'); }); - it('both stay strip — a precisely-validated dead slot is the more convincing lie (#4583)', () => { - // Closing either would spend a v17 breaking change on a shape nothing - // parses. If someone closes one, this fails and points at the ledger row. - expect(NotificationActionSchema.safeParse({ label: 'Undo', action: 'undo', bogus: 1 }).success) + it('the surviving door is still CLOSED — the retirement did not relax 批 14', () => { + // The one assertion in this block that exercises behaviour rather than + // topology. `SharingConfigSchema` was tightened by 批 14 and must stay + // tightened: removing its dead sibling from the same file is exactly the + // kind of edit that could take the strictness with it. + const rejected = SharingConfigSchema.safeParse({ enabled: true, bogus: 1 }); + expect(rejected.success).toBe(false); + // Positive control in the same run: a declared key still parses, so the + // rejection above is about `bogus` and not about the shape being broken. + expect(SharingConfigSchema.safeParse({ enabled: true, allowAnonymous: true }).success) .toBe(true); - expect(EmbedConfigSchema.safeParse({ enabled: true, bogus: 1 }).success).toBe(true); }); - it('their prose records the verdict where the next reader will look', () => { + it('both modules record the retirement where the next reader will look', () => { const notification = fs.readFileSync(path.join(SPEC_SRC, 'ui/notification.zod.ts'), 'utf8'); const sharing = fs.readFileSync(path.join(SPEC_SRC, 'ui/sharing.zod.ts'), 'utf8'); for (const source of [notification, sharing]) { - expect(source).toContain('NO AUTHORING DOOR'); - expect(source).toContain('#4001 批 14'); + expect(source).toContain('#5015'); + expect(source).toContain('ADR-0049'); } + // The surviving module still explains its own live door, so a later reader + // does not mistake the whole file for retired surface. + expect(sharing).toContain('live authoring door'); }); });