From a7c62ad6d6ded4794d87d71009bb16985b795595 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 04:15:10 +0000 Subject: [PATCH 1/4] =?UTF-8?q?wip(spec):=20page=20requires=20only=20on=20?= =?UTF-8?q?compiled=20kinds=20=E2=80=94=20refusal,=20conversion,=20D3=20en?= =?UTF-8?q?try,=20ledger=20rows=20(#21459)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude --- packages/spec/liveness/page.json | 6 +- packages/spec/src/conversions/registry.ts | 72 +++++ ...page-requires-non-compiled-kind-refused.ts | 51 ++++ packages/spec/src/migrations/registry.ts | 63 +++++ .../metadata-form-zod-reconciliation.test.ts | 2 +- .../object-refinement-check-exports.test.ts | 36 ++- .../ui/page-requires-compiled-kinds.test.ts | 246 ++++++++++++++++++ packages/spec/src/ui/page.zod.ts | 90 ++++++- 8 files changed, 543 insertions(+), 23 deletions(-) create mode 100644 packages/spec/src/migrations/entries/semantic/18.page-requires-non-compiled-kind-refused.ts create mode 100644 packages/spec/src/ui/page-requires-compiled-kinds.test.ts diff --git a/packages/spec/liveness/page.json b/packages/spec/liveness/page.json index a866b58134a..5738316c86f 100644 --- a/packages/spec/liveness/page.json +++ b/packages/spec/liveness/page.json @@ -8,11 +8,11 @@ }, "requires": { "status": "live", - "verifiedAt": "2026-10-02", + "verifiedAt": "2026-10-03", "evidenceScope": "in-repo", - "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", + "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", "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", - "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." + "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." }, "name": { "status": "live", diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index bf455aee494..2c159228f6e 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -9523,6 +9523,77 @@ const pageHeaderBreadcrumbRemoved: MetadataConversion = { }, }; +/** + * `page.requires` removed from the page kinds whose source is never compiled at + * save — `react`, `full` and `slotted`, a page that omits `kind` included + * (protocol 18, #21459; ruling record 5964312254, letter A). + * + * `requires` is the plugin-namespace list ADR-0080 §5 derives from an html + * page's source at save. `PageSchema` used to admit it on every kind, yet on + * these three nothing derives it: the save door compiles only `html` / `jsx` + * (`COMPILED_PAGE_KINDS`), the Studio page editor drops the key on every save, + * and its one reader there was the load report's warning. The parse now refuses + * it on these kinds (`checkPageRequiresKind` in `ui/page.zod.ts`). + * + * **A strip — a pure lossless delete.** On these kinds the list never derived, + * gated or rendered anything, so deleting it changes nothing a page does; it is + * what a Studio save of the same page already does. The paired D3 entry is + * `page-requires-non-compiled-kind-refused`. + * + * Scoped by `kind`, exactly as the refusal is: the kinds stripped are + * `PageSchema`'s kind vocabulary minus `COMPILED_PAGE_KINDS` (pinned in + * `ui/page-requires-compiled-kinds.test.ts`), an absent `kind` reading as the + * spec default `full`. An `html` / `jsx` page keeps its list — the save door + * judges it — and an unknown `kind` is left as stored: that refusal is the kind + * enum's, not this entry's. + * + * ⚠️ Coverage boundary: this walks `stack.pages[]` ({@link mapPages}). + * `requires` is a top-level `PageSchema` key with no nested spelling. + */ +const pageRequiresNonCompiledKindRemoved: MetadataConversion = { + id: 'page-requires-non-compiled-kind-removed', + toMajor: 18, + retiredFromLoadPath: true, + retiredAfter: '17.6.0', + surface: 'page.requires', + summary: + "page key 'requires' removed from react, full and slotted pages (a page with no kind is " + + 'full) — the plugin-namespace list is derived from the source at save only on html / jsx ' + + 'pages; on the other kinds nothing derived or enforced it, and the Studio page editor drops it', + apply(stack, emit) { + return mapPages(stack, (page, path) => { + if (page.requires === undefined) return page; + const kind = page.kind ?? 'full'; + if (kind !== 'full' && kind !== 'slotted' && kind !== 'react') return page; + return stripKeys(page, ['requires'], emit, path); + }); + }, + fixture: { + before: { + pages: [ + // A react page whose author listed the plugins its source uses. + { name: 'crm_workbench', kind: 'react', source: '', requires: ['ui'] }, + // A slotted record page, and a page with no kind (the `full` default). + { name: 'lead_record', type: 'record', kind: 'slotted', requires: ['plugin-kanban'] }, + { name: 'team_home', label: 'Team Home', requires: [] }, + // An html page keeps its list: the save door derives and judges it. + { name: 'command_center', kind: 'html', source: '', requires: ['ui'] }, + ], + }, + after: { + pages: [ + { name: 'crm_workbench', kind: 'react', source: '' }, + { name: 'lead_record', type: 'record', kind: 'slotted' }, + { name: 'team_home', label: 'Team Home' }, + { name: 'command_center', kind: 'html', source: '', requires: ['ui'] }, + ], + }, + // One per stripped key: the react, slotted and kind-less pages. The html + // page emits none. + expectedNotices: 3, + }, +}; + /** * Object-permission lifecycle bits `allowRestore` / `allowPurge` removed * (protocol 18, #12497 — ADR-0049 enforce-or-remove, maintainer ruling @@ -14375,6 +14446,7 @@ const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [ { conversion: pageComponentFilterRecordToRuleArray, order: 36 }, { conversion: pageComponentResponsiveRemoved, order: 13 }, { conversion: pageHeaderBreadcrumbRemoved, order: 49 }, + { conversion: pageRequiresNonCompiledKindRemoved, order: 58 }, { conversion: permissionAllowRestorePurgeRemoved, order: 16 }, { conversion: permissionRlsTagsRemoved, order: 42 }, { conversion: recordChatterPositionVocabulary, order: 2 }, diff --git a/packages/spec/src/migrations/entries/semantic/18.page-requires-non-compiled-kind-refused.ts b/packages/spec/src/migrations/entries/semantic/18.page-requires-non-compiled-kind-refused.ts new file mode 100644 index 00000000000..cd05da695c2 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.page-requires-non-compiled-kind-refused.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #21459 — the D3 entry of the `page-requires-non-compiled-kind-removed` family +// (one D3 entry per retirement family, even when D2 is lossless): page +// `requires` narrowed to the kinds whose source the save door compiles, ruling +// record 5964312254, letter A. It is a narrowing by `kind`, not a key removal: +// the key stays live on html / jsx pages, so there is no tombstone and no +// RETIRED_KEYS_BY_MAJOR row — the parse refuses it through +// `checkPageRequiresKind` (ui/page.zod.ts) on the other kinds. +export const entry: SemanticMigration = { + id: 'page-requires-non-compiled-kind-refused', + // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it + // inside a code span and a table cell. + surface: + 'page.requires on a page whose kind is react, full or slotted — a page that omits kind ' + + 'included, since its kind is full', + replacement: + 'Nothing: delete the key. On an html page (and its deprecated jsx alias) the platform derives ' + + '`requires` from the source at save and stores it, so it is omitted there too; on a react, full ' + + 'or slotted page nothing ever derived or enforced it, and nothing takes its place.', + reason: + '`PageSchema` admitted `requires` on every page kind, but the platform derives it only on the ' + + 'kinds whose source the metadata save door compiles: saving an html page (alias jsx) on a server ' + + 'that has the deployment\'s SDUI component manifest compiles the source, stores the plugin ' + + '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 kinds nothing derived the key, the Studio page editor dropped it on every save, and its ' + + 'one reader was a load-time warning. The maintainer ruled (2026-10-03) that the key is accepted ' + + 'only on html and jsx pages. The parse now refuses it on react, full and slotted pages, and a ' + + 'page that omits kind is a full page: `objectstack validate`, the metadata save door (a 422) and ' + + 'every other door that parses a page name the key, the page\'s kind and the compiled kinds. An ' + + 'empty list is refused like a full one, because the key is what is refused. No page body ' + + 'authoring the key on those kinds was measured in this repository, cloud, hotcrm or objectui. ' + + 'The D2 conversion `page-requires-non-compiled-kind-removed` deletes it from such pages: stored ' + + 'rows and built artifacts replay it at load, with a notice, and `objectstack migrate meta --from ' + + '17` lists the edit for authored sources, which the parse refuses until it is made. The delete ' + + 'loses nothing a page did. What it cannot decide is whether the page should have been an html ' + + 'page: an author who wrote the list to have plugin presence checked gets that check only on an ' + + 'html page, where the platform derives the list from the source and judges it at save and load.', + acceptanceCriteria: + '`objectstack validate` reports no issue at a page\'s `requires` path: no react, full or slotted ' + + 'page, and no page that omits kind, carries the key, and each html or jsx page either omits it ' + + 'or carries exactly the list its source compiles to. Saving each formerly affected page through ' + + 'the metadata API succeeds instead of answering a 422 that names `requires`. Replaying ' + + '`objectstack migrate meta --from 17` over the edited source lists no ' + + '`page-requires-non-compiled-kind-removed` edit, and every page renders as it did before the ' + + 'upgrade.', + conversionIds: ['page-requires-non-compiled-kind-removed'], +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 3641f88540d..7b3b658390d 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5904,6 +5904,22 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + '`page-header-breadcrumb-retired`. The `nav:breadcrumb` component type is not part of it: ' + 'the Studio page palette still offers it.', }, + { + id: 'page-requires-non-compiled-kind-refused', + order: 65, + text: + 'It also narrows page `requires` to the kinds whose source is compiled at save (ADR-0080 §5; ' + + 'maintainer ruling 2026-10-03, letter A): the plugin-namespace list is derived from an html ' + + 'page\'s source when the page is saved, while on `react`, `full` and `slotted` pages nothing ' + + 'derived it, the Studio page editor dropped it on every save, and a load-time warning was its ' + + 'one reader. `PageSchema` now accepts the key only when `kind` is `html` or its deprecated ' + + 'alias `jsx`, and refuses it at `requires` on every other kind, a page that omits `kind` ' + + 'included, naming the key, the page\'s kind and the compiled kinds. The key stays live on html ' + + 'pages, so there is no tombstone. The D2 conversion `page-requires-non-compiled-kind-removed` ' + + 'deletes the key from those pages, retired from the load path, so stored rows and artifacts ' + + 'replay clean while authored sources are refused until edited; the delete is lossless. Its D3 ' + + 'record is the semantic entry `page-requires-non-compiled-kind-refused`.', + }, { id: 'permission-restore-purge-bits-retired', order: 18, @@ -15548,6 +15564,53 @@ const step18: MigrationStep = { + 'page shows the same navigation trail in the app shell\'s header as before the upgrade, and each ' + 'page header shows the same title, subtitle and actions.', }, + // #21459 — the D3 entry of the `page-requires-non-compiled-kind-removed` family + // (one D3 entry per retirement family, even when D2 is lossless): page + // `requires` narrowed to the kinds whose source the save door compiles, ruling + // record 5964312254, letter A. It is a narrowing by `kind`, not a key removal: + // the key stays live on html / jsx pages, so there is no tombstone and no + // RETIRED_KEYS_BY_MAJOR row — the parse refuses it through + // `checkPageRequiresKind` (ui/page.zod.ts) on the other kinds. + { + id: 'page-requires-non-compiled-kind-refused', + // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it + // inside a code span and a table cell. + surface: + 'page.requires on a page whose kind is react, full or slotted — a page that omits kind ' + + 'included, since its kind is full', + replacement: + 'Nothing: delete the key. On an html page (and its deprecated jsx alias) the platform derives ' + + '`requires` from the source at save and stores it, so it is omitted there too; on a react, full ' + + 'or slotted page nothing ever derived or enforced it, and nothing takes its place.', + reason: + '`PageSchema` admitted `requires` on every page kind, but the platform derives it only on the ' + + 'kinds whose source the metadata save door compiles: saving an html page (alias jsx) on a server ' + + 'that has the deployment\'s SDUI component manifest compiles the source, stores the plugin ' + + '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 kinds nothing derived the key, the Studio page editor dropped it on every save, and its ' + + 'one reader was a load-time warning. The maintainer ruled (2026-10-03) that the key is accepted ' + + 'only on html and jsx pages. The parse now refuses it on react, full and slotted pages, and a ' + + 'page that omits kind is a full page: `objectstack validate`, the metadata save door (a 422) and ' + + 'every other door that parses a page name the key, the page\'s kind and the compiled kinds. An ' + + 'empty list is refused like a full one, because the key is what is refused. No page body ' + + 'authoring the key on those kinds was measured in this repository, cloud, hotcrm or objectui. ' + + 'The D2 conversion `page-requires-non-compiled-kind-removed` deletes it from such pages: stored ' + + 'rows and built artifacts replay it at load, with a notice, and `objectstack migrate meta --from ' + + '17` lists the edit for authored sources, which the parse refuses until it is made. The delete ' + + 'loses nothing a page did. What it cannot decide is whether the page should have been an html ' + + 'page: an author who wrote the list to have plugin presence checked gets that check only on an ' + + 'html page, where the platform derives the list from the source and judges it at save and load.', + acceptanceCriteria: + '`objectstack validate` reports no issue at a page\'s `requires` path: no react, full or slotted ' + + 'page, and no page that omits kind, carries the key, and each html or jsx page either omits it ' + + 'or carries exactly the list its source compiles to. Saving each formerly affected page through ' + + 'the metadata API succeeds instead of answering a 422 that names `requires`. Replaying ' + + '`objectstack migrate meta --from 17` over the edited source lists no ' + + '`page-requires-non-compiled-kind-removed` edit, and every page renders as it did before the ' + + 'upgrade.', + conversionIds: ['page-requires-non-compiled-kind-removed'], + }, // #12497 (ADR-0049, maintainer ruling accepting #1883's recommendation B) — the // D3 entry of the `permission-allow-restore-purge-removed` family (ruling B on // #17152: one D3 entry per retirement family, even when D2 is lossless). The diff --git a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts index 2694ee4f52c..26f47a279c3 100644 --- a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts +++ b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts @@ -328,7 +328,7 @@ const LEDGER: ReadonlyArray = [ type: 'page', path: ROOT_PATH, key: 'requires', - why: "platform-written, never authored — the schema's own words: `derived from the source at save — omit it`. On an `html` / `jsx` page, on a server with the deployment's SDUI manifest, the save door stamps the compiled list and refuses a written list that disagrees (`page-requires-disagrees-with-source`); on `react`, `full` and `slotted` pages nothing derives it, and its one reader is the load report (a warning; the page is still served); and the Studio page editor drops the key on every save. A control would invite the list the describe tells every author to omit", + why: "platform-written, never authored — the schema's own words: `derived from the source at save — omit it`. The key exists only on an `html` / `jsx` page, where, on a server with the deployment's SDUI manifest, the save door stamps the compiled list and refuses a written list that disagrees (`page-requires-disagrees-with-source`); on every other kind the parse refuses it; and the Studio page editor drops the key on every save. A control would invite the list the describe tells every author to omit", }, { kind: 'omit', diff --git a/packages/spec/src/ui/object-refinement-check-exports.test.ts b/packages/spec/src/ui/object-refinement-check-exports.test.ts index 7ab1feef2c3..ad6ced460f0 100644 --- a/packages/spec/src/ui/object-refinement-check-exports.test.ts +++ b/packages/spec/src/ui/object-refinement-check-exports.test.ts @@ -55,7 +55,7 @@ import { ObjectListViewSchema, checkListViewCalendarVisualization, } from './view.zod'; -import { PageSchema, checkPageSourceCompleteness } from './page.zod'; +import { PageSchema, checkPageSourceCompleteness, checkPageRequiresKind } from './page.zod'; import { GlobalFilterSchema, checkGlobalFilterDateDefaultValue, @@ -221,6 +221,22 @@ const pageSourceFixtures: Fixture[] = [ { label: 'a page with no `kind` (the `full` default)', value: { ...PAGE_BASE }, refusesAt: [] }, ]; +// `requires` only on the compiled kinds (#21459). A `react` page carries a +// `source` throughout so the source-completeness sibling stays silent and each +// fixture exercises THIS check alone; the kind-less fixture is the one whose +// direct call sees no `kind` while the parse sees the applied `full` default, +// so it also pins that the two read the same answer. +const pageRequiresFixtures: Fixture[] = [ + { label: '`requires` on a `react` page', value: { ...PAGE_BASE, kind: 'react', source: 'Card', requires: ['ui'] }, refusesAt: ['requires'] }, + { label: '`requires` on a `full` page', value: { ...PAGE_BASE, kind: 'full', requires: ['ui'] }, refusesAt: ['requires'] }, + { label: '`requires` on a `slotted` page', value: { ...PAGE_BASE, kind: 'slotted', requires: ['ui'] }, refusesAt: ['requires'] }, + { label: '`requires` on a page with no `kind` (the `full` default)', value: { ...PAGE_BASE, requires: ['ui'] }, refusesAt: ['requires'] }, + { label: 'an EMPTY `requires` on a `react` page — the key is refused, not its contents', value: { ...PAGE_BASE, kind: 'react', source: 'Card', requires: [] }, refusesAt: ['requires'] }, + { label: '`requires` on an `html` page', value: { ...PAGE_BASE, kind: 'html', source: 'Card', requires: ['ui'] }, refusesAt: [] }, + { label: '`requires` on a `jsx` page (the deprecated `html` alias)', value: { ...PAGE_BASE, kind: 'jsx', source: 'Card', requires: ['ui'] }, refusesAt: [] }, + { label: 'a `react` page with no `requires`', value: { ...PAGE_BASE, kind: 'react', source: 'Card' }, refusesAt: [] }, +]; + const DATE_FILTER = { field: 'created_at', type: 'date' } as const; const dateDefaultFixtures: Fixture[] = [ @@ -430,7 +446,10 @@ const MIRRORED: MirroredSchema[] = [ { name: 'PageSchema', schema: PageSchema, - exports: [{ name: 'checkPageSourceCompleteness', check: checkPageSourceCompleteness, fixtures: pageSourceFixtures }], + exports: [ + { name: 'checkPageSourceCompleteness', check: checkPageSourceCompleteness, fixtures: pageSourceFixtures }, + { name: 'checkPageRequiresKind', check: checkPageRequiresKind, fixtures: pageRequiresFixtures }, + ], cleanFixtures: [{ ...PAGE_BASE }], }, { @@ -577,11 +596,13 @@ describe('each schema attaches its export BY IDENTIFIER — no inline copy', () expect(attachments(src, 'checkListViewPageMount')).toBe(0); }); - it('page.zod.ts declares the export and attaches it to PageSchema', () => { + it('page.zod.ts declares both exports and attaches each to PageSchema', () => { const src = read('page.zod.ts'); - expect(src).toContain('export function checkPageSourceCompleteness('); - expect(declarations(src, 'checkPageSourceCompleteness')).toBe(1); - expect(attachments(src, 'checkPageSourceCompleteness')).toBe(1); + for (const name of ['checkPageSourceCompleteness', 'checkPageRequiresKind']) { + expect(src).toContain(`export function ${name}(`); + expect(declarations(src, name)).toBe(1); + expect(attachments(src, name)).toBe(1); + } }); it('dashboard.zod.ts declares the export and attaches it to GlobalFilterSchema', () => { @@ -608,6 +629,7 @@ describe('`./index` (the `@objectstack/spec/ui` surface) exports the same functi it.each([ ['checkListViewCalendarVisualization', checkListViewCalendarVisualization], ['checkPageSourceCompleteness', checkPageSourceCompleteness], + ['checkPageRequiresKind', checkPageRequiresKind], ['checkGlobalFilterDateDefaultValue', checkGlobalFilterDateDefaultValue], ] as const)('%s — reference identity, and the `(value, ctx)` arity', (name, fn) => { expect((ui as Record)[name]).toBe(fn); @@ -618,7 +640,7 @@ describe('`./index` (the `@objectstack/spec/ui` surface) exports the same functi // [#17063] The retired member, from the same surface, in the same leg. A // downstream mirror re-attaching a check it imports from here is the whole // point of this file, so the barrel is where a relapse would first become - // reachable — the runtime namespace answers it, with the three survivors + // reachable — the runtime namespace answers it, with the four survivors // above as the lit control that the namespace is really populated. it('no longer exports `checkListViewPageMount` — retired with the mount it policed', () => { expect('checkListViewPageMount' in (ui as Record)).toBe(false); diff --git a/packages/spec/src/ui/page-requires-compiled-kinds.test.ts b/packages/spec/src/ui/page-requires-compiled-kinds.test.ts new file mode 100644 index 00000000000..2f71696b117 --- /dev/null +++ b/packages/spec/src/ui/page-requires-compiled-kinds.test.ts @@ -0,0 +1,246 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Page `requires` only on the kinds whose source is compiled at save (#21459; + * ruling record 5964312254, letter A). + * + * `requires` is the plugin-namespace list ADR-0080 §5 derives from an html + * page's source at save. `PageSchema` used to admit it on every kind; on + * `react`, `full` and `slotted` pages nothing derived it, so a written list + * there was a declaration nothing honoured. Bookkeeping shapes, pinned below: + * + * 1. `checkPageRequiresKind`, an exported object-level check attached to + * `PageSchema` beside `checkPageSourceCompleteness` (the one mechanism + * this file uses for kind-conditional rules), refuses the key at + * `requires` on every kind outside `COMPILED_PAGE_KINDS`, naming the key, + * the page's kind and the compiled kinds. Its parity with the schema is + * pinned in `object-refinement-check-exports.test.ts`. + * 2. D2 conversion `page-requires-non-compiled-kind-removed` (step 18), a + * lossless strip retired from the load path: stored rows and artifacts + * replay clean, authored sources are refused until edited. + * 3. The family's D3 entry is `page-requires-non-compiled-kind-refused`. + * There is no `RETIRED_KEYS_BY_MAJOR` row: the key stays live on html + * pages, so nothing is tombstoned. + * + * On the assertion set: a bare `PageSchema` parse raises zod issues, pinned by + * `code`, `path` and the message's named subjects; the stack door wraps them + * in the ADR-0112 envelope, pinned by `code` and `status`. + */ + +import { describe, expect, it } from 'vitest'; + +import { collectConversionNotices } from '../conversions/apply'; +import { ALL_CONVERSIONS } from '../conversions/registry'; +import { applyConversionsToStoredItem } from '../conversions/stored'; +import type { ConversionNotice } from '../conversions/types'; +import { MIGRATIONS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; +import { defineStack } from '../stack.zod'; +import { COMPILED_PAGE_KINDS, PageSchema } from './page.zod'; + +const CONVERSION_ID = 'page-requires-non-compiled-kind-removed'; +const D3_ID = 'page-requires-non-compiled-kind-refused'; + +const BASE = { name: 'home_page', label: 'Home', type: 'home' } as const; +/** The kinds a `source` is required on — every fixture of one carries a source, so only `requires` is judged. */ +const SOURCE_KINDS = new Set(['html', 'jsx', 'react']); +const page = (kind: string | undefined, extra: Record = {}) => ({ + ...BASE, + ...(kind === undefined ? {} : { kind }), + ...(kind !== undefined && SOURCE_KINDS.has(kind) ? { source: 'Card' } : {}), + ...extra, +}); + +/** `PageSchema`'s own `kind` vocabulary, read off the schema rather than restated. */ +const PAGE_KINDS: readonly string[] = ( + PageSchema as unknown as { shape: { kind: { unwrap: () => { options: readonly string[] } } } } +).shape.kind.unwrap().options; + +const NON_COMPILED = ['react', 'full', 'slotted'] as const; + +function refusalOf(value: unknown) { + const r = PageSchema.safeParse(value); + expect(r.success, 'expected a refusal').toBe(false); + return r.success ? [] : r.error.issues; +} + +describe('page `requires` — the parse refuses it outside the compiled kinds', () => { + it('the vocabulary: five kinds, of which `html` and `jsx` are the compiled ones', () => { + expect([...PAGE_KINDS].sort()).toEqual(['full', 'html', 'jsx', 'react', 'slotted']); + expect([...COMPILED_PAGE_KINDS]).toEqual(['html', 'jsx']); + expect(PAGE_KINDS.filter((k) => !(COMPILED_PAGE_KINDS as readonly string[]).includes(k)).sort()) + .toEqual([...NON_COMPILED].sort()); + }); + + it.each(NON_COMPILED)('refuses `requires` on a `%s` page, at `requires`, naming the key, the kind and the compiled kinds', (kind) => { + const issues = refusalOf(page(kind, { requires: ['ui'] })); + expect(issues).toHaveLength(1); + const [issue] = issues; + expect(issue!.code).toBe('custom'); + expect(issue!.path).toEqual(['requires']); + expect(issue!.message.startsWith(`\`requires\` is refused on a \`kind: '${kind}'\` page`)).toBe(true); + expect(issue!.message).toContain('`html`'); + expect(issue!.message).toContain('`jsx`'); + expect(issue!.message).toContain('Delete the key.'); + // Runtime prose carries no tracker number (`check:doc-authoring`). + expect(issue!.message).not.toMatch(/#\d/); + }); + + it('a page that omits `kind` is a `full` page — refused the same way, and the message says why', () => { + // The default the refusal reads: an omitted kind parses as `full`. + const parsed = PageSchema.parse(page(undefined)); + expect(parsed.kind).toBe('full'); + + const issues = refusalOf(page(undefined, { requires: ['ui'] })); + expect(issues).toHaveLength(1); + expect(issues[0]!.path).toEqual(['requires']); + expect(issues[0]!.message.startsWith( + "`requires` is refused on a `kind: 'full'` page (`full` is also the kind of a page that omits `kind`)", + )).toBe(true); + }); + + it('the KEY is refused, not its contents: an empty list on a non-compiled kind is refused too', () => { + for (const kind of [...NON_COMPILED, undefined]) { + const issues = refusalOf(page(kind, { requires: [] })); + expect(issues.map((i) => i.path.join('.')), `kind: ${kind}`).toEqual(['requires']); + } + }); + + it.each(COMPILED_PAGE_KINDS)('CONTROL: a `%s` page keeps its `requires` — the save door is the one that judges it', (kind) => { + const r = PageSchema.safeParse(page(kind, { requires: ['ui', 'plugin-kanban'] })); + expect(r.success, JSON.stringify(r.error?.issues ?? [])).toBe(true); + if (r.success) expect(r.data.requires).toEqual(['ui', 'plugin-kanban']); + }); + + it('CONTROL: a page with no `requires` parses on every kind, and grows none', () => { + for (const kind of [...PAGE_KINDS, undefined]) { + const r = PageSchema.safeParse(page(kind)); + expect(r.success, `kind: ${kind} — ${JSON.stringify(r.error?.issues ?? [])}`).toBe(true); + if (r.success) expect(r.data).not.toHaveProperty('requires'); + } + }); + + it('the stack door wraps the same refusal in its ADR-0112 envelope, at `pages.N.requires`', () => { + const stack = { + manifest: { id: 'com.example.pages', name: 'pages', version: '1.0.0', type: 'app', namespace: 'pgs' }, + pages: [ + { name: 'pgs_landing', label: 'Landing', kind: 'html', source: 'Card', requires: ['ui'] }, + { name: 'pgs_workbench', label: 'Workbench', kind: 'react', source: 'Card', requires: ['ui'] }, + ], + }; + let refusal: { code?: unknown; status?: unknown; issues?: Array<{ path: unknown[]; code: string }> } | undefined; + try { + defineStack(stack as never); + } catch (e) { + refusal = e as typeof refusal; + } + expect(refusal, 'defineStack must refuse the react page').toBeDefined(); + expect({ code: refusal!.code, status: refusal!.status }).toEqual({ code: 'STACK_SCHEMA_INVALID', status: 422 }); + expect(refusal!.issues!.map((i) => ({ path: i.path.join('.'), code: i.code }))).toEqual([ + { path: 'pages.1.requires', code: 'custom' }, + ]); + }); +}); + +type Notice = Pick; +const brief = (n: ConversionNotice): Notice => ({ conversionId: n.conversionId, path: n.path, from: n.from, to: n.to }); + +describe('page `requires` — the D2 conversion (the stored-row disposition)', () => { + it.each([...NON_COMPILED, undefined])('a STORED `%s` page loads with the key stripped and the notice recorded, and then parses', (kind) => { + const notices: ConversionNotice[] = []; + const row = page(kind, { requires: ['ui'] }); + const rehydrated = applyConversionsToStoredItem('page', row, { onNotice: (n) => notices.push(n) }) as Record; + expect(notices.map(brief)).toEqual([ + { conversionId: CONVERSION_ID, path: 'pages[0].requires', from: 'requires', to: '(removed)' }, + ]); + expect(rehydrated).not.toHaveProperty('requires'); + const { requires: _dropped, ...rest } = row as Record; + expect(rehydrated).toEqual(rest); + expect(PageSchema.safeParse(rehydrated).success).toBe(true); + }); + + it.each(COMPILED_PAGE_KINDS)('CONTROL: a stored `%s` page keeps its list — no notice from this entry', (kind) => { + const notices: ConversionNotice[] = []; + const row = page(kind, { requires: ['ui'] }); + const rehydrated = applyConversionsToStoredItem('page', row, { onNotice: (n) => notices.push(n) }) as Record; + expect(notices.filter((n) => n.conversionId === CONVERSION_ID)).toHaveLength(0); + expect(rehydrated.requires).toEqual(['ui']); + }); + + it('strips exactly where the parse refuses: on every kind of the vocabulary, and on an omitted one', () => { + for (const kind of [...PAGE_KINDS, undefined]) { + const value = page(kind, { requires: ['ui'] }); + const refused = !PageSchema.safeParse(value).success; + const { notices } = collectConversionNotices({ pages: [value] }, { includeRetired: true }); + const stripped = notices.some((n) => n.conversionId === CONVERSION_ID); + expect(stripped, `kind: ${kind}`).toBe(refused); + } + }); + + it('leaves an unknown `kind` as stored — that refusal is the kind enum\'s, not this entry\'s', () => { + const artifact = { pages: [{ ...BASE, kind: 'bespoke', requires: ['ui'] }] }; + const { stack, notices } = collectConversionNotices(artifact, { includeRetired: true }); + expect(notices.filter((n) => n.conversionId === CONVERSION_ID)).toHaveLength(0); + expect(stack).toBe(artifact); + }); + + it('a BUILT artifact replays the strip on every affected page and only those', () => { + const artifact = { + pages: [ + page('react', { requires: ['ui'] }), + page('html', { requires: ['ui'] }), + page('slotted', { requires: [] }), + page(undefined), + ], + }; + const { notices } = collectConversionNotices(artifact, { includeRetired: true }); + expect(notices.filter((n) => n.conversionId === CONVERSION_ID).map((n) => n.path)).toEqual([ + 'pages[0].requires', + 'pages[2].requires', + ]); + }); + + it('is idempotent by construction: a second replay converts nothing', () => { + const { stack } = collectConversionNotices({ pages: [page('react', { requires: ['ui'] })] }, { includeRetired: true }); + const replay = collectConversionNotices(stack, { includeRetired: true }); + expect(replay.notices.filter((n) => n.conversionId === CONVERSION_ID)).toHaveLength(0); + expect(replay.stack).toBe(stack); + }); + + it('is retired from the load path — the authoring funnel does not rewrite a live source', () => { + const input = { pages: [page('react', { requires: ['ui'] })] }; + const { stack, notices } = collectConversionNotices(input); + expect(notices.filter((n) => n.conversionId === CONVERSION_ID)).toHaveLength(0); + expect(stack).toEqual(input); + }); +}); + +describe('page `requires` — the ADR-0087 ledger', () => { + it('wires the D2 conversion into the step-18 chain as a retired, stamped strip', () => { + expect(MIGRATIONS_BY_MAJOR[18]!.conversionIds).toContain(CONVERSION_ID); + const conversion = ALL_CONVERSIONS.find((c) => c.id === CONVERSION_ID); + expect(conversion, 'the D2 conversion must be registered').toBeDefined(); + expect(conversion!.toMajor).toBe(18); + expect(conversion!.retiredFromLoadPath).toBe(true); + expect(conversion!.retiredAfter).toMatch(/^\d+\.\d+\.\d+$/); + expect(conversion!.surface).toBe('page.requires'); + }); + + it('carries ONE D3 entry for the family, judging its D2 conversion', () => { + const entries = MIGRATIONS_BY_MAJOR[18]!.semantic.filter((s) => s.id === D3_ID); + expect(entries, 'the family needs its own D3 entry').toHaveLength(1); + const [entry] = entries; + expect(entry!.reason).toContain(`\`${CONVERSION_ID}\``); + expect(entry!.conversionIds).toEqual([CONVERSION_ID]); + expect(entry!.replacement).toContain('delete the key'); + expect(entry!.acceptanceCriteria.length).toBeGreaterThan(0); + }); + + it('registers no tombstone: the key stays in the walked shape, live on html pages', () => { + const all = Object.values(RETIRED_KEYS_BY_MAJOR).flat(); + expect(all).not.toContain('ui/Page:requires'); + // CONTROL: the table does carry this def's one real tombstone. + expect(all).toContain('ui/Page:assignedProfiles'); + const shape = (PageSchema as unknown as { shape: Record }).shape; + expect(Object.keys(shape)).toContain('requires'); + }); +}); diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index fb363a364c5..d450d1021df 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -645,6 +645,63 @@ export function checkPageSourceCompleteness( } } +/** + * The page kinds whose `source` the metadata save door compiles at save — + * `html` and its deprecated alias `jsx` (ADR-0080) — and therefore the only + * kinds that carry `requires`: on these the platform derives the list from the + * compiled source and stores it (ADR-0080 §5). A `react` source is executed at + * render, never compiled at save (ADR-0081), and `full` / `slotted` pages have + * no source to derive from, so nothing derives the key there. + * + * The save door's own compile gate holds the same two kinds + * (`COMPILED_PAGE_KINDS` in `metadata-protocol`'s `runtime-authoring-gate.ts`). + */ +export const COMPILED_PAGE_KINDS = ['html', 'jsx'] as const; + +/** + * The `kind` ⇄ `requires` check attached to {@link PageSchema}: `requires` is + * accepted only on a page whose `kind` is one of {@link COMPILED_PAGE_KINDS} + * (ruling record 5964312254 on #21459, letter A). On a `react`, `full` or + * `slotted` page nothing derives it — the save door never compiles their + * source, and the Studio page editor drops the key on every save — so a + * written list there was a declaration the platform never honoured. It is + * refused at `requires`, naming the key, the page's kind and the compiled + * kinds. + * + * The KEY is refused, not its contents: an empty list on a non-compiled page + * is refused like a full one, since there is nothing for it to agree with. + * + * A `kind` that is absent is the spec default, `full`, which does not carry + * the key. `PageSchema` has already applied that default when this runs; a + * `.shape` mirror without the default reaches here with `kind` absent, and the + * same answer holds. + * + * Exported for the reason {@link checkPageSourceCompleteness} is: a downstream + * mirror that derives its schema from `PageSchema.shape` drops every + * object-level check, and re-attaches this one with + * `.superRefine(checkPageRequiresKind)`. `PageSchema` attaches this same + * binding — pinned in `object-refinement-check-exports.test.ts`. + */ +export function checkPageRequiresKind( + page: { kind?: string; requires?: unknown }, + ctx: z.RefinementCtx, +): void { + if (page.requires === undefined) return; + const kind = page.kind ?? 'full'; + if ((COMPILED_PAGE_KINDS as readonly string[]).includes(kind)) return; + ctx.addIssue({ + code: 'custom', + path: ['requires'], + message: + `\`requires\` is refused on a \`kind: '${kind}'\` page` + + (kind === 'full' ? ' (`full` is also the kind of a page that omits `kind`)' : '') + + ': it exists only on the kinds whose source the platform compiles at save, `html` and its ' + + 'deprecated alias `jsx`, where it is derived from the source and stored. On a ' + + `\`${kind}\` page nothing derives it and nothing enforces it. Delete the key. ` + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', + }); +} + /** * Page Schema * Defines a composition of components for a specific context. @@ -901,16 +958,20 @@ export const PageSchema = lazySchema(() => strictObject({ .describe("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."), /** * Plugin namespaces an html page's source uses (ADR-0080 §5; ADR-0048 - * provenance). Derived from the source at save, so authors omit it. On a - * server that has the deployment's SDUI component manifest, the save door - * compiles a `kind: 'html'` page's source, refuses a written list that - * disagrees with it (a draft at its publish), and stores the derived one; - * at load, a stored page whose list names a plugin no manifest component - * carries is reported and still served. A server with no manifest judges - * neither, and says so at boot. + * provenance). Derived from the source at save, so authors omit it. The key + * exists only on the kinds whose source the save door compiles — + * {@link COMPILED_PAGE_KINDS}, `html` (alias `jsx`) — and is refused at + * parse on a `react`, `full` or `slotted` page, a page that omits `kind` + * included ({@link checkPageRequiresKind}). On a server that has the + * deployment's SDUI component manifest, the save door compiles a + * `kind: 'html'` page's source, refuses a written list that disagrees with + * it (a draft at its publish), and stores the derived one; at load, a stored + * page whose list names a plugin no manifest component carries is reported + * and still served. A server with no manifest judges neither, and says so at + * boot. */ requires: z.array(z.string()).optional() - .describe("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."), + .describe("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."), // ADR-0010 — runtime protection envelope (internal — set by the loader). // `page` is a registered metadata type, so `MetadataPlugin`'s loader stamps @@ -923,10 +984,15 @@ export const PageSchema = lazySchema(() => strictObject({ // `source` is silently inert — fail loudly at author time, never render empty. // Attached by identifier, not inlined: the export is the rule a `.shape` // mirror re-attaches (#16489), and it must be this binding, not a copy. - .superRefine(checkPageSourceCompleteness)); -// PageSchema's only cross-field rule is the ADR-0080 jsx-source completeness -// check above. It once also required `recordReview`/`blankLayout` and `slots` -// (all removed — unrendered roadmap / "required-but-unauthorable" Studio traps). + .superRefine(checkPageSourceCompleteness) + // ADR-0080 §5 (`requires`): the key exists only on the kinds whose source is + // compiled at save (#21459, ruling A). Attached by identifier for the same + // reason as the check above. + .superRefine(checkPageRequiresKind)); +// PageSchema's cross-field rules are the two `kind` checks above: the +// ADR-0080/0081 source completeness check and the compiled-kind `requires` +// check. It once also required `recordReview`/`blankLayout` and `slots` (all +// removed — unrendered roadmap / "required-but-unauthorable" Studio traps). export type Page = z.input; /** Post-parse shape of {@link Page} — defaults applied, transforms run (ADR-0122). */ From 5d2b8d45643e6322b166e8cf61c64f143a12b67c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 04:22:17 +0000 Subject: [PATCH 2/4] chore(spec): regenerate api-surface, export-origins and the page reference for the compiled-kind requires check Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude --- content/docs/references/ui/page.mdx | 2 +- packages/spec/api-surface/ui.json | 2 ++ packages/spec/export-origins/ui.json | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index 02305770f86..5d25fa4ed05 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -184,7 +184,7 @@ View filter rule | **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). | | **slots** | `{ header?: object \| object[]; actions?: object \| object[]; alerts?: object \| object[]; highlights?: object \| object[]; … }` | optional | Slot override map for slotted pages | | **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. | -| **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. | +| **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. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | | **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index b1663817a1f..5287cdfa7db 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -73,6 +73,7 @@ "BulkActionParamSchema (const)", "CHART_AGGREGATE_COMPARISON_SUFFIX (const)", "COLUMN_SUMMARY_AGGREGATION (const)", + "COMPILED_PAGE_KINDS (const)", "CalendarConfig (type)", "CalendarConfigSchema (const)", "ChartAggregate (type)", @@ -483,6 +484,7 @@ "checkDashboardWidgetStageOrder (function)", "checkGlobalFilterDateDefaultValue (function)", "checkListViewCalendarVisualization (function)", + "checkPageRequiresKind (function)", "checkPageSourceCompleteness (function)", "columnSummaryAlias (function)", "compileListViewGroupQuery (function)", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index b1e99c14ff2..961a0dfc59c 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -70,6 +70,7 @@ "BulkActionParamSchema": "src/ui/bulk-action.zod.ts#BulkActionParamSchema (const)", "CHART_AGGREGATE_COMPARISON_SUFFIX": "src/ui/chart-aggregate.ts#CHART_AGGREGATE_COMPARISON_SUFFIX (const)", "COLUMN_SUMMARY_AGGREGATION": "src/ui/view-grouping-query.ts#COLUMN_SUMMARY_AGGREGATION (const)", + "COMPILED_PAGE_KINDS": "src/ui/page.zod.ts#COMPILED_PAGE_KINDS (const)", "CalendarConfig": "src/ui/view.zod.ts#CalendarConfig (type)", "CalendarConfigSchema": "src/ui/view.zod.ts#CalendarConfigSchema (const)", "ChartAggregate": "src/ui/chart.zod.ts#ChartAggregate (type)", @@ -468,6 +469,7 @@ "checkDashboardWidgetStageOrder": "src/ui/dashboard.zod.ts#checkDashboardWidgetStageOrder (function)", "checkGlobalFilterDateDefaultValue": "src/ui/dashboard.zod.ts#checkGlobalFilterDateDefaultValue (function)", "checkListViewCalendarVisualization": "src/ui/view.zod.ts#checkListViewCalendarVisualization (function)", + "checkPageRequiresKind": "src/ui/page.zod.ts#checkPageRequiresKind (function)", "checkPageSourceCompleteness": "src/ui/page.zod.ts#checkPageSourceCompleteness (function)", "columnSummaryAlias": "src/ui/view-grouping-query.ts#columnSummaryAlias (function)", "compileListViewGroupQuery": "src/ui/view-grouping-query.ts#compileListViewGroupQuery (function)", From 8c2513b62f6a5f0f4e15c8e41d397a4ccadcb7a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 04:29:07 +0000 Subject: [PATCH 3/4] test(metadata-protocol): pin the save-door refusal and the stored-row conversion of page requires on non-compiled kinds; add the changeset Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude --- .../21459-page-requires-compiled-kinds.md | 35 +++++++++++ .../protocol.runtime-authoring-gate.test.ts | 58 +++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 .changeset/21459-page-requires-compiled-kinds.md diff --git a/.changeset/21459-page-requires-compiled-kinds.md b/.changeset/21459-page-requires-compiled-kinds.md new file mode 100644 index 00000000000..0f6d5e7c3bf --- /dev/null +++ b/.changeset/21459-page-requires-compiled-kinds.md @@ -0,0 +1,35 @@ +--- +'@objectstack/spec': minor +--- + +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. + +Clause-②: yes (narrowing) + + + +**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings. + +**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. + +**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`). + +**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. + +## FROM → TO + +| you wrote | write instead | +|:--|:--| +| `requires: [...]` on a `kind: 'react'` page | nothing: delete the key. Nothing derived or enforced it | +| `requires: [...]` on a `kind: 'full'` or `kind: 'slotted'` page, or on a page with no `kind` | nothing: delete the key | +| `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 | + +**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. + +**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. + +### The retirement kit + +- **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. +- **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`. +- **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. diff --git a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts index 3603b0982c6..c63e35b4825 100644 --- a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts +++ b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts @@ -1224,6 +1224,35 @@ describe('html page source compiled at the save door against the SDUI manifest ( .resolves.toMatchObject({ success: true }); }); + // [#21459] `requires` exists only on the kinds this door compiles. On any + // other kind the spec parse refuses it before the compile runs, so the + // refusal is the spec's own issue at `requires` — not a compile finding. + it.each(['react', 'full', 'slotted', undefined])( + 'refuses `requires` on a `%s` page with a 422 at `requires`, and persists nothing (#21459)', + async (kind) => { + const { protocol, rows } = hostWith(new Map([['sdui-manifest', manifest()]])); + const page = { + name: 'landing', label: 'Landing', requires: ['ui'], + ...(kind === undefined ? {} : { kind }), + ...(kind === 'react' ? { source: KNOWN } : {}), + }; + const err = await savePage(protocol, page).catch((e: any) => e); + expect(refusal(err)).toEqual({ code: 'INVALID_METADATA', status: 422 }); + const issues = err.issues.filter((i: any) => i.path === 'requires'); + expect(issues, JSON.stringify(err.issues)).toHaveLength(1); + expect(issues[0].code).toBe('custom'); + expect(issues[0].message).toContain(`\`kind: '${kind ?? 'full'}'\``); + expect(err.issues.some((i: any) => String(i.rule ?? '').startsWith('jsx-'))).toBe(false); + expect(pageRows(rows)).toEqual([]); + }, + ); + + it('CONTROL: the same `requires` on an html page still saves, and the compile stamps it (#21459)', async () => { + const { protocol, rows } = hostWith(new Map([['sdui-manifest', manifest()]])); + await expect(savePage(protocol, htmlPage(KNOWN, { requires: ['ui'] }))).resolves.toMatchObject({ success: true }); + expect(storedPage(rows)?.requires).toEqual(['ui']); + }); + it('a registered value that is not a manifest is warned about once and compiled against never', async () => { const { protocol, rows } = hostWith(new Map([['sdui-manifest', { oops: true }]])); 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)', expect(loadReports(warn)).toEqual([]); }); + // [#21459] A row stored before `requires` was narrowed to the compiled + // kinds: a react page carrying the key, which the save door now refuses, so + // it is seeded straight into the store. The stored-row seam replays the D2 + // conversion `page-requires-non-compiled-kind-removed` before anything reads + // the body — so the row loads with the key gone and a notice saying so, the + // spec check finds nothing to report, and the load report never sees a list. + it('at load, a stored react page carrying `requires` is read without it — converted, not reported or badged (#21459)', async () => { + const services = new Map([['sdui-manifest', manifest(false)]]); + const { protocol, rows, registered } = hostWith(services); + const body = { name: 'workbench', label: 'Workbench', kind: 'react', source: '', requires: ['plugin-kanban'] }; + rows.set(keyOf({ type: 'page', name: 'workbench', organization_id: null, state: 'active' }), { + id: 'r_seed', type: 'page', name: 'workbench', organization_id: null, state: 'active', metadata: JSON.stringify(body), + }); + + const result = await protocol.loadMetaFromDb(); + + expect(result).toMatchObject({ loaded: 1, errors: 0, invalid: 0 }); + expect(registered).toContainEqual({ type: 'page', name: 'workbench' }); + const lines = (warn.mock.calls as unknown[][]).map((c) => String(c[0])); + const converted = lines.filter((m) => m.includes('stored page/workbench carries a pre-protocol shape')); + expect(converted, JSON.stringify(lines)).toHaveLength(1); + expect(converted[0]).toContain("ADR-0087 conversion 'page-requires-non-compiled-kind-removed'"); + expect(converted[0]).toContain('page.requires at pages[0].requires'); + // The manifest carries no `plugin-kanban`, so an unconverted list WOULD + // have been reported — its absence is the conversion's doing. + expect(loadReports(warn)).toEqual([]); + expect(lines.filter((m) => m.includes('[metadata_spec_invalid]'))).toEqual([]); + }); + // ── At draft → active promotion ────────────────────────────────────── it('a draft saved before the manifest arrived is promoted with the `requires` the save door computes', async () => { From d7cd7975495c56bebfafe4ba1b867c4c2c61bf3f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 04:33:38 +0000 Subject: [PATCH 4/4] chore(spec): regenerate the generated artifacts on the merged tree; order the requires rationale fragment after the ai:chat_window one that landed first Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude --- content/docs/references/ui/page.mdx | 5 ++--- packages/spec/api-surface/ui.json | 1 - packages/spec/export-origins/ui.json | 1 - packages/spec/src/migrations/registry.ts | 2 +- 4 files changed, 3 insertions(+), 6 deletions(-) diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index 5d25fa4ed05..bf8f3ae5a9d 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -246,7 +246,7 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **type** | `Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| 'page:card' \| 'page:section' \| 'record:details' \| 'record:highlights' \| 'record:related_list' \| 'record:activity' \| 'record:chatter' \| 'record:discussion' \| 'record:path' \| 'record:alert' \| 'record:quick_actions' \| 'record:reference_rail' \| 'record:history' \| 'app:launcher' \| 'nav:menu' \| 'nav:breadcrumb' \| 'global:search' \| 'global:notifications' \| 'ai:chat_window' \| 'ai:suggestion' \| 'element:text' \| 'element:number' \| 'element:image' \| 'element:divider' \| 'element:button' \| 'element:record_picker' \| 'element:text_input'> \| string` | ✅ | Component Type — a standard vocabulary member, or a custom/registered component type in its own namespace (e.g. `object-grid`, `mcp:connect-agent`). The spec's own type namespaces are a closed vocabulary at author time: inside them, a type the vocabulary does not declare is refused by `os validate` / `os build` / `os lint` (rule `component-type-unknown`); a type the vocabulary RETIRED by name (`user:profile` — shell chrome, not author-placeable; `element:filter` and `element:form` — retired whole, no renderer for either ever shipped) is refused at the parse itself, with the retirement prescription. | +| **type** | `Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| 'page:card' \| 'page:section' \| 'record:details' \| 'record:highlights' \| 'record:related_list' \| 'record:activity' \| 'record:chatter' \| 'record:discussion' \| 'record:path' \| 'record:alert' \| 'record:quick_actions' \| 'record:reference_rail' \| 'record:history' \| 'app:launcher' \| 'nav:menu' \| 'nav:breadcrumb' \| 'global:search' \| 'global:notifications' \| 'ai:suggestion' \| 'element:text' \| 'element:number' \| 'element:image' \| 'element:divider' \| 'element:button' \| 'element:record_picker' \| 'element:text_input'> \| string` | ✅ | Component Type — a standard vocabulary member, or a custom/registered component type in its own namespace (e.g. `object-grid`, `mcp:connect-agent`). The spec's own type namespaces are a closed vocabulary at author time: inside them, a type the vocabulary does not declare is refused by `os validate` / `os build` / `os lint` (rule `component-type-unknown`); a type the vocabulary RETIRED by name (`user:profile` — shell chrome, not author-placeable; `ai:chat_window` — no renderer by design, the floating chat overlay is the AI chat entry point; `element:filter` and `element:form` — retired whole, no renderer for either ever shipped) is refused at the parse itself, with the retirement prescription. | | **id** | `string` | optional | Unique instance ID | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **properties** | `Record` | optional (default: `{}`) | Component props passed to the widget. See component.zod.ts for schemas. | @@ -317,7 +317,6 @@ View filter rule * `nav:breadcrumb` * `global:search` * `global:notifications` -* `ai:chat_window` * `ai:suggestion` * `element:text` * `element:number` @@ -344,7 +343,7 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **type** | `Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| …> \| string` | ✅ | Component Type — a standard vocabulary member, or a custom/registered component type in its own namespace (e.g. `object-grid`, `mcp:connect-agent`). The spec's own type namespaces are a closed vocabulary at author time: inside them, a type the vocabulary does not declare is refused by `os validate` / `os build` / `os lint` (rule `component-type-unknown`); a type the vocabulary RETIRED by name (`user:profile` — shell chrome, not author-placeable; `element:filter` and `element:form` — retired whole, no renderer for either ever shipped) is refused at the parse itself, with the retirement prescription. | +| **type** | `Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| …> \| string` | ✅ | Component Type — a standard vocabulary member, or a custom/registered component type in its own namespace (e.g. `object-grid`, `mcp:connect-agent`). The spec's own type namespaces are a closed vocabulary at author time: inside them, a type the vocabulary does not declare is refused by `os validate` / `os build` / `os lint` (rule `component-type-unknown`); a type the vocabulary RETIRED by name (`user:profile` — shell chrome, not author-placeable; `ai:chat_window` — no renderer by design, the floating chat overlay is the AI chat entry point; `element:filter` and `element:form` — retired whole, no renderer for either ever shipped) is refused at the parse itself, with the retirement prescription. | | **id** | `string` | optional | Unique instance ID | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **properties** | `Record` | optional (default: `{}`) | Component props passed to the widget. See component.zod.ts for schemas. | diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index 5287cdfa7db..08f63711feb 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -4,7 +4,6 @@ "exports": [ "ACTION_LOCATIONS (const)", "ACTION_PARAM_BUILTIN_KEYS (const)", - "AIChatWindowProps (const)", "ASSEMBLED_VIEW_ITEMS_KEY (const)", "Action (const)", "Action (type)", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index 961a0dfc59c..366d453f16f 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -4,7 +4,6 @@ "exports": { "ACTION_LOCATIONS": "src/ui/action.zod.ts#ACTION_LOCATIONS (const)", "ACTION_PARAM_BUILTIN_KEYS": "src/ui/action-params.zod.ts#ACTION_PARAM_BUILTIN_KEYS (const)", - "AIChatWindowProps": "src/ui/component.zod.ts#AIChatWindowProps (const)", "ASSEMBLED_VIEW_ITEMS_KEY": "src/ui/assembled-views.zod.ts#ASSEMBLED_VIEW_ITEMS_KEY (const)", "Action": "src/ui/action.zod.ts#Action (type)", "ActionAi": "src/ui/action.zod.ts#ActionAi (type)", diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 9ef2a6d172a..2e4e632ea66 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5906,7 +5906,7 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ }, { id: 'page-requires-non-compiled-kind-refused', - order: 65, + order: 66, text: 'It also narrows page `requires` to the kinds whose source is compiled at save (ADR-0080 §5; ' + 'maintainer ruling 2026-10-03, letter A): the plugin-namespace list is derived from an html '