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
29 changes: 29 additions & 0 deletions .changeset/23388-page-component-rules-runtime-door.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
'@objectstack/lint': minor
'@objectstack/spec': patch
---

The metadata save door runs the two page component rules that `objectstack validate`, `objectstack build` and `objectstack lint` run. A page saved through Studio, REST `/meta` or an MCP/AI author whose components carry an undeclared, wrong-typed or retired property (`component-props-unknown-key`, `component-props-invalid`), or a component `type` inside a spec-reserved namespace that the platform does not declare (`component-type-unknown`), is refused with `422 INVALID_METADATA`, and each issue carries the fix the CLI already prints. Before, the save answered `200` and the renderer ignored the property or drew a placeholder.

Clause-②: no (narrowing)

<!-- adr-0087: not-required (already-registered element-data-source-and-object-block-filter-rule-array, element-flat-data-binding-retired, element-text-variant-heading-subheading-retired, ui-action-group-menu-member-params-array-only, ui-action-group-menu-members-typed, ui-object-block-grouping-config-typed, ui-object-form-custom-fields-typed, ui-object-form-fields-names-typed, ui-object-form-members-typed, ui-object-form-sections-typed, ui-object-gantt-markers-typed, ui-object-grid-columns-typed, ui-object-grid-export-options-closed, ui-object-grid-kanban-calendar-list-members-typed, ui-object-grid-page-size-positive-integer-refused, ui-object-grid-row-members-typed, ui-object-kanban-conditional-formatting-typed, ui-object-map-gantt-tree-navigation-typed, ui-object-master-detail-form-details-closed, ui-object-metric-aggregate-trend-typed, ui-object-metric-compare-to-typed, ui-object-metric-drill-down-report-typed, ui-object-metric-drill-down-typed, ui-object-timeline-items-typed, ui-object-timeline-mapping-typed, ui-record-line-items-props-closed) Every props shape the save door now refuses was declared, and each narrowing of one recorded in its own D3 entry, before this change; the entries named here are the step's narrowings whose refusal the component-props gate carries, and this diff amends each one whose text said a stored page still saves, to say its next save meets that gate at the metadata save door. The change moves an existing rule onto one more door; it removes no key and rewrites no shape, so it adds no entry and no conversion: nothing on the load path refuses a page. -->

**BREAKING**: an accept-set narrowing on the metadata save door (`PUT /api/v1/meta/page/:name`, the draft promotion `POST /api/v1/meta/page/:name/publish`, the package publish, and every in-process `saveMetaItem` caller on an environment's authoring channel), shipped as `minor` under the launch-window convention for accept-set narrowings (`.changeset/pre.json` is open in `next` pre mode).

**The next save of a stored page whose components carry an undeclared, wrong-typed or retired property, or a reserved-namespace type the platform does not declare, is refused with the fix; stored pages keep serving until then.** The door judges writes only: a stored page row is not re-judged at boot, loads and is served as before, and a draft save is not judged until it is published.

## FROM → TO

| you saved | the door now answers | write instead |
|:--|:--|:--|
| `page:header` with `properties: { titel: 'Deals' }` | `422`, `component-props-unknown-key` at `…properties.titel`, offering `title` | `properties: { title: 'Deals' }` |
| `record:related_list` with `properties: { limit: 'five' }` | `422`, `component-props-invalid`, naming the expected type | `properties: { limit: 5 }` |
| a component typed `page:headr` | `422`, `component-type-unknown`: "Rename `page:headr` → `page:header`." | `type: 'page:header'` |
| a component typed `acme:pipeline_board` (outside the reserved namespaces) | unchanged: saved | nothing |

**The one-line fix: apply what the issue names, then save again.** If a component really does read the key, the props row in `@objectstack/spec` is what is wrong, not the page.

**Who is affected.** A deployment whose stored pages carry such a finding: those pages keep loading and serving, and only their next save is refused until the finding is fixed. `objectstack migrate meta --stored` does not list these findings. The authored pages measured when the props findings became errors (37 pages in this repository and 8 in `objectstack-ai/hotcrm`) carried none, and the in-repo page corpus carries no `component-type-unknown` finding; deployed rows were not measured. The two former ADR-0087 D2 aliases on page component properties, `page:header` `description` (write `subtitle`) and an `element:button` inline `type: 'api'` action's object `params` (write `bodyExtra`), are retired from the load path at 18 and refused at every door with the rename (`objectstack validate`, load, and this save door), so the save door and `objectstack validate` agree on them.

The `@objectstack/spec` change is text only: the reason of each D3 migration entry named above (and the matching step-18 summaries) said a stored page still saves and loads, and now says it still loads and that its next save meets the component-props gate at the metadata save door.
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
'@objectstack/spec': minor
---
Expand All @@ -15,7 +15,7 @@
- **`onSuccess`** was any value (`z.unknown()`). The runner reads only `ActionSchema`'s post-success block, `{ navigate, openIn }`, and classifies `navigate` with the url scheme set. So `onSuccess: { navigate: 'javascript:alert(1)' }` parsed clean, and so did a misspelled key or a callback shape, which the runner gives no reading at all. The rows now hold `ActionSchema.onSuccess`'s own block, the same instance by reference, so a row's verdict on any `onSuccess` value is the action's: the scheme set on `navigate`, the rename prescriptions for `url` / `redirectUrl` / `to` / `route` / `path` / `target` / `navigateTo` / `href`, and the closed `openIn` with its `'self'` default.
- **The executor key** (`actionType` on a node, `type` on a member) is a free string, and the rows judged a url `target` only when it read `'url'`. The runner also dispatches `'navigation'`, its local alias of `'url'`, to the same navigator, so `actionType: 'navigation'` with `target: 'javascript:…'` parsed clean. The url executor now has one spelling on the rows: `'navigation'` is refused at the executor key, with the rename onto `'url'`. The key otherwise stays open: a handler a host registers under another name (`navigate_create`, `navigate_edit`, …) is still dispatched by name.

A page's `properties` bag is outside the stack parse. `os validate` judges it through its props gate, which parses the bag against these same rows, so it reports each refusal at the row's own path with the same text, as a `component-props-invalid` finding (an error, like every finding of that gate). A stored page still saves and loads.
A page's `properties` bag is outside the stack parse. `os validate` judges it through its props gate, which parses the bag against these same rows, so it reports each refusal at the row's own path with the same text, as a `component-props-invalid` finding (an error, like every finding of that gate). A stored page still loads; the metadata save door runs the same gate, so the next save of a page carrying a refused row is refused with the same text.

### FROM → TO

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
'@objectstack/lint': major
'@objectstack/spec': patch
Expand All @@ -16,7 +16,7 @@
- `component-props-unknown-key`: a key the component's props row does not declare, at the bag's own level, on a strict object or union arm below it, or on a strip-mode object below it.
- `component-props-invalid`: a value the props row rejects (a wrong type, a missing required prop, a closed-set refusal such as a url action's `target` scheme or a `record:details` section's `count`, a retired enum value), and a missing `dataSource.object` on `element:record_picker`, `element:number` and `element:repeater`.

**Not judged here.** The runtime publish door (the Studio, REST `/meta`, MCP) does not run this rule, so a page saved there is not refused for any of these. `defineStack` and the stack schema keep the open `properties` bag as written. A component `type` that `ComponentPropsMap` carries no row for (a `custom.*` or plugin type) is not judged.
**Not judged here.** `defineStack` and the stack schema keep the open `properties` bag as written. A component `type` that `ComponentPropsMap` carries no row for (a `custom.*` or plugin type) is not judged.

`ComponentPropsSeverity`, the type `@objectstack/lint` exports for these findings, narrows from `'error' | 'warning'` to `'error'`.

Expand Down
2 changes: 1 addition & 1 deletion content/docs/deployment/validating-metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -470,7 +470,7 @@ orthogonal to both, and no cell here can carry it; it is written out in
| Chart bindings outside dashboards (#3583) | ✓ | ✓ | ✓ | — |
| Navigation vs. granted access (ADR-0090 D6) | ✓ | ✓ | ✓ | — |
| SDUI scoped styling (ADR-0065) | ✓ | ✓ | ✓ | — |
| SDUI component props — every `ComponentPropsMap` verdict: an undeclared prop, a value its props schema rejects, a missing `dataSource.object` binding, and a retired prop (`retiredKey()`) refused with its prescription (#23026, #23147) | ✓ | ✓ | ✓ | — |
| SDUI component props — every `ComponentPropsMap` verdict: an undeclared prop, a value its props schema rejects, a missing `dataSource.object` binding, and a retired prop (`retiredKey()`) refused with its prescription (#23026, #23147; at the save door #23388) | ✓ | ✓ | ✓ | ✓ᵖ |
| JSX / React page source parses (ADR-0080/0081) | ✓ | ✓ | ✓ | — |
| Approval-node approvers (ADR-0090 D3) | ✓ | ✓ | ✓ | ✓ᶠ |
| Security posture (ADR-0090 — e.g. every custom object declares `sharingModel`) | ✓ | ✓ | ✓ | ✓ˢᵖᵉᵇᵒ |
Expand Down
71 changes: 37 additions & 34 deletions packages/lint/src/authoring-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1149,11 +1149,10 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
//
// `normalized` for a reason worth stating, since the props bag survives the
// Zod parse UNCHANGED and both tiers would otherwise carry the same data: the
// ADR-0087 conversion layer runs inside `normalizeStackInput`, so a converted
// alias (`page-header-subtitle-alias` rewrites `properties.description` →
// `subtitle`) is already canonical here and is never reported as undeclared —
// while a schema error elsewhere in the stack cannot take these findings down
// with it.
// ADR-0087 conversion layer runs inside `normalizeStackInput`, so a prop
// spelling a live D2 conversion still rewrites on the load path is already
// canonical here and is never reported as undeclared — while a schema error
// elsewhere in the stack cannot take these findings down with it.
//
// Gating: every finding is an `error`, so `os validate`, `os build` and
// `os lint` refuse an undeclared prop, a value the props schema rejects, a
Expand All @@ -1172,24 +1171,28 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
input: 'normalized',
commands: ALL,
source: 'packages/lint/src/validate-component-props.ts',
surfaces: CLI_ONLY,
// On the runtime door by the ruling of record on #23388 (record
// 6161173492, letter B), which replaced, for this rule and
// `validateComponentTypes`, the #4716 discipline both entries were held
// on (a gating rule reaches the Studio/REST/MCP door only behind a
// measured false-refusal budget over stored tenant page rows — a
// discipline a dispatch seat set, not a maintainer ruling). The ruling's
// principle: a page with a finding is fixed, then saved. No stored-row
// replay came first, by that ruling.
//
// Page-local, MEASURED (#23147): the rule reads `stack.pages` and nothing
// else — each page's components through `walkPageComponents`, judged
// against the static `ComponentPropsMap` — and a `page` write's per-write
// snapshot (`runtime-gate.ts`) carries the written page as `pages[0]`. Over
// the showcase's 28 pages, the rule's findings through that snapshot were
// identical to the whole-stack run's findings on the same page, as
// authored (0 = 0) and with an undeclared key and a wrong-typed prop
// planted (19 = 19). So RUNTIME_NEEDS_FULL_SNAPSHOT, which this entry
// carried until then, was false here. What holds the rule off the runtime
// door is the `validateComponentTypes` discipline: a measured false-refusal
// budget over STORED tenant page rows, which no population measured so far
// is. Crossing is its own rollout decision.
surfaceReason:
'Gating rule held off the runtime door pending the crossing discipline the gating object rules ' +
'went through: a measured false-refusal budget, here over stored tenant page rows. The rule is ' +
'page-local and a page write\'s snapshot carries everything it reads; the 0-finding inventories ' +
'cover authored config-file metadata only. Crossing is its own rollout card.',
// planted (19 = 19). So the crossing is this `surfaces`/`runtimeTypes`
// pair and nothing else. Stored pages keep loading and serving: the gate
// judges writes, never the read path, so only the next save of a page
// with a finding is refused, with the fix the finding carries.
surfaces: CLI_AND_RUNTIME,
runtimeTypes: ['page'],
run: (stack) => validateComponentProps(stack),
},
// The TYPE half of the same door (#12183's ruling): a component `type` inside
Expand All @@ -1204,29 +1207,29 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
input: 'normalized',
commands: ALL,
source: 'packages/lint/src/validate-component-types.ts',
surfaces: CLI_ONLY,
// Page-local judgment, so the per-write snapshot IS sufficient — what the
// crossing owes is not a wider snapshot but the #4716 discipline: a gating
// rule reaches the Studio/REST/MCP door only behind a measured
// false-refusal budget over REAL stored page rows, and the population this
// rule was measured on (the in-repo corpus, 0 findings) is authored
// config-file metadata, not tenant rows. A tenant page carrying a
// reserved-namespace type their own plugin registers would be refused at
// the only door that tenant has. Crossing is its own rollout decision with
// that replay as its evidence, not a bare `runtimeTypes` edit.
surfaceReason:
'Gating rule held off the runtime door pending the crossing discipline the gating object rules ' +
'went through: a measured false-refusal budget, here over stored tenant page rows (the in-repo ' +
'0-finding measurement covers ' +
'authored config-file metadata only). Crossing is its own rollout card.',
// On the runtime door by the same ruling of record as
// `validateComponentProps` above (#23388, record 6161173492, letter B),
// which crossed this rule in the same change: the #4716 stored-row
// false-refusal budget it was held on was seat-set, and the maintainer's
// principle — fix the page, then save it — applies unchanged. A page
// carrying a type inside a spec-reserved namespace the platform does not
// declare ships a placeholder to end users, and the refusal names the fix
// (a declared type, or a type outside the reserved namespaces). Page-local
// judgment, so the per-write snapshot IS sufficient: the crossing is this
// `surfaces`/`runtimeTypes` pair and nothing else. Stored pages keep
// loading and serving; only their next save is judged.
surfaces: CLI_AND_RUNTIME,
runtimeTypes: ['page'],
run: (stack) => validateComponentTypes(stack),
},
// #22158, card ① of the ruling of record on #8346 (letter B′): a page that
// declares `print` is a document, and every block in it must come from the
// spec's printable block subset (`PRINTABLE_PAGE_COMPONENT_TYPES`). Gating,
// and on the runtime door from birth — the rule's header says why the
// false-refusal budget `validateComponentTypes` is held on is zero here by
// construction: no page could carry `print` before this rule landed.
// and on the runtime door from birth — the rule's header says why a
// stored-row false-refusal budget was zero here by construction: no page
// could carry `print` before this rule landed. (Its siblings
// `validateComponentProps` and `validateComponentTypes` crossed later, on
// the ruling of record on #23388, record 6161173492.)
{
name: 'validatePrintPageBlocks',
tier: 'gating',
Expand Down
5 changes: 3 additions & 2 deletions packages/lint/src/validate-component-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,9 @@
* types as the lit control in the same query. Every in-tree occurrence of a
* retired name is the map itself, a tombstone prescription, conversion or
* migration registry data, a test, or prose. ⚠️ That reading covers authored
* config-file metadata only — the same scope limit the rule's `surfaceReason`
* records for stored tenant rows.
* config-file metadata only, not stored tenant rows. The rule runs on the
* runtime save door too (the ruling of record on #23388): a stored row is
* judged on its next save, never on load.
*/

import {
Expand Down
7 changes: 4 additions & 3 deletions packages/lint/src/validate-print-page-blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,10 @@
* declare before this rule landed — `PageSchema` is closed, so no stored page
* row and no authored config file could carry it. The population of print
* pages is therefore EMPTY at landing, in the repo and in every tenant's
* stored rows alike: the false-refusal budget the sibling
* `validateComponentTypes` is still waiting on (measured over stored tenant
* rows) is zero by construction here, not by sampling. And the judgment is
* stored rows alike: a false-refusal budget over stored tenant rows (the
* one the sibling `validateComponentTypes` was held on until the ruling of
* record on #23388 crossed it) is zero by construction here, not by
* sampling. And the judgment is
* page-local, so a `page` write's per-write snapshot — exactly one page, its
* own — is all it reads. Hence `surfaces: CLI_AND_RUNTIME` with
* `runtimeTypes: ['page']` in the registry: Studio, REST `/meta` and MCP
Expand Down
Loading
Loading