Skip to content
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 (that gate's one tier, a warning). 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 saves and loads.

### FROM → TO

Expand Down
36 changes: 36 additions & 0 deletions .changeset/23147-component-props-every-finding-refused.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
'@objectstack/lint': major
'@objectstack/spec': patch
---

Every finding of the component-props gate is now an error at `objectstack validate`, `objectstack build` and `objectstack lint`. A page component whose `properties` carry a key its `ComponentPropsMap` row does not declare, a value that row rejects, or no `dataSource.object` binding where the element reads only that binding drew a warning: the command printed "✓ Validation passed" and built the artifact, and the renderer ignored the key or drew nothing. Each is now a `component-props-unknown-key` or `component-props-invalid` error, and the stack does not pass until it is fixed. A retired prop was already refused, and still is.

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 this refuses on 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 gate carries, and this diff amends the reason text of each one that called that refusal advisory, to say it is now an error. It moves only the severity of the lint findings, so it adds no entry, and no conversion: nothing on the load path refuses a page's `properties`. -->

**BREAKING**: an accept-set narrowing on a published authoring surface (the `component-props-invalid` and `component-props-unknown-key` rules of `objectstack validate`, `objectstack build` and `objectstack lint`).

**What is refused now.** Every verdict of the gate:

- `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.

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

## FROM → TO

| you wrote | write instead |
|:--|:--|
| `page:header` `properties: { titel: 'Deals' }` | `properties: { title: 'Deals' }`, the declared spelling the error names |
| `record:related_list` `properties: { limit: 'five' }` | `properties: { limit: 5 }`, the type the error names |
| `element:number` with no `dataSource` | `dataSource: { object: 'deal' }` on the component node, beside `type` |
| a key the component does not read | nothing: delete the key |

**The one-line fix: apply what the error names.** An unknown-key error names the declared spelling when one is close, a value error names what the row expects, and the binding error names where the object goes. If the component really does read the key or value, the props row is what is wrong: correct the declaration in `@objectstack/spec`, not the page.

The `@objectstack/spec` change is text only: the reason of each D3 migration entry named above said the gate reported its refusal as an advisory finding, and now says it is an error.

**Who is affected, measured.** At objectstack `8323cad97`, this rule ran at its own input tier over `examples/app-crm`, `app-todo`, `app-multi-package` and `app-showcase`, and over the eight shipped pages outside them (`@objectstack/platform-objects` ×3, `@objectstack/plugin-approvals`, `packages/qa/downstream-contract`, `@objectstack/cloud-connection` ×2, `@objectstack/mcp`): 37 pages, 177 components judged. It also ran over `objectstack-ai/hotcrm` at `0f84c9c72`: 8 pages, 50 components judged. Result: 0 findings of every class in both. A planted undeclared key and a planted wrong-typed prop each drew one finding on every population. The `create-objectstack` template authors no page. Deployed metadata and other repositories were not measured.
4 changes: 2 additions & 2 deletions 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) | ✓ | ✓ | ✓ | — |
| Retired SDUI component props — a key `ComponentPropsMap` tombstones (`retiredKey()`), refused with its prescription (#23026) | ✓ | ✓ | ✓ | — |
| 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) | ✓ | ✓ | ✓ | — |
| 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 All @@ -485,7 +485,7 @@ orthogonal to both, and no cell here can carry it; it is written out in
| Flow authoring anti-patterns (#1874) | ✓ | ✓ | ✓ | ✓ᶠ |
| Flow trigger readiness — a flow that looks armed and never launches (#5762) | ✓ | ✓ | ✓ | ✓ᶠ |
| `views[]` conditional-visibility predicates — CEL syntax, parse budget, bare identifiers, binding-root layer, schema path refs (ADR-0089 D3b, #7010) | ✓ | ✓ | ✓ | ✓ᵛ |
| Advisory: record titles, semantic field pointers (ADR-0085), form-section layout, action placement, SDUI component props (every finding but a retired prop), seed replay/state safety, capability references, liveness | ✓ | ✓ | ✓ | — |
| Advisory: record titles, semantic field pointers (ADR-0085), form-section layout, action placement, seed replay/state safety, capability references, liveness | ✓ | ✓ | ✓ | — |
| Package docs — flatness, prefixes, links (ADR-0046) | ✓ | ✓ | ✓ | — |
| Undeclared authoring keys — every metadata collection (#3786) and the stack's own top-level keys (#4167) | ✓ | ✓ | — | — |
| Declared-unique scope — a bare `unique: true` index, a field-level and index-level double declaration, legacy organization composites (ADR-0120 D5) | ✓ | ✓ | ✓ | — |
Expand Down
6 changes: 3 additions & 3 deletions content/docs/protocol/objectui/layout-dsl.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -740,9 +740,9 @@ properties:
`properties` is an open bag (`Record<string, unknown>`), so **it is the one part
of a component the spec parse does not check**. For the platform's own types the
authoring rules (`os validate`, `os build`, `os lint`) dispatch `ComponentPropsMap`:
a misspelled prop is reported as a warning, and a retired prop is refused as an
error, with its prescription. A `custom.*` type has no entry there, so its props
are validated only by whatever registered the component.
a misspelled prop or a value its schema rejects is refused as an error, and so is
a retired prop, with its prescription. A `custom.*` type has no entry there, so
its props are validated only by whatever registered the component.
</Callout>

## Advanced Layouts
Expand Down
38 changes: 28 additions & 10 deletions packages/lint/src/authoring-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1155,23 +1155,41 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
// while a schema error elsewhere in the stack cannot take these findings down
// with it.
//
// Gating since #23026, for ONE class: a `retiredKey()` tombstone's verdict is
// an `error`, so `os validate`, `os build` and `os lint` refuse a retired
// prop with its prescription. Every other finding stays a `warning`. #5068's
// first step was advisory on purpose — wiring the parse is the precondition
// for enforcement, not the enforcement (#5020, one surface over) — and the
// error upgrade was to be its own step once the warning-period inventory was
// empty. Its blockers (#5728, #5775, #6776's undeclared props, two page
// rewrites) are all closed, and the inventory measured 0. The rule's module
// header records the measurement and why the other classes stay advisory.
// 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
// missing `dataSource.object` binding and a retired prop (with its
// prescription). #5068's first step was advisory on purpose — wiring the
// parse is the precondition for enforcement, not the enforcement (#5020, one
// surface over) — and the error upgrade was to be its own step once the
// warning-period inventory was empty. Its blockers (#5728, #5775, #6776's
// undeclared props, two page rewrites) are all closed. #23026 moved the
// retired-key class and #23147 the rest, each on an inventory of 0 — the
// second one over a corpus authored outside this repository too. The rule's
// module header records both measurements.
{
name: 'validateComponentProps',
tier: 'gating',
input: 'normalized',
commands: ALL,
source: 'packages/lint/src/validate-component-props.ts',
surfaces: CLI_ONLY,
surfaceReason: RUNTIME_NEEDS_FULL_SNAPSHOT,
// 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.',
run: (stack) => validateComponentProps(stack),
},
// The TYPE half of the same door (#12183's ruling): a component `type` inside
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@
// whose executor key refuses `'navigation'`, the runner's alias of `'url'`.
// These pins hold that the gate reports each refusal at the row's own path,
// with the parse's own first sentence, and stays silent on the accepted
// control. The finding is a `component-props-invalid` warning, the gate's one
// tier (its module header says why); no code in this package changed for it.
// control. The finding is a `component-props-invalid` error, like every finding
// of the gate (its module header says why); no code in this package changed for
// it.
import { describe, expect, it } from 'vitest';
import { validateComponentProps, COMPONENT_PROPS_INVALID } from './validate-component-props.js';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
// executor's `target`. These pins hold that the gate reports that refusal at
// the row's own `target` path, with the parse's own first sentence, and stays
// silent on an admitted target. The finding is a `component-props-invalid`
// warning, the gate's one tier (its module header says why).
// error, like every finding of the gate (its module header says why).
import { describe, expect, it } from 'vitest';
import { validateComponentProps, COMPONENT_PROPS_INVALID } from './validate-component-props.js';

Expand Down
Loading
Loading