Skip to content

Commit 72af58c

Browse files
feat(spec)!: page requires only on the compiled kinds — refused at parse on react, full and slotted pages (#21459) (#21547)
Fixes #21459 Clause-②: yes (narrowing) ## Summary This PR executes ruling A (record `5964312254`). `PageSchema` now accepts `requires` only when `kind` is `html` or `jsx`, the two kinds the metadata save door compiles. On a `react`, `full` or `slotted` page it is refused at parse, and so is a page that omits `kind`, which defaults to `full`. The refusal names the key, the page's kind and the compiled kinds. - **The refusal: `checkPageRequiresKind`** (`packages/spec/src/ui/page.zod.ts`). It is an exported object-level check chained onto `PageSchema` with `.superRefine(...)`, right after `checkPageSourceCompleteness`. That is the mechanism this file already uses for kind-conditional rules, so no second one is added. Its vocabulary is the new export `COMPILED_PAGE_KINDS = ['html', 'jsx']`. The issue is `code: 'custom'` at `path: ['requires']`. The message opens with "`requires` is refused on a `kind: 'react'` page" (or the page's kind), names `html` and its deprecated alias `jsx`, says "Delete the key.", and ends with the house `os migrate meta --from 17` sentence. It carries no tracker number. - **The describe** now says the key exists only on html / jsx pages and is refused at parse on the other kinds. Its opening sentence, "derived from the source at save — omit it", is unchanged byte for byte, because the reconciliation ledger row quotes it. - **The kit (ADR-0087):** - D2 conversion `page-requires-non-compiled-kind-removed` (step 18, `order: 58`, `retiredFromLoadPath: true`, `retiredAfter: '17.6.0'`). It strips the key from `react`, `full`, `slotted` and kind-less pages. - D3 semantic entry `page-requires-non-compiled-kind-refused`, which carries `conversionIds: [page-requires-non-compiled-kind-removed]`. - A hand-written `STEP18_RATIONALE` fragment (`order: 66`, placed after `ui-ai-chat-window-retired`, which landed first at 65). The registry regions were regenerated with `gen:migration-registry`. - No tombstone and no `RETIRED_KEYS_BY_MAJOR` row: the key stays live on html pages. - **Ledgers:** - `liveness/page.json`: the `requires` row stays `live`. Its evidence gains the PARSE reader, its note's per-kind clause shrinks to the compiled kinds, and `verifiedAt` is now 2026-10-03. The state counts do not move. - The reconciliation row in `metadata-form-zod-reconciliation.test.ts`: its per-kind clause now reads "on every other kind the parse refuses it". - **Generated:** `api-surface/ui.json` and `export-origins/ui.json` gain the two exports, and `content/docs/references/ui/page.mdx` re-renders the describe. All three were produced by `check:generated --fix`, never edited by hand. - **Changeset:** `@objectstack/spec` `minor` with the BREAKING banner, `Clause-②: yes (narrowing)`, a FROM → TO table, and the ADR-0087 marker `registered page-requires-non-compiled-kind-removed, page-requires-non-compiled-kind-refused`. No runtime code changes. The save door, the load report and objectui are untouched. ## Premise checks (at base `c98a72d69e`, re-read on the merged tree) - **A1 census: zero producers.** Every `requires:` hit in `examples/**`, `packages/apps/**` (none there), `content/docs/**` and `skills/**` is the stack-level capability list. In examples that is `app-crm`, `app-showcase` and `app-todo`'s `objectstack.config.ts`, plus prose comments naming capability tokens. The only page bodies that carry `requires` are the 11 `htmlPage(...)` sites in `packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`, all `kind: 'html'`, so they are still accepted. The proximity scan (each `requires:` hit within 15 lines of a page marker) found them, which is its positive control. No test pinned acceptance on a non-compiled kind. objectui at the `.objectui-sha` pin `89cad75d55` shows page `requires` only on stamped html fixtures, plus one compile-only type fixture (`twins-spec-by-reference-9736.test.ts:158`). The ruling's cloud and hotcrm census stands. - **A2, the default kind.** An omitted `kind` parses as `full`, and the refinement runs on the parsed value. So `{ requires: [...] }` with no `kind` is refused as a `full` page, and the message adds "(`full` is also the kind of a page that omits `kind`)". A `.shape` mirror without the default reaches the check with `kind` absent and gets the same issue, which the exports-parity fixture pins. - **A2, the empty array.** On a non-compiled kind the save door never looks at `[]`: `compileHtmlPage` returns `undefined`, so `findHtmlPageSourceGaps` answers `null` and `stampHtmlPageRequires` returns the body as written. The load report answers `[]` for it, which reports nothing. So `[]` had no effect anywhere. The ruling's words refuse the key ("accepts `requires` only when `kind` is `html` or `jsx`"), not its contents, so `[]` is refused too, and the conversion strips it too. Both are pinned. ## A4: the stored-row disposition is a mechanical drop, so it has a D2 conversion The drop is lossless. On those kinds nothing derived the list, no renderer read it, and the Studio page editor already drops it on every save. Its one reader was the load report's warning. With the conversion: - a stored row at rest replays it at every rehydration seam, loading with one conversion notice instead of a `metadata_spec_invalid` warning and a `_diagnostics` badge at every boot; - an artifact built by 17.6.0 or earlier replays it too; - an authored source is still refused at parse, because the conversion is retired from the load path, and `os migrate meta --from 17` lists the edit. This is pinned at the real seam: `loadMetaFromDb` over a seeded stored react page carrying `requires` gives `loaded: 1, errors: 0, invalid: 0`, one notice naming the conversion, no `[page_requires_plugin_absent]` line (the manifest lacks the plugin, so an unconverted list would have been reported) and no `[metadata_spec_invalid]` line. ## A6: reader branches the parse boundary now makes unreachable for non-compiled kinds (none changed here) - `packages/metadata-protocol/src/runtime-authoring-gate.ts:616-627`, `findPageRequiresAbsentFromManifest`, which is "Kind-agnostic on purpose" (TSDoc at `:610`). A non-compiled page can no longer reach it: the save door refuses the key, and at load the stored-row conversion strips it before `reportPageRequiresAbsentAtLoad` (`protocol.ts:24418`, called on the converted body) reads it. The kind-agnostic reach now serves only html / jsx rows, and its TSDoc sentence is a follow-up candidate. - `packages/metadata-protocol/src/protocol.ts:24526`, the `reportPageRequiresAbsentAtLoad` TSDoc "How a stored page gets here". It is still true, but now only of html rows. - `runtime-authoring-gate.ts:679` and `:728` were already gated to html / jsx by `compileHtmlPage` (`:634`). No change. - objectui `builtinComponents.tsx` `pageSaveBody` (at the pin) deletes `requires` on every kind. On non-compiled kinds that is now redundant, and harmless. No test pinned the old acceptance, so no existing test changed meaning. ## A9: merge state PR #21531 landed as `48eb9c193f`. This branch merged `origin/main` twice through `scripts/pm/os-regen-merge.sh`: `dafb0f6d1e` after #21531, and `e38149267c` at `49161683fb`. - `ui/page.zod.ts` and `migrations/registry.ts` merged textually clean. Both rationale fragments are kept (`ui-ai-chat-window-retired` 65, this one 66), and the regenerated regions match `check:migration-registry`. - The three `os-regen` artifacts both sides touched were regenerated on the merged tree, and the delta against main is exactly the two exports and the describe row. - Sibling entries survive, with equal counts on main and head for `'ui-ai-chat-window-retired'` (3), `'ui/AIChatWindowProps'` (2) and `'ai:chat_window'` (17). - None of the 10 open PRs touches `ui/page.zod.ts`, `migrations/registry.ts`, `conversions/registry.ts`, `liveness/page.json` or the reconciliation test. - Since `49161683fb`, main gained `1ac7308d7a` and `41b13331cd`. Neither touches a file in this diff. ## Tests (all at head `e38149267c` unless noted) - `@objectstack/spec` test (`vitest run --project local`): 604 files, 17888 passed, 1 todo. Run at `d7cd797549`; the later merge moved nothing under `packages/spec`. - `@objectstack/spec` typecheck: exit 0. That covers `tsc --noEmit`, `check:scripts-typecheck` and `check:test-typecheck` (52 files / 246 errors held, unchanged). - `@objectstack/metadata-protocol` test: 205 files passed, 3 skipped (3157 tests passed). Typecheck exit 0, and `--listFiles` includes `protocol.runtime-authoring-gate.test.ts`. - New and changed pins: - `packages/spec/src/ui/page-requires-compiled-kinds.test.ts`: the refusal on `react`, `full` and `slotted`, checked for code, path and named subjects; the omitted-kind default; the empty array; html / jsx controls; no `requires` on every kind; the stack door envelope (`STACK_SCHEMA_INVALID`, 422, at `pages.1.requires`); and the conversion. The conversion pins cover the stored-row strip with a notice, html / jsx kept, strip-iff-refused over the whole kind vocabulary, unknown kind left alone, artifact replay, idempotence and retired-from-load-path. The file also pins the ledger wiring and that there is no tombstone, with `ui/Page:assignedProfiles` as the control. - `object-refinement-check-exports.test.ts`: the new export is catalogued with 8 fixtures, and the parity, bijection, attachment-by-identifier and barrel-identity legs hold. - `protocol.runtime-authoring-gate.test.ts`: the save door refuses `requires` on a `react`, `full`, `slotted` or kind-less page with `{ code: 'INVALID_METADATA', status: 422 }`, one `custom` issue at `requires`, no `jsx-*` compile finding, and nothing persisted. The html control still saves and stamps. The load pin is described above. - **Ablation** through `scripts/ablation-replace.mjs`, run under the verify lock. The mutation replaces the kind condition `if ((COMPILED_PAGE_KINDS as readonly string[]).includes(kind)) return;` with a bare `return;`, so the key is accepted on every kind again. The source-resolved spec suites need no rebuild. - Mutation landed: anchor 1 → 0, blob `6b13a7df8e44` → `1fd2575e28e3`, marker on disk 1. - Red leg: 13 failed, 174 passed. The three non-compiled-kind refusal pins are red, along with the omitted-kind, empty-array, stack-door and strip-iff-refused pins and the exports parity and bijection legs. The html / jsx controls stayed green. - Restore: blob equals HEAD (`6b13a7df8e44`) and `git diff HEAD` is empty. Green leg: 187 passed. ## Gates `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 116 commands at `e38149267c`. All 116 were run and their exit codes recorded. `--ran` reports: "116 derived famil(ies) accounted for — 115 run, 1 NOT-MEASURED". The NOT-MEASURED one is `pnpm check:dual-build-cjs-loads` (exit 3, PREREQUISITE NOT MET: it needs every package's `dist/`, a repo-wide build), and it is declared to CI. `check:skill-examples` and `check:lean-entry-closure` first exited 3 for missing `client-react` / `objectql` builds. After building those closures they re-ran to exit 0. Highlights, each exit 0: - `check:generated` reports "all up to date" for 15 artifacts; - `check:liveness`, `check:migration-registry`, `check:spec-changes`, `check:upgrade-guide`, `check:api-surface`, `check:export-origins`, `check:docs`; - `check:adr-0087-registration`, `check-changeset-no-major` (also run with this body as the `--event` payload), `check-empty-changeset`; - `check:doc-authoring`, `check:nul-bytes`, `check:cross-package-test-inputs`, `check:engine-double-contract`, `check:type-check-debt`. A narrowed eslint run (`--no-inline-config --format json`) covered the 8 changed `.ts` files and found 0 errors and 0 warnings. This repo's eslint config enables no type-aware linting, so the diff cannot move a verdict on an untouched file. ## Acceptance notes - **objectui census tripwire.** objectui's `spec-object-refinements-7715.test.ts` census (at the pin) counts `PageSchema`'s object-level checks and lists `attached: ['checkPageSourceCompleteness']` for `PageNodeSchema`. It is built to go red when the spec adds a check. At objectui's next `@objectstack/spec` bump, that row will ask for `checkPageRequiresKind` to be attached, or declared not attachable. Runtime behaviour needs no objectui change, as the ruling says, but that census will need one row. The Console Pin Gate only builds objectui, and nothing objectui compiles against changed type, so it is unaffected. - `examples/app-showcase` and every other example author no page-level `requires`, so no example changed. --- _Generated by [Claude Code](https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fd5a1cd commit 72af58c

13 files changed

Lines changed: 641 additions & 24 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
A page's `requires` is accepted only on the kinds whose source is compiled at save: `html` and its deprecated alias `jsx`. On a `react`, `full` or `slotted` page, and on a page that omits `kind` (which is `full`), it is refused at parse.
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: registered page-requires-non-compiled-kind-removed, page-requires-non-compiled-kind-refused -->
10+
11+
**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings.
12+
13+
**Why.** `requires` is the list of plugin namespaces a page's source uses (ADR-0080 §5). It is derived from the source at save, and its describe has always said "omit it". On an html page, on a server that has the deployment's SDUI component manifest, the metadata save door compiles the source, stores the namespaces it uses as `requires`, and refuses a written list that disagrees. A `react` source is executed at render and never compiled at save, and `full` and `slotted` pages have no source. So on those three kinds nothing derived the key, the Studio page editor dropped it on every save, and its one reader was a load-time warning. `PageSchema` still accepted it there and never told the author it did nothing. The maintainer ruled that the key is accepted only on the compiled kinds.
14+
15+
**What is refused.** `requires` on a page whose `kind` is `react`, `full` or `slotted`, or a page with no `kind`, at the `requires` path. An empty list is refused too, because the key is what is refused, not its contents. The issue's `code` is `custom`, and its message names the key, the page's kind and the compiled kinds. That covers `definePage()`, `PageSchema`, `defineStack` (`STACK_SCHEMA_INVALID`, 422, at `pages.N.requires`), `os validate`, which runs the same stack parse, and the metadata save door (`422 INVALID_METADATA`).
16+
17+
**What stays accepted.** `requires` on an `html` or `jsx` page, byte for byte. The save door still derives it, stores it, and refuses a written list that disagrees. Every page that omits `requires` parses as before, on every kind.
18+
19+
## FROM → TO
20+
21+
| you wrote | write instead |
22+
|:--|:--|
23+
| `requires: [...]` on a `kind: 'react'` page | nothing: delete the key. Nothing derived or enforced it |
24+
| `requires: [...]` on a `kind: 'full'` or `kind: 'slotted'` page, or on a page with no `kind` | nothing: delete the key |
25+
| `requires: [...]` on a `kind: 'html'` or `kind: 'jsx'` page | unchanged. The platform derives it from the source at save, so omitting it is still the intended authoring |
26+
27+
**The one-line fix: delete `requires` from every page whose `kind` is not `html` or `jsx`.** `os migrate meta --from 17` lists the mechanical edits for existing sources. Stored pages and built artifacts are converted when they are read.
28+
29+
**Who is affected, measured.** No page body authors `requires` on a `react`, `full` or `slotted` page in this repository at `c98a72d69e` (`examples/**`, `packages/apps/**`, `content/docs/**`, `skills/**`, tests and fixtures). Every `requires:` there is the stack-level capability list or an html page in a save-door test. The same holds in cloud (`c5a4c9e6cb`), hotcrm (`5ae524916d`) and objectui (`8366accd13`), per the ruling's census. Deployed metadata was not measured.
30+
31+
### The retirement kit
32+
33+
- **The refusal.** `checkPageRequiresKind`, an exported object-level check attached to `PageSchema` beside `checkPageSourceCompleteness` (`@objectstack/spec/ui`), with `COMPILED_PAGE_KINDS` (`['html', 'jsx']`) as its vocabulary. A downstream mirror that derives its schema from `PageSchema.shape` re-attaches it with `.superRefine(checkPageRequiresKind)`. There is no tombstone and no `RETIRED_KEYS_BY_MAJOR` row, because the key stays live on html pages.
34+
- **The conversion.** `page-requires-non-compiled-kind-removed` (protocol 18) deletes the key from `react`, `full`, `slotted` and kind-less pages. It is a lossless delete: on those kinds the list never had an effect. It is retired from the load path, so authored sources are refused at parse, while stored rows, built artifacts and `os migrate meta` replay it. Its D3 record is the semantic entry `page-requires-non-compiled-kind-refused`.
35+
- **The ledgers.** The `requires` describe, its liveness row (`liveness/page.json`) and its form-reconciliation row now say the key exists only on html and jsx pages.

‎content/docs/references/ui/page.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ View filter rule
184184
| **kind** | `Enum<'full' \| 'slotted' \| 'html' \| 'react' \| 'jsx'>` | optional (default: `"full"`) | Page override mode. full \| slotted = structured authoring; html = author-written constrained JSX compiled (parsed, never executed) to the tree (ADR-0080; the legacy value 'jsx' is a deprecated alias), styled by the registered components' structured props plus a JSON `style` object with hsl(var(--token)) theme colors; react = real-React source executed at render by the runtime (ADR-0081), styled by inline `style` with the same token colors; it runs author JS, so it is gated by a host capability that defaults ON and is disabled server-side via the OS_PAGE_REACT=off env toggle. Do not author Tailwind classes in page source in either tier: `source` is runtime metadata the build-time Tailwind never scans, so utility classNames silently produce no CSS (ADR-0065; ADR-0080 amendment 2026-06-30). |
185185
| **slots** | `{ header?: object \| object[]; actions?: object \| object[]; alerts?: object \| object[]; highlights?: object \| object[]; … }` | optional | Slot override map for slotted pages |
186186
| **source** | `string` | optional | Page source text. For kind==='html' (alias 'jsx') it is constrained JSX compiled to the tree by @objectstack/sdui-parser at save time (parse, never execute), styled by the registered components' structured props plus a JSON `style` object with hsl(var(--token)) theme colors. For kind==='react' it is real React/JSX executed at render by @object-ui/react-runtime (trusted tier), styled by inline `style` with the same token colors. Do not author Tailwind classes in page source in either tier: `source` is runtime metadata the build-time Tailwind never scans, so utility classNames silently produce no CSS (ADR-0065; ADR-0080 amendment 2026-06-30). Authoritative over `regions` in both. |
187-
| **requires** | `string[]` | optional | Plugin namespaces the page's source uses, derived from the source at save — omit it. On a server that has the deployment's SDUI component manifest, saving a kind==='html' page (alias 'jsx') compiles its source and stores the namespaces it uses here; a written list that disagrees with the source is refused (422 INVALID_METADATA, page-requires-disagrees-with-source) — on a draft save it is kept until the draft's publish, which refuses it. At load, a stored page whose list names a plugin no component in that manifest carries is reported, page and plugin named, and is still served. A server with no manifest checks neither and says so once at boot. |
187+
| **requires** | `string[]` | optional | Plugin namespaces the page's source uses, derived from the source at save — omit it. The key exists only on a kind==='html' page (alias 'jsx'), the kinds whose source is compiled at save; on a 'react', 'full' or 'slotted' page — and a page that omits kind, which is 'full' — it is refused at parse. On a server that has the deployment's SDUI component manifest, saving an html page compiles its source and stores the namespaces it uses here; a written list that disagrees with the source is refused (422 INVALID_METADATA, page-requires-disagrees-with-source) — on a draft save it is kept until the draft's publish, which refuses it. At load, a stored page whose list names a plugin no component in that manifest carries is reported, page and plugin named, and is still served. A server with no manifest checks neither and says so once at boot. |
188188
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
189189
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
190190
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |

‎packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1224,6 +1224,35 @@ describe('html page source compiled at the save door against the SDUI manifest (
12241224
.resolves.toMatchObject({ success: true });
12251225
});
12261226

1227+
// [#21459] `requires` exists only on the kinds this door compiles. On any
1228+
// other kind the spec parse refuses it before the compile runs, so the
1229+
// refusal is the spec's own issue at `requires` — not a compile finding.
1230+
it.each(['react', 'full', 'slotted', undefined])(
1231+
'refuses `requires` on a `%s` page with a 422 at `requires`, and persists nothing (#21459)',
1232+
async (kind) => {
1233+
const { protocol, rows } = hostWith(new Map([['sdui-manifest', manifest()]]));
1234+
const page = {
1235+
name: 'landing', label: 'Landing', requires: ['ui'],
1236+
...(kind === undefined ? {} : { kind }),
1237+
...(kind === 'react' ? { source: KNOWN } : {}),
1238+
};
1239+
const err = await savePage(protocol, page).catch((e: any) => e);
1240+
expect(refusal(err)).toEqual({ code: 'INVALID_METADATA', status: 422 });
1241+
const issues = err.issues.filter((i: any) => i.path === 'requires');
1242+
expect(issues, JSON.stringify(err.issues)).toHaveLength(1);
1243+
expect(issues[0].code).toBe('custom');
1244+
expect(issues[0].message).toContain(`\`kind: '${kind ?? 'full'}'\``);
1245+
expect(err.issues.some((i: any) => String(i.rule ?? '').startsWith('jsx-'))).toBe(false);
1246+
expect(pageRows(rows)).toEqual([]);
1247+
},
1248+
);
1249+
1250+
it('CONTROL: the same `requires` on an html page still saves, and the compile stamps it (#21459)', async () => {
1251+
const { protocol, rows } = hostWith(new Map([['sdui-manifest', manifest()]]));
1252+
await expect(savePage(protocol, htmlPage(KNOWN, { requires: ['ui'] }))).resolves.toMatchObject({ success: true });
1253+
expect(storedPage(rows)?.requires).toEqual(['ui']);
1254+
});
1255+
12271256
it('a registered value that is not a manifest is warned about once and compiled against never', async () => {
12281257
const { protocol, rows } = hostWith(new Map([['sdui-manifest', { oops: true }]]));
12291258
await expect(savePage(protocol, htmlPage(UNKNOWN))).resolves.toMatchObject({ success: true });
@@ -1339,6 +1368,35 @@ describe('stored html page `requires` at load and at draft promotion (#20312)',
13391368
expect(loadReports(warn)).toEqual([]);
13401369
});
13411370

1371+
// [#21459] A row stored before `requires` was narrowed to the compiled
1372+
// kinds: a react page carrying the key, which the save door now refuses, so
1373+
// it is seeded straight into the store. The stored-row seam replays the D2
1374+
// conversion `page-requires-non-compiled-kind-removed` before anything reads
1375+
// the body — so the row loads with the key gone and a notice saying so, the
1376+
// spec check finds nothing to report, and the load report never sees a list.
1377+
it('at load, a stored react page carrying `requires` is read without it — converted, not reported or badged (#21459)', async () => {
1378+
const services = new Map<string, unknown>([['sdui-manifest', manifest(false)]]);
1379+
const { protocol, rows, registered } = hostWith(services);
1380+
const body = { name: 'workbench', label: 'Workbench', kind: 'react', source: '<Workbench />', requires: ['plugin-kanban'] };
1381+
rows.set(keyOf({ type: 'page', name: 'workbench', organization_id: null, state: 'active' }), {
1382+
id: 'r_seed', type: 'page', name: 'workbench', organization_id: null, state: 'active', metadata: JSON.stringify(body),
1383+
});
1384+
1385+
const result = await protocol.loadMetaFromDb();
1386+
1387+
expect(result).toMatchObject({ loaded: 1, errors: 0, invalid: 0 });
1388+
expect(registered).toContainEqual({ type: 'page', name: 'workbench' });
1389+
const lines = (warn.mock.calls as unknown[][]).map((c) => String(c[0]));
1390+
const converted = lines.filter((m) => m.includes('stored page/workbench carries a pre-protocol shape'));
1391+
expect(converted, JSON.stringify(lines)).toHaveLength(1);
1392+
expect(converted[0]).toContain("ADR-0087 conversion 'page-requires-non-compiled-kind-removed'");
1393+
expect(converted[0]).toContain('page.requires at pages[0].requires');
1394+
// The manifest carries no `plugin-kanban`, so an unconverted list WOULD
1395+
// have been reported — its absence is the conversion's doing.
1396+
expect(loadReports(warn)).toEqual([]);
1397+
expect(lines.filter((m) => m.includes('[metadata_spec_invalid]'))).toEqual([]);
1398+
});
1399+
13421400
// ── At draft → active promotion ──────────────────────────────────────
13431401

13441402
it('a draft saved before the manifest arrived is promoted with the `requires` the save door computes', async () => {

‎packages/spec/api-surface/ui.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
"BulkActionParamSchema (const)",
7373
"CHART_AGGREGATE_COMPARISON_SUFFIX (const)",
7474
"COLUMN_SUMMARY_AGGREGATION (const)",
75+
"COMPILED_PAGE_KINDS (const)",
7576
"CalendarConfig (type)",
7677
"CalendarConfigSchema (const)",
7778
"ChartAggregate (type)",
@@ -482,6 +483,7 @@
482483
"checkDashboardWidgetStageOrder (function)",
483484
"checkGlobalFilterDateDefaultValue (function)",
484485
"checkListViewCalendarVisualization (function)",
486+
"checkPageRequiresKind (function)",
485487
"checkPageSourceCompleteness (function)",
486488
"columnSummaryAlias (function)",
487489
"compileListViewGroupQuery (function)",

‎packages/spec/export-origins/ui.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
"BulkActionParamSchema": "src/ui/bulk-action.zod.ts#BulkActionParamSchema (const)",
7070
"CHART_AGGREGATE_COMPARISON_SUFFIX": "src/ui/chart-aggregate.ts#CHART_AGGREGATE_COMPARISON_SUFFIX (const)",
7171
"COLUMN_SUMMARY_AGGREGATION": "src/ui/view-grouping-query.ts#COLUMN_SUMMARY_AGGREGATION (const)",
72+
"COMPILED_PAGE_KINDS": "src/ui/page.zod.ts#COMPILED_PAGE_KINDS (const)",
7273
"CalendarConfig": "src/ui/view.zod.ts#CalendarConfig (type)",
7374
"CalendarConfigSchema": "src/ui/view.zod.ts#CalendarConfigSchema (const)",
7475
"ChartAggregate": "src/ui/chart.zod.ts#ChartAggregate (type)",
@@ -467,6 +468,7 @@
467468
"checkDashboardWidgetStageOrder": "src/ui/dashboard.zod.ts#checkDashboardWidgetStageOrder (function)",
468469
"checkGlobalFilterDateDefaultValue": "src/ui/dashboard.zod.ts#checkGlobalFilterDateDefaultValue (function)",
469470
"checkListViewCalendarVisualization": "src/ui/view.zod.ts#checkListViewCalendarVisualization (function)",
471+
"checkPageRequiresKind": "src/ui/page.zod.ts#checkPageRequiresKind (function)",
470472
"checkPageSourceCompleteness": "src/ui/page.zod.ts#checkPageSourceCompleteness (function)",
471473
"columnSummaryAlias": "src/ui/view-grouping-query.ts#columnSummaryAlias (function)",
472474
"compileListViewGroupQuery": "src/ui/view-grouping-query.ts#compileListViewGroupQuery (function)",

‎packages/spec/liveness/page.json‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@
88
},
99
"requires": {
1010
"status": "live",
11-
"verifiedAt": "2026-10-02",
11+
"verifiedAt": "2026-10-03",
1212
"evidenceScope": "in-repo",
13-
"evidence": "SAVE: packages/metadata-protocol/src/runtime-authoring-gate.ts#findHtmlPageSourceGaps reads the authored list of a kind 'html' page and refuses one that disagrees with the namespaces its compiled source uses — 422 INVALID_METADATA under page-requires-disagrees-with-source, on an active save and on a draft's publish; packages/metadata-protocol/src/runtime-authoring-gate.ts#stampHtmlPageRequires stores the compiled list on save, and packages/metadata-protocol/src/protocol.ts#promoteDraftForPublish applies it again to the body a draft promotion writes. LOAD: packages/metadata-protocol/src/protocol.ts#reportPageRequiresAbsentAtLoad, called from boot hydration (loadMetaFromDb), reports a stored page whose list names a namespace no component in the manifest carries (packages/metadata-protocol/src/runtime-authoring-gate.ts#findPageRequiresAbsentFromManifest), page and plugin named; the page still loads and is served",
13+
"evidence": "PARSE: packages/spec/src/ui/page.zod.ts#checkPageRequiresKind refuses the key on every kind but html / jsx (COMPILED_PAGE_KINDS) — a react, full or slotted page, a page that omits kind included — at requires, naming the key, the kind and the compiled kinds. SAVE: packages/metadata-protocol/src/runtime-authoring-gate.ts#findHtmlPageSourceGaps reads the authored list of a kind 'html' page and refuses one that disagrees with the namespaces its compiled source uses — 422 INVALID_METADATA under page-requires-disagrees-with-source, on an active save and on a draft's publish; packages/metadata-protocol/src/runtime-authoring-gate.ts#stampHtmlPageRequires stores the compiled list on save, and packages/metadata-protocol/src/protocol.ts#promoteDraftForPublish applies it again to the body a draft promotion writes. LOAD: packages/metadata-protocol/src/protocol.ts#reportPageRequiresAbsentAtLoad, called from boot hydration (loadMetaFromDb), reports a stored page whose list names a namespace no component in the manifest carries (packages/metadata-protocol/src/runtime-authoring-gate.ts#findPageRequiresAbsentFromManifest), page and plugin named; the page still loads and is served",
1414
"producer": "packages/cli/src/utils/sdui-manifest.ts#registerDeploymentSduiManifest — both moments compare the list against the deployment's SDUI component manifest, a second input: os serve (packages/cli/src/commands/serve.ts, which dev and start spawn) resolves it at boot and registers it under SDUI_MANIFEST_SERVICE, and packages/metadata-protocol/src/protocol.ts#resolveSduiManifest reads that key per publish and at load. A host that registers no manifest judges neither moment and prints one boot line saying so",
15-
"note": "Plugin namespaces an html page's `source` uses (ADR-0080 §5), derived from the source at save. planned → live 2026-10-02 (#20871): refused at save since the save door landed (PR #20852, #20312 stages ① and ②), reported at load and re-stamped on draft promotion since #20870 (PR #21121). An authored list survives only when it agrees with the source; omitting it is the intended authoring. Boundaries: kind 'react' pages are not compiled at save (ADR-0081), so an authored list on one is judged only by the load report; a draft is stored as written and judged at its publish; rows already stored are reported, never rewritten."
15+
"note": "Plugin namespaces an html page's `source` uses (ADR-0080 §5), derived from the source at save. planned → live 2026-10-02 (#20871): refused at save since the save door landed (PR #20852, #20312 stages ① and ②), reported at load and re-stamped on draft promotion since #20870 (PR #21121). An authored list survives only when it agrees with the source; omitting it is the intended authoring. Boundaries: the key exists only on kind 'html' / 'jsx' pages, the kinds compiled at save; on 'react' (not compiled at save, ADR-0081), 'full' and 'slotted' pages, a page with no kind being 'full', the parse refuses it since 2026-10-03 (#21459, ruling A), and the protocol-18 conversion page-requires-non-compiled-kind-removed strips it from stored rows and built artifacts at load; a draft is stored as written and judged at its publish; stored html rows are reported, never rewritten."
1616
},
1717
"name": {
1818
"status": "live",

0 commit comments

Comments
 (0)