Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
43 changes: 43 additions & 0 deletions .changeset/19474-six-inert-runtime-create-doors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
"@objectstack/lint": minor
---

**BREAKING for runtime metadata writes** — five metadata write doors that dispatched NOTHING now dispatch the rules already written for them, and three of the five judge what walks through. `action`, `hook`, `report`, `email_template` and `mapping` each declared `allowRuntimeCreate: true` and reached ZERO author-time rules at the runtime publish gate; `action`, `hook` and `report` publishes that used to succeed can now be refused, while `email_template` and `mapping` are wired to a ledger-driven rule that warns on nothing today (#19542)

Clause-②: no (narrowing)

Each of these is a registered metadata type declaring `allowRuntimeCreate: true`, so Studio's designer, REST `/meta` item CRUD and an MCP/AI author may all mint one — and at that door nothing judged any of them. Measured on `origin/main`: no rule declared any of them in `runtimeTypes`, so the gate filtered them out before it ever consulted `TYPE_TO_STACK_KEY`. `action` and `hook` already HAD their stack-key rows, which made the two absences **consistent rather than contradictory** — the gate filters by `runtimeTypes` first — so nothing was mis-wired and CI was green, correctly. What they summed to is that a write of any of them built no per-write snapshot and ran no rule at all. An author working only through Studio or MCP has no `os lint` step to fall back on, so for them that door is the only one there is.

ADR-0049's 「声明即强制」 admits two resolutions — honour the declaration, or retire it — and the ruling on #19275 took the first for these six, by evidence group. The rules exist; this is the wiring that reaches them.

- **`validateStackExpressions` crosses to `action` and `hook`.** It judges the written action's own `visible` / `disabled` CEL and the written hook's own `condition`, resolving `record.<field>` against `objects` — the one collection every snapshot carries. The action/hook BODY rules deliberately do **not** cross with them: they parse authored JS through `typescript`/`sucrase`, the two dependencies `runtime-lazy-deps.test.ts` pins off the kernel boot path outright, and an action/hook write is exactly the snapshot that would carry a body for them to parse.
- **`validatePresetComparands` and `validateEmptyCombinators` cross to `report`, together.** Both judge the same authored filter literal on the same `reports` scan surface, so on #7220's reading they cross or they do not — an author refused for a bad preset comparand and waved through for a literal `$and: []` on the same report could not predict the door.
- **The reference-integrity suite entry gains `report`**, and its per-member axis admits exactly ONE member: `validateChartBindings` (it resolves the report's `dataset` / `rows` / `columns` / `values` against `stack.datasets`, a carried collection).
- **`lintLivenessProperties` crosses to `email_template` and `mapping` only.** The `RUNTIME_OBJECT_ADVISORY_VOLUME` reason that held it back is about the OBJECT write door (~8 advisories per object write, rendered in Studio); `object` is deliberately not declared, so that reason is untouched and still holds for every type left off.
- **`TYPE_TO_STACK_KEY` gains `report` / `email_template` / `mapping`**, never ahead of their rules — the inert state the table's own `seed: 'data'` note records paying for. Every crossed rule has a door control that fires it through the real gate (`runtime-gate.inert-type-writes.test.ts`), and each control was shown to be load-bearing by reverting its declaration and watching it go red.
- **No new rule and no new finding class.** The rule ids (`expression-invalid`, `chart-dataset-unknown`, `chart-dimension-unknown`, `filter-empty-combinator`, `filter-preset-comparand`) and their severities are unchanged — they now reach the door where the author actually is.
- **Measured before crossing**, at the door's own snapshot shape and differential, over every item of these types shipped in this monorepo: **79 actions** (showcase 70, todo 8, crm 1), **6 hooks** (showcase 4, todo 1, crm 1), **9 reports** (showcase 4, todo 5 — 5 of them carrying an authored filter key, so the filter rules were non-vacuously exercised), **1 email template** and **1 mapping** — **0 findings** on every one, with lit synthetic probes refused per rule.

## Two readings that are part of the deliverable, not omissions

**`email_template` and `mapping` are wired and SILENT.** Their bridge, `lintLivenessProperties`, is ledger-driven and skips a type whose warn map is empty; `packages/spec/liveness/email_template.json` is 13 props / **0** warn keys and `mapping.json` is 7 / **0** (lit control on the same instrument: `tool.json` 6/1, `object.json` 35/1). The ruling dispatched the wiring and **no ledger-population work** — 「the empty warn maps stay empty until a real property needs a row — zero pull, the wiring is the whole deliverable」 — so this is the ruled end state. Both halves are pinned: that the rule is dispatched, and that it judges nothing today. The day a property earns an `authorWarn` row the door lights up with no second edit.

**`skill` — the fourth type of group A — is NOT wired, and for it that IS the deliverable.** Its bridge, `validateAiToolReferences`, resolves into `stack.tools` and `stack.actions`; a per-write snapshot carries `objects` (so an object-level `action_NAME` resolves) but neither of those, so at that door the rule has no truthful `unresolved` verdict at all — only its clean answers are reliable. Measured on the shipped corpus rather than synthetically: `app-showcase`'s single AI-exposed action exists at STACK level only, and a skill naming it is advised `ai-skill-tool-unresolved` at the door while the same rule over the whole stack answers `[]`. That advisory reaches `SaveMetaItemResponseSchema.advisories` and renders in Studio, with a hint prescribing exactly what the author had already done — so the card's own acceptance («a good write passes») does not hold for `skill`. The type therefore takes the ruling's own group B treatment of `tool`, the same universe obstacle read from the other side: **a reading first, not a wiring**. Crossing it needs `actions` / `tools` carried in `RuntimeStackContext` plus a `CLOSURE_CONTEXT_KEY_BY_TYPE` row and two more door gathers in `@objectstack/metadata-protocol` — a second package, a snapshot widening paid on every gated write, and its own card. Both halves of the wiring are held ABSENT by pins, with the measurement kept executable beside them.

## The refusal set grows — and there is no FROM → TO, because nothing changed spelling

A runtime metadata write — Studio's designer, REST `/meta`, an MCP/AI author — of an `action`, `hook` or `report` that carries one of the defects below is now refused with the 422 lint envelope instead of stored. Concretely, these used to succeed at that door and no longer do:

- an action whose `visible` / `disabled` CEL does not parse, or names a field its bound object does not declare;
- a hook whose `condition` does the same;
- a report binding a dataset nothing declares, or grouping by a dimension or measure its dataset does not declare;
- a report whose filter carries a literal empty combinator (`$and: []`, `$or: []`, `$not: {}`);
- a report filtering by a dashboard date-range PRESET name (`last_30_days`, …) as if it were a value.

⚠️ **No metadata needs rewriting to a new spelling, and none is being retired.** Every one of those was ALREADY refused by `os build`, `os validate` and `os lint` — the rules, their ids, their severities and their fix-it text are unchanged since they landed. What widens is the set of doors each runs at. A tenant whose stored metadata carries one of these defects has metadata that was never valid; the refusal envelope names the rule id, the path and the offending string, and the rule's own `hint` carries the correction at the moment it is needed. There is nothing for `objectstack migrate meta` to reach and no ledger entry to make.

`skill` writes are unchanged — the type is not gated by this change. `email_template` and `mapping` writes are unchanged in behaviour today: their rule is dispatched and judges nothing until a ledger row lands.

The gate's differential keeps all of this honest in the one direction that matters: a STORED sibling already in violation is never charged to this write (#4463 D4).

<!-- adr-0087: not-required (no-migration-prescription) nothing is retired, renamed or added: no authorable key changes, no stored shape is rewritten, and `objectstack migrate meta` has nothing to reach. Every rule id, severity and fix-it text crossed here is unchanged — only the surface each runs on widens, from the three CLI commands to the runtime publish door as well. An affected tenant corrects its own metadata against a message the rule already shipped, which is tenant data rather than a spec migration. -->
116 changes: 110 additions & 6 deletions packages/lint/src/authoring-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -466,8 +466,42 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
// checks every script-node callable and every declared predicate the flow
// carries, against the live object universe — the same parse `os build`
// runs, now at the door Studio/REST/MCP authors actually use.
//
// [#19542] `action` and `hook` join under the ADR-0049 ruling 「declared ⇒
// honoured; not honourable ⇒ retired」. Both types declare
// `allowRuntimeCreate: true`, so Studio, REST `/meta` and an MCP/AI author
// may mint one — and both already had their `TYPE_TO_STACK_KEY` row, inert
// because no rule declared them here. This rule is the bridge the
// measurement named for each: `recordsOf(stack.actions)` →
// `checkAction('stack', action)` judges the written action's own `visible`
// / `disabled` CEL, and `recordsOf(stack.hooks)` judges the written hook's
// own `condition` — the WRITTEN item is the subject in both, not a
// resolution universe for someone else's reference.
//
// It needs only `objects` to resolve `record.<field>`, and that is the one
// collection every snapshot carries, so RUNTIME_NEEDS_FULL_SNAPSHOT does
// not apply. A predicate whose `object` is outside the write's package
// closure degrades to syntax-only rather than to a false verdict (`check`
// passes `fields: undefined`), which is the safe direction.
//
// ⛔ The action/hook BODY rules (`validateActionBodyWrites`,
// `validateHookBodyWrites`, `validateReadonly{Action,Hook}Writes`) do NOT
// cross with them: they parse authored JS through typescript/sucrase, the
// two dependencies `runtime-lazy-deps.test.ts` pins off the kernel boot
// path outright (tier 1), and an action/hook write is exactly the snapshot
// that would carry a body for them to parse.
//
// ⛔ Nor do `validateActionNameRefs` / `validateActionDispatchContract`:
// they read `stack.actions` as a resolution UNIVERSE for a view's button
// wiring, so an action write can only make a reference resolve — it can
// only REMOVE findings, which the gate's differential already discards.
//
// MEASURED over the shipped corpus at the door's own snapshot shape before
// crossing: 79 actions and 6 hooks (showcase 70/4, todo 8/1, crm 1/1) →
// 0 differential findings, with lit synthetic probes refused per type in
// `runtime-gate.inert-type-writes.test.ts`.
surfaces: CLI_AND_RUNTIME,
runtimeTypes: ['flow'],
runtimeTypes: ['flow', 'action', 'hook'],
run: (stack) =>
validateStackExpressions(stack).map((i) => ({
severity: i.severity ?? 'error',
Expand Down Expand Up @@ -715,8 +749,16 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
input: 'parsed',
commands: ALL,
source: 'packages/lint/src/validate-preset-comparands.ts',
// [#19542] `report` joins under the ADR-0049 ruling. `reports` is already
// one of this rule's declared scan surfaces (`{ key: 'reports', kind:
// 'report' }`), walked by `walkAuthoredFilters` into `reports[i]…` paths,
// so the written report is the subject; the type declares
// `allowRuntimeCreate: true` and had no `runtimeTypes` row anywhere, which
// is the declared-not-enforced state the ruling resolves. Arm 2 binds
// field types from `objects` / `datasets` and stays silent where they are
// absent, exactly as it does for the five types already listed.
surfaces: CLI_AND_RUNTIME,
runtimeTypes: ['dashboard', 'view', 'object', 'page', 'flow'],
runtimeTypes: ['dashboard', 'view', 'object', 'page', 'flow', 'report'],
run: (stack) => validatePresetComparands(stack),
},
// #5330 — the LITERAL empty combinators (`$and: []`, `$or: []`, `$not: {}`,
Expand All @@ -740,8 +782,23 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
// `page`, `dashboard`) is a one-line `runtimeTypes` edit once #4463 P2
// opens them at the gate. Making that call here would widen the gate's
// dispatch surface on this rule's authority, which is P2's decision.
//
// [#19542] `report` is that edit, taken on the ADR-0049 ruling's authority
// rather than this rule's, and taken for ONE type only. It crosses TOGETHER
// with `validatePresetComparands` above and for #7220's reason: both judge
// the SAME authored filter literal on the SAME `{ key: 'reports' }`
// surface, so an author refused for a bad preset comparand and waved
// through for a literal `$and: []` on the same report could not predict
// the door. ⛔ The other four filter-carrying types are untouched here —
// this card is the six `allowRuntimeCreate` types, not P2's remainder.
//
// MEASURED over the shipped report corpus at the door's own snapshot shape
// before crossing, and NON-VACUOUSLY: 9 reports (showcase 4, todo 5), of
// which 5 carry an authored filter key this rule and its sibling walk
// (`runtimeFilter`, one of them nested under `blocks[]`) — 0 findings, with
// lit synthetic probes refused per arm.
surfaces: CLI_AND_RUNTIME,
runtimeTypes: ['flow'],
runtimeTypes: ['flow', 'report'],
run: (stack) => validateEmptyCombinators(stack),
},
// The reference-integrity suite (#3583 §5 D5) — itself a registry, of the
Expand Down Expand Up @@ -844,8 +901,32 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
// crossing, at the door's own snapshot shape: 11 datasets
// (platform-objects 5, showcase 4, crm 1, todo 1) — 0 findings, with a lit
// synthetic probe refused.
// [#19542] `report` joins under the ADR-0049 ruling, and the same
// granularity mechanism keeps it NARROW: this entry says which WRITES
// dispatch the suite, the suite's own per-member `runtimeTypes` says which
// MEMBERS judge that snapshot. A `report` write reaches exactly
// `validateChartBindings`. Every other member keeps its declaration.
//
// ⛔ `skill` is NOT here. It was crossed in an earlier revision of this
// card and is held out on a measurement: `validateAiToolReferences`
// resolves into `stack.tools` and `stack.actions`, neither of which the
// per-write snapshot carries, so the shipped corpus's own AI-exposed
// stack-level action reads as unresolved at the door and the rule ships a
// false advisory into Studio. The member carries the measurement; the type
// takes the ruling's group B treatment of `tool`, the same universe
// obstacle read from the other side.
//
// ⛔ `action` and `hook` are deliberately NOT here, although this card
// crosses both types on `validateStackExpressions` above. The suite carries
// the four body-writes members, which parse authored JS through
// typescript/sucrase — and an action/hook write is precisely the snapshot
// that WOULD carry a body for them to parse, so dispatching the suite on
// those two types is the one crossing that turns `runtime-lazy-deps.test.ts`
// tier 1 («the parsers load NEVER») from a standing fact into a red. The
// measurement that named their bridge named `validateStackExpressions`, a
// CEL-only rule, for exactly this reason.
surfaces: CLI_AND_RUNTIME,
runtimeTypes: ['flow', 'view', 'object', 'dataset'],
runtimeTypes: ['flow', 'view', 'object', 'dataset', 'report'],
run: (stack, ctx) => validateReferenceIntegrity(stack, ctx),
},
// ADR-0078 / #5068 — the SDUI component-props gate. `PageComponent.properties`
Expand Down Expand Up @@ -1310,8 +1391,31 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
input: 'parsed',
commands: ALL,
source: 'packages/lint/src/lint-liveness-properties.ts',
surfaces: CLI_ONLY,
surfaceReason: RUNTIME_OBJECT_ADVISORY_VOLUME,
// [#19542] Group C of the ADR-0049 ruling — `email_template` and `mapping`,
// the two types whose only named candidate is this rule. Both declare
// `allowRuntimeCreate: true` and had no `runtimeTypes` row anywhere, so the
// only door a Studio/REST/MCP author has ran no authoring rule at all on
// them; this crossing is what honours the declaration.
//
// RUNTIME_OBJECT_ADVISORY_VOLUME — the reason that held this entry back —
// is about the OBJECT write door («~8 findings per object write … rendered
// in Studio since #4717»), and `object` is deliberately NOT declared below.
// The two types that are declared judge one flat collection each, so that
// reason does not reach them; it still holds for every type left off.
//
// ⚠️ MEASURED, and the report's first reading: this rule is LEDGER-DRIVEN
// and `continue`s on an empty warn map. `packages/spec/liveness/
// email_template.json` is 13 props / 0 warn keys and `mapping.json` is 7 /
// 0 (lit control, same script, same dir: `tool.json` 6/1, `object.json`
// 35/1), so these two writes dispatch this rule and it judges NOTHING
// today. That is the ruled end state, not a half-landing: the ruling
// dispatched the wiring and ⛔ no ledger population («the empty warn maps
// stay empty until a real property needs a row — zero pull, the wiring is
// the whole deliverable»). `runtime-gate.inert-type-writes.test.ts` pins
// both halves — that the rule is dispatched, and that it is silent — so
// the day a ledger row lands the door lights up with no second edit here.
surfaces: CLI_AND_RUNTIME,
runtimeTypes: ['email_template', 'mapping'],
run: (stack) =>
lintLivenessProperties(stack).map((f) => ({
severity: 'warning' as const,
Expand Down
Loading
Loading