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
14 changes: 14 additions & 0 deletions .changeset/20871-page-requires-live.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/spec': patch
'@objectstack/lint': patch
---

`page.requires` says what the runtime now does with it: refused at save, reported at load (ADR-0080 §5).

Clause-②: no

The key's description used to say the list is "validated at save and load" while the liveness ledger recorded it as not enforced yet. Both are now true and say so. On a server that has the deployment's SDUI component manifest, saving a `kind: 'html'` page compiles its source, refuses a written `requires` that disagrees with it (`422 INVALID_METADATA`, `page-requires-disagrees-with-source`; a draft at its publish) and stores the derived list. At load, a stored page whose list names a plugin no manifest component carries is reported and still served. A server with no manifest checks neither and says so once at boot. Omit `requires`: it is derived from the source. The liveness row moves from `planned` to `live`, and the generated page reference carries the new description.

`validateJsxPages`' reason for staying off the runtime publish gate no longer says it parses through `typescript`/`sucrase`. It parses with the dependency-free `@objectstack/sdui-parser`, and it stays CLI-only because the save door already runs that compiler on every html page. The `ui-html-page-div-refused` upgrade-guide entry now names that save door too: on a server with a manifest, a `div` page saved from Studio or through the metadata API is refused under the same rule ids.

No schema accepts or refuses anything it did not before, and no runtime behaviour changes.
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 JSX source references (validated at save and load) |
| **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. |
| **_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
28 changes: 26 additions & 2 deletions packages/lint/src/authoring-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,16 +441,40 @@ const RUNTIME_NEEDS_FULL_SNAPSHOT =
'now would report the rest of the tenant\'s metadata as missing rather than judging this write.';

/**
* The rule parses authored SOURCE (react/jsx page bodies, L2 JS hook/action
* The rule parses authored SOURCE (react page bodies, L2 JS hook/action
* bodies) through `typescript` / `sucrase`. Those are exactly the dependencies
* `lazy-deps.test.ts` keeps off the kernel boot path, and `@objectstack/lint`'s
* runtime entry is guarded to load neither. Studio's page editor has its own
* save-time compile path; this gate is not where that check belongs.
*
* The html tier's rule (`validateJsxPages`) is NOT this case — see
* {@link RUNTIME_HTML_SOURCE_COMPILED_AT_SAVE}.
*/
const RUNTIME_HEAVY_SOURCE_PARSE =
'Not runtime-safe: parses authored source through typescript/sucrase, the two dependencies the ' +
'kernel boot path must never load (lazy-deps.test.ts). Studio compiles page source on its own path.';

/**
* `validateJsxPages` parses an html page's source with `@objectstack/sdui-parser`
* — no dependencies, never executes the source — so nothing about it is unsafe
* on the kernel boot path. It stays off this registry's runtime surface because
* the save door already runs the same compile itself: `findHtmlPageSourceGaps`
* in `@objectstack/metadata-protocol`'s `runtime-authoring-gate.ts` imports the
* same `compile()` and runs it against the deployment's SDUI component manifest,
* reports under the same `jsx-CODE` rule ids, and adds the page's `requires`
* check (`page-requires-disagrees-with-source`). Wiring this entry there too
* would judge every html page twice.
*
* The two differ in one case: with no manifest this rule still checks syntax
* and structure, while a host that registered no manifest has its save door
* judge nothing and says so once at boot.
*/
const RUNTIME_HTML_SOURCE_COMPILED_AT_SAVE =
'Runtime-safe (the dependency-free @objectstack/sdui-parser, which never executes the source) but ' +
'not wired here: the save door already compiles an html page\'s source itself, with the same ' +
'compiler against the deployment\'s SDUI component manifest and under the same jsx-* rule ids ' +
'(metadata-protocol\'s findHtmlPageSourceGaps), so a second run would judge each page twice.';

/**
* The rule judges an OBJECT/field declaration at `advisory` tier — it can
* never refuse a write (`tier: 'advisory'` means it never emits `error`, and
Expand Down Expand Up @@ -1111,7 +1135,7 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
commands: ALL,
source: 'packages/lint/src/validate-jsx-pages.ts',
surfaces: CLI_ONLY,
surfaceReason: RUNTIME_HEAVY_SOURCE_PARSE,
surfaceReason: RUNTIME_HTML_SOURCE_COMPILED_AT_SAVE,
run: (stack, ctx) =>
validateJsxPages(stack, ctx.sduiManifest ? { manifest: ctx.sduiManifest as never } : {}),
},
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/liveness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -932,7 +932,7 @@ marker where the Notes cell goes, never a guess at what belongs there.
| tool | the inert authoring surface is now REMOVED, not merely marked: `category`/`permissions`/`active`/`builtIn` retired 2026-07-30 (#3896 close-out) after `requiresConfirmation` set the precedent (#3715, ADR-0033 §2). `permissions` promised an invocation gate nothing enforced and `active:false` withdrew nothing — false compliance, same shape as rls.enabled. The `.strict()` ToolSchema rejects each retired key with its prescription; the `tool-inert-authoring-keys-removed` conversion strips them from authored sources |
| skill | `permissions` REMOVED 2026-07 (#3704); `triggerPhrases` REMOVED 2026-07-30 (#3896 close-out sweep — phrases were never matched; activation is `triggerConditions` + the agent's `skills[]` + /skill-name pinning) |
| dataset | `measures.certified` (declared-but-unenforced governance flag) REMOVED in 16.0 (#2377) |
| page | live + one planned; dead `assignedProfiles` REMOVED 2026-09-12 (ADR-0090 D2 + ADR-0049 — a per-page audience list named for the concept D2 deleted, with zero readers in either repo, so the page was open to everyone who could reach it). The row stays because `retiredKey` keeps the key in the walked shape (the `rls.priority` precedent). Its prior `live` verdict is the #12516 class twice over: the objectui bridge it cited never existed (lit control — two sibling objectui citations in the same file resolve), and the entry carried no `verifiedAt`, so nothing ever re-asked |
| page | live, plus the one dead tombstone below. Its one `planned` row, `requires`, flipped `live` 2026-10-02 (#20871): the save door refuses an html page whose written list disagrees with its source and stores the derived one, and boot hydration reports a stored page whose list names a plugin the deployment's SDUI manifest does not carry. Dead `assignedProfiles` REMOVED 2026-09-12 (ADR-0090 D2 + ADR-0049 — a per-page audience list named for the concept D2 deleted, with zero readers in either repo, so the page was open to everyone who could reach it). The row stays because `retiredKey` keeps the key in the walked shape (the `rls.priority` precedent). Its prior `live` verdict is the #12516 class twice over: the objectui bridge it cited never existed (lit control — two sibling objectui citations in the same file resolve), and the entry carried no `verifiedAt`, so nothing ever re-asked |
| view | list/form drilled via `children` (#2998 Track B); list.{responsive,performance} + form.{defaultSort,aria} REMOVED 2026-07-30 (#3896 close-out sweep — list aria/data stay live); **form.data was that sweep's one CORRECTION** — the removal attempt broke the build (`defineForm` writes `data.provider='schema'` onto every metadata form, `metadata-protocol` serves it), so it stands `live` with re-verified evidence; form.{buttons,defaults} live (framework#1894 / #2998); audit-era DEAD lines superseded by re-verification. **The dead set is six, not the four removals above**: #4534 (the last #4001 batch, batch 6e) declared three CONTAINER-level keys this row had never classified — `name` and `label`, both `dead`, and `object`, `live`. All three are properties of the `views: [...]` *container*, not of a view: `name` is dead because authoring it changes nothing — an authored value restates the key the container already registers under or contradicts it — and `label` is container display metadata with no reader. Neither is `authorWarn`'d and both are deliberately KEPT — the metadata door itself stamps the save name into every saved view body (`normalizeViewMetadata`) and its overlay paths key on that copy, so tombstoning `name` would reject the platform's own saves (re-measured 2026-10-02, #20301 stage 2: the earlier attribution to artifact-shipped containers and the metadata-validation sweep was the door's stamp misread; neither carries one). `object` is the container's object binding, and it was *stripped on every parse* until #4534 declared it. Separately, the level-2 dead residue (userActions.buttons, addRecord.mode/formView, tabs[].order) is noted on parents and is **not** in the counts — one drill level only **#9340**: `list.map` declared — the eighth visualization block (`ListMapConfigSchema`), keys mirroring objectui plugin-map's documented read set. FLIPPED `planned` → `live` 2026-08-24 (#11442): objectui#5908 landed `resolveListMapConfig`, which merges the view-level `map` block over the legacy `options.map` bag before `ListView.tsx`'s `case 'map'` forwards it into `ObjectMap`, with the same merged config also feeding the visualization-switcher's capability gate so a view binding coordinates only in the spec block is no longer filtered out of `allowedVisualizations` either (objectui#5042) |
| report | dataset-bound (ADR-0021); the aria/performance LEDGER entries were stale — the keys left the schema in the report-liveness close-out; deleted 2026-07-30 as hygiene. Audit-era `chart` DEAD superseded (framework#1890 / #3441) — live on non-joined reports only: a `joined` report's container `chart` is refused and `blocks[].chart` was removed (#20161, 2026-09-27; nothing drew either) |
| dashboard | ADR-0021 dataset widgets (#3251; DashboardWidgetSchema `.strict()`); `aria`/`performance` (and widget `performance` + PerformanceConfigSchema) REMOVED 2026-07-30 (#3896 close-out sweep — no renderer applied any of them); audit-era `globalFilters`/`dateRange` DEAD superseded (framework#2501) **#4956**: `widgets` DRILLED — the row jumps 20 → 41 classified because all 22 widget-level keys enter the count at once. They had never been classified at all: the entry carried one blanket `live` plus a `note` asserting they were classified "in the DashboardWidgetSchema subtree", and no such subtree existed in any of the 28 ledger files. That gap, not any evidence, is what carried `widgets[].responsive` through the #3896 sweep that removed both its sibling `widgets[].performance` and its literal namesake `view.responsive` — `view` is drilled, so `list.responsive` got asked and went out. New dead 6 = `responsive` (retired #4876/#4995, tombstone keeps the row) + `colorVariant` + `actionUrl`/`actionType`/`actionIcon` + `aria`. The action trio is the sharpest: no renderer draws a per-widget action button at all (every `actionUrl` read in DashboardRenderer is scoped to `header.actions[]`), yet `validate-dashboard-action-refs.ts` enforces reference integrity on it and its docblock calls it "the per-widget button" — a lint guarding an affordance that does not exist. `requiresService` is the counter-example worth remembering: dead by every objectui measurement, and LIVE server-side (`filterDashboardForUser`, ADR-0057 D10) — judging a widget key from the renderer repo alone would have retired an enforced gate. `compareTo` is `live` on ONE path only (inline object-provider charts); on the ADR-0021 dataset path the string arms are dropped and `{ offset }` throws in the executor. **#6774** moves the row 33/8 → 34/7: `colorVariant` CORRECTED dead → live 2026-08-09, the enforce leg of #5010 ruling B landing from the renderer side (objectui#3359 / PR objectui#3799, absorbed by pin `09987b68`). Worth reading beside `requiresService` above, because it is the same lesson from the other end — that row warns against judging a widget key from the renderer repo alone, and this one is a `dead` verdict that was correct in this repo AND correct in the renderer repo on the day it was measured, and stopped being either when a cross-repo decision was implemented. A ledger row is a claim with a timestamp; `verifiedAt` is what makes the claim re-askable. It also empties the dashboard warn set, so the author-side lint now says nothing about any widget key — `dashboard` stays in the lint's TYPE_COLLECTIONS all the same (the `webhook`/`email_template` resolved state). **#17385** DRILLS `widgets.chartConfig` — 14 per-key verdicts where the row had carried one blanket `live`, re-measured against `.objectui-sha` pin `53ded82bf7a4`: 12 live (the nine chrome keys `chartConfigPresentation` lowers, plus `xAxis`/`yAxis`/`series`, whose PRESENTATION merges onto the derived bindings while `ChartAxis.field` and `ChartSeries.name` are dropped so membership stays with the dataset) and dead 2 — `type`, which parses and does nothing because the widget's own `type` owns the chart family, and `aria`, which has no reader on either face. Both are pinned as NEGATIVES in objectui, which is what makes them re-askable rather than merely asserted. ⚠️ The drill made SIX containers one level further down visible for the first time (`xAxis`/`yAxis`/`series`/`annotations`/`interaction`/`aria`, 39 child keys); they are RECORDED, not drilled — fanning this row's verdicts down over them would manufacture verdicts, and the evidence work is a separate measurement. Note the cell's previous last stated position (`34/7`) had already drifted one `dead` behind the generated artifact before this change; the counts columns are generated and are the authority |
Expand Down
8 changes: 6 additions & 2 deletions packages/spec/liveness/page.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,12 @@
"note": "JSX-source page authoring (ADR-0080). Consumer: objectui PageRenderer compiles `source` via @object-ui/sdui-parser into the SchemaNode tree (parse, never execute) and renders it — components/src/renderers/layout/page.tsx (kind:'jsx' branch). Browser-verified in the Command Center showcase."
},
"requires": {
"status": "planned",
"note": "Plugin namespaces the JSX `source` references (ADR-0080). Inferred at compile time; save/load enforcement of plugin presence is deferred (M3b) — declared, not enforced yet."
"status": "live",
"verifiedAt": "2026-10-02",
"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",
"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."
},
"name": {
"status": "live",
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/liveness/state-counts/page.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them.

| Type | live | exp | elsewhere | dead | planned | classified |
|---|---|---|---|---|---|---|
| `page` | 22 | 0 | 0 | 1 | 1 | 24 |
| `page` | 23 | 0 | 0 | 1 | 0 | 24 |
Loading
Loading