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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/21459-page-requires-compiled-kinds.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- adr-0087: registered page-requires-non-compiled-kind-removed, page-requires-non-compiled-kind-refused -->

**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.
2 changes: 1 addition & 1 deletion content/docs/references/ui/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -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<string, unknown>([['sdui-manifest', manifest(false)]]);
const { protocol, rows, registered } = hostWith(services);
const body = { name: 'workbench', label: 'Workbench', kind: 'react', source: '<Workbench />', 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 () => {
Expand Down
2 changes: 2 additions & 0 deletions packages/spec/api-surface/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
"BulkActionParamSchema (const)",
"CHART_AGGREGATE_COMPARISON_SUFFIX (const)",
"COLUMN_SUMMARY_AGGREGATION (const)",
"COMPILED_PAGE_KINDS (const)",
"CalendarConfig (type)",
"CalendarConfigSchema (const)",
"ChartAggregate (type)",
Expand Down Expand Up @@ -482,6 +483,7 @@
"checkDashboardWidgetStageOrder (function)",
"checkGlobalFilterDateDefaultValue (function)",
"checkListViewCalendarVisualization (function)",
"checkPageRequiresKind (function)",
"checkPageSourceCompleteness (function)",
"columnSummaryAlias (function)",
"compileListViewGroupQuery (function)",
Expand Down
2 changes: 2 additions & 0 deletions packages/spec/export-origins/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,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)",
Expand Down Expand Up @@ -467,6 +468,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)",
Expand Down
6 changes: 3 additions & 3 deletions packages/spec/liveness/page.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading