Skip to content

Commit 1f69917

Browse files
os-steveclaude
andauthored
spec(lint): wire the six inert runtime-create doors — action / hook / report / skill / email_template / mapping (#19517)
Fixes #19542 Clause-②: no ⚠️ **Card re-pointed 2026-09-21: the original card #19474 became unreachable (HTTP 404) when the `os-sam` account that filed it was banned, so `Fixes #19474` now reads `Fixes #19542`, its verbatim rebuild. The claim, the round-1 FAIL record `5756052587` and every correction still read on the old card and are linked from #19542. ⛔ Nothing about the delivery changed.** ⭐ **Corrected by the owning seat after the round-1 contract review (record `5756052587`): this lands FIVE wired types, not six — `skill` is HELD OUT. The original sentence is struck through rather than deleted.** ~~Six metadata types declared `allowRuntimeCreate: true` and reached **zero** author-time rules at the runtime publish gate. This wires them,~~ Six metadata types declared `allowRuntimeCreate: true` and reached **zero** author-time rules at the runtime publish gate. This wires **five** of them — `action`, `hook`, `report`, `email_template`, `mapping` — and holds `skill` out as a reading, under the ADR-0049 ruling on #19275 (`5754204885`, batch #203 item 4, letter B — 「declared ⇒ honoured; not honourable ⇒ retired」), groups **A** (`action` · `hook` · `report` · `skill`) and **C** (`email_template` · `mapping`), as one card. ## The first reading the ruling asked for The ruling carried forward one NOT MEASURED item unchanged — 「whether a wired rule fires on a real write」 — and made acceptance behavioural. Here it is, per type, each with the test that proves it. All legs go through the real door (`runRuntimeAuthoringRules`), never through a rule called directly. | type | wired rule | a bad write is… | test | a good write passes | |:--|:--|:--|:--|:--| | `action` | `validateStackExpressions` | **REFUSED** (`expression-invalid`) | `⭐ LIT — an action whose visible CEL does not parse is REFUSED`, `⭐ LIT — an action bound to an object, naming a field it has not got, is REFUSED` | ✅ ×2 (synthetic + `crm_convert_lead` verbatim) | | `hook` | `validateStackExpressions` | **REFUSED** (`expression-invalid`) | `⭐ LIT — a hook whose condition names a field the object has not got is REFUSED`, `⭐ LIT — a hook whose condition does not parse is REFUSED` | ✅ ×2 (synthetic + `showcase_audit_task_completion` verbatim) | | `report` | `validateChartBindings`, `validateEmptyCombinators`, `validatePresetComparands` | **REFUSED** (`chart-dataset-unknown`, `chart-dimension-unknown`, `filter-empty-combinator`, `filter-preset-comparand`) | four `⭐ LIT` cases, one per rule id | ✅ ×2 (synthetic + `completed_tasks` verbatim, a filter-carrying member of the corpus) | | `skill` | — | ⛔ **HELD OUT of this landing** — the rule resolves into `stack.tools` / `stack.actions` and the door carries neither, so the corpus's own AI-exposed stack-level action reads as a **FALSE** `unresolved` advisory and a good write does NOT pass clean. Takes the ruling's group-B treatment of `tool`: a reading, not a wiring. Both halves of the wiring are held absent by pins. | `⭐ DARK — a skill write dispatches NOTHING, and has no stack key` · `⭐ LIT — the reason, reproduced: one skill, one rule, two universes` | n/a | | `email_template` | `lintLivenessProperties` | **dispatched, judges NOTHING today** — ledger-driven with 0 warn keys | `writes DO dispatch the ledger rule` + `the rule judges NOTHING today` | ✅ (`showcase_task_done_email` verbatim) | | `mapping` | `lintLivenessProperties` | **dispatched, judges NOTHING today** — same reason | same pair | ✅ (`showcase_inquiry_feed` verbatim) | ⚠️ **Three of the five wired types refuse and two are silent; `skill` is held out.** That is the ruled end state, not a shortfall — see the two readings below. Nothing here is a `surfaces` / `runtimeTypes` field that merely changed. ## Each control was shown to be load-bearing A green control that would be green anyway proves nothing, so each declaration was reverted and the tests watched. Every mutation is proven on disk (anchor count + blob hash) and every restore proven byte-identical to `HEAD`, via `scripts/ablation-replace.mjs`. | ablation | tests that went red | |:--|:--| | `validateStackExpressions` `runtimeTypes` back to `['flow']` | **8** — every `action` and `hook` case | | delete `report: 'reports'` from `TYPE_TO_STACK_KEY` | **8** — every `report` case | | `validateAiToolReferences` member back to the `['flow']` default | **3** — every `skill` case | | `lintLivenessProperties` back to `CLI_ONLY` + `surfaceReason` | **6** — every group C dispatch case | | declare `object` on `lintLivenessProperties` (against the re-pointed #4716 fence) | **3** — the fence refuses it, as before | ## Measured before crossing, at the door's own snapshot shape Every item of these types shipped in this monorepo, pushed through the gate's real baseline/candidate differential: | type | population | differential findings | |:--|:--|:--| | `action` | 79 (showcase 70, todo 8, crm 1) | **0** | | `hook` | 6 (showcase 4, todo 1, crm 1) | **0** | | `report` | 9 (showcase 4, todo 5) — **5 carry an authored filter key**, so the two filter rules were exercised non-vacuously | **0** | | `email_template` | 1 | **0** | | `mapping` | 1 | **0** | | `skill` | **0 — NOT MEASURED, and now moot** | the example corpus authors no skills; `skill` is held out of this landing, so no budget is owed | ## Two readings that are part of the deliverable **1. `email_template` and `mapping` are wired and SILENT.** `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, `mapping.json` is 7 / **0** — lit control on the same instrument, same run: `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」. Both halves are pinned — that the rule **is** dispatched, and that it judges nothing — plus a lit control proving the same instrument fires in the same process on a ledger that does warn, so the two zeros can never be confused with a broken dispatch or an unresolvable ledger directory. **2. A `skill` write is judged with a PARTIAL tool universe.** `collectToolUniverse` unions the platform tool registry ∪ `stack.tools` ∪ the action family from `stack.actions` and every object's `actions`. A per-write snapshot carries `objects` (so an object-level `action_NAME` resolves) but neither `tools` nor `actions`, so a skill naming a stack-level declared tool reads as unresolved at this door while it is clean on the whole stack. Two things bound it: ADR-0109 states the default authoring path declares no tool records at all, and this member is `warning`-tier throughout — it advises and **can never refuse a publish**. Pinned in both directions, so it can only change deliberately. Closing it properly means carrying `tools` / `actions` in `RuntimeStackContext`, which is also an edit to `@objectstack/metadata-protocol`'s routing table — outside this card's file surface and its own decision. ## The fences, and what deliberately did not cross - ⛔ **`action` / `hook` do NOT dispatch the reference-integrity suite.** It 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. That is the one crossing that turns `runtime-lazy-deps.test.ts` tier 1 («the parsers load NEVER») from a standing fact into a red. Pinned as a DARK case; `runtime-lazy-deps.test.ts` is green. - ⛔ **`validateActionNameRefs` / `validateActionDispatchContract` do not cross either** — they read `stack.actions` as a resolution *universe* for a view's button wiring, so an action write can only make a reference resolve, i.e. only REMOVE findings, which the differential already discards. - ⛔ **`lintLivenessProperties` still does not reach the OBJECT door.** `RUNTIME_OBJECT_ADVISORY_VOLUME` is about ~8 advisories per object write rendered in Studio; `object` is not declared, so that reason is untouched. - **`validatePresetComparands` and `validateEmptyCombinators` cross to `report` TOGETHER** (#7220): both judge the same authored filter literal on the same `reports` surface, so an author refused for a bad comparand and waved through for a literal `$and: []` on the same report could not predict the door. - **`report` and `skill` each reach exactly ONE suite member**, pinned by name. ## One pin was re-pointed, and it is the interesting one The #4716 Q2 fence in `runtime-gate.object-writes.test.ts` asserted that each of six advisory-tier rules is absent from the object door **and** carries a substantive `surfaceReason`. Until now every fenced rule happened to be off the runtime surface entirely, so the `surfaceReason` clause was a faithful proxy for the fence. `lintLivenessProperties` crossing for two non-object types broke the proxy without touching the thing it stood for. The fence now asks the question **directly** — a rule on the runtime surface must not declare `object` in `runtimeTypes` — which is the *stronger* of the two arms, mechanical where a `surfaceReason` is prose that goes stale. ⛔ Neither arm is a way around the fence, and the ablation above confirms it still refuses an object crossing. ## Verification - `pnpm --filter @objectstack/lint test` — **107 files / 4074 tests pass** (head `1ac5e9779b`); `pnpm --filter @objectstack/lint typecheck` — clean (test layer compiles under `tsconfig.test.json`). - Downstream gate consumer: `@objectstack/metadata-protocol`'s five runtime-gate suites — **63 tests pass**. - `pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*'` — 72/72. - **63 of 63** derived gate families, re-derived and re-run on head `1ac5e9779b`, (`scripts/pm/dispatch-gates.mjs`) run, reconciled with `--ran`, **0 NOT-MEASURED, 0 UNRUN**, every one recording an exit code. - `eslint . --no-inline-config` over the **whole repo** — **6971 files, 0 errors, 0 warnings** (head `1ac5e9779b`) (not a narrowing: the full sweep ran). - `pnpm check:nul-bytes` green, plus a direct control-byte scan of every changed file. - Merged `origin/main` (`c9b23cd`) after #19480 landed in `lint-liveness-properties.ts`, refreshed install + full build, and re-ran the above. ## Acceptance notes Observed while measuring, ⛔ not fixed here, routed to the PM: - **#19276's `liveness-ledger-unreadable` cannot reach the runtime publish door.** Measured with `mapping.json` corrupted: the whole-stack rule emits `["liveness-ledger-unreadable"]` while the runtime door emits `errors: []`, `advisories: []`, `rulesRun: ["lintLivenessProperties"]`. The finding is stack-independent, so it appears identically in the gate's baseline and candidate passes and cancels in the differential. Not introduced here — but before this card the rule never ran at that door, so there was nothing to cancel. The signal says 「this rule's silence about this type means nothing until it is fixed」 and at this door it is itself silent. - A stack-level `action` binds its object with `objectName`; the `object` spelling is an alias the strict schema renames one layer earlier, so the door judges an object-bound action's predicate with full field resolution and an object-less one for syntax only. Measured, correct, and not a gap — recorded so the next reader does not re-measure it. --- _Generated by [Claude Code](https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2)_ --- ## Seat corrections, 2026-09-21T06:40Z The round-1 at-tier contract review (`5756052587`) returned **FAIL** on two grounds; both are addressed at head `72de2b946301aa59f624da807ae7fc383e82b81e`, and this body — written once at creation, per the dev-writes-it-once rule — is corrected here by the owning seat rather than by the dev. 1. **`skill` is held out.** Group A lands as **three** types, not four. The dev took the review's route (b) over route (a) and its reasons are on card #19474; whether that is inside the ruling's logic or a scope reduction its author should decide is the **round-2 reviewer's** question, ⛔ not settled here. 2. **The changeset now declares.** `**BREAKING for runtime metadata writes**`, `Clause-②: no (narrowing)`, and one `adr-0087: not-required (no-migration-prescription)` marker. ⚠️ The two-line fix did not suffice: with the banner added the ADR-0087 gate still refused `not-required` against this body's framed Migration **table** (the #6048 shape). The table was replaced with prose — the content has no FROM to translate — ⛔ not the category swapped to get past the gate. 3. **The #4716 fence wording is corrected.** It no longer claims to be 「the stronger of the two」; it now records that the crossed arm is implied by the `atDoor` assertion and is strictly **weaker** than the clause it replaced. ⛔ No code change: the wording was what was false. 4. **Group C's proof size is recorded** where a reader meets it: a wrong `TYPE_TO_STACK_KEY` key for `email_template` / `mapping` reds exactly one string pin and no behavioural case, because those rules judge nothing at that door. ## Seat corrections, round 3 — the red suite and where it came from Round 2's at-tier review returned **PASS** (record `5757740876`), but CI on that head was **red**: `Test Core` shards 4/6 and 5/6 failed, and `Test Core` failed with them. ⛔ The PR was not landed on the PASS. It is recorded here because the miss is structural rather than careless. **It was this PR's, measured before anything was touched:** on `origin/main` `3e8e2b0d6d` all six shards read success; on the PR head two failed. **One root cause, four test files, two packages — and it is this card's own door working correctly.** Each file authors a `report` binding a dataset its harness never declares, into a universe that is **empty by construction** (`find` mocked to `[]`; `listItems: () => []`). Since the report door opened, `validateChartBindings` resolves that binding and refuses the write with `chart-dataset-unknown` before the assertion each file exists to make. ⭐ The refusal is **true** — those reports really do bind nothing — and `ReportSchema` refines `dataset` to **required**, so dropping the binding was never available: a report either binds a dataset the tenant has, or it is not a report. The fix seeds that dataset into each harness's live universe, the landed pattern from `protocol.dashboard-dataset-publish-gate.test.ts`. ⛔ No test was skipped, disabled or quarantined; every whitelist, hash, org-scope, history and rejection assertion is untouched, and a report binding a dataset nobody declares is still refused. ⚠️ **Why a dependents sweep could not see it.** `@objectstack/objectql` and `@objectstack/rest` declare **no** dependency on `@objectstack/lint` — they reach the gate through `@objectstack/metadata-protocol`. ⇒ for a card that widens a publish gate, the blast radius is **every package that drives `saveMetaItem`**, ⛔ not the package graph under the rule registry. ⚠️ **Declared file surface breached, and reported rather than widened silently.** The dispatch declared `packages/lint/src/`; this round necessarily reached two test files each in `packages/objectql/src/` and `packages/rest/src/`. All four are test-harness fixtures made truthful — no production code, no rule touched — which is the standard shape for a door-widening card (#15254, #19143 did the same). ⛔ One adjacent repair was **declined**: `metadata-validation-sweep.test.ts` still prints `dataset: no fixture (skipped)`, and adding that fixture surfaces a **pre-existing** `object-reference-unknown` from the same empty-universe condition. It was reverted and filed as its own card rather than carried here. ### The false sentence, corrected — and it had to be corrected twice `runtime-gate.ts` claimed 「Twelve of the sixteen mappings」 and 「#19474's four rows」. Counted from the table rather than by eye: **fifteen** rows above `position`, four of them context collections ⇒ **eleven of fifteen**, and this card lands **three** rows. Both figures were true at the round-1 head; withdrawing the `skill` row falsified them, and they contradicted this same file's correct 「The three rows」 68 lines above. ⚠️ The build does not strip comments, so the sentence ships in `dist/index.js`, `dist/runtime.js` and both `.cjs`. ⭐ The correction was made once, **lost**, and made again: it was still uncommitted when an ablation's restore leg ran `git checkout HEAD -- runtime-gate.ts` and discarded it silently at exit 0 — the hazard `AGENTS.md` names in as many words («commit the fix FIRST»). It was caught only by reading the sentence back out of `git show HEAD:…` instead of trusting the edit, and it is now confirmed present in the built bundles. The provenance note is **kept and extended**, ⛔ not deleted: #19370's 「eight of the twelve」 is recorded as true of the table it was written against, and the withdrawn-row step is recorded instead of leaving a silent jump. ## Seat corrections, round 4 — every citation in this diff now resolves Round 3 was red on `Lint & Repo Gates`: the issue-citation gate reported `[allocated-but-absent]` — 「minted and absent from the board」 — because the account that filed cards #19474 and #19370 was **banned**. ⛔ Neither issue was deleted; `GET`/`PATCH` on them answer **404** while their comments and timelines still resolve, and they vanish from label listings. Card #19474 was rebuilt verbatim as **#19542**, which is what this PR closes. ### The fix was bigger than the ten lines the job printed, and the job said so The gate stops at the first non-zero exit, and its own tail states 「the red above is a **LOWER BOUND** on the number of problems in this tree, not a count」 and that the gates behind it are **NOT MEASURED**. ⇒ the dev enumerated every `#NNNN` the diff **adds** — 11 distinct numbers — and probed each against the API rather than trusting the printed list. Result: **25** `#19474` sites across ten files, not nine. All 25 re-pointed to `#19542`. ⭐ ⛔ **Not a guess**, and verified before editing rather than after: #19542 resolves, its title opens `[rebuild of #19474]`, its body states the original is unreachable and tabulates the same 404 readings, and this PR already closes it. ⚠️ The **changeset** was re-pointed too. `.changeset/**` is not a judged surface, so the gate would never have caught it — but that text ships verbatim into `CHANGELOG.md`, and a dangling number there would outlive the card. ### `#19370` is NOT re-pointed, and two assumptions were corrected by measurement That citation is **historical provenance** — what #19370's author wrote, and when it was true. Re-pointing it would rewrite history to satisfy a gate. 1. ⚠️ **PR #19486 — the obvious live record to name — also answers 404**, filed by the same banned account. Naming it would have minted a second dangling reference to fix the first. The **merge commit** `a227afa415f596269ed36aae0a0631c84270ccc9` is named instead: it is in history, carries that card's whole diff, and cannot rot. 2. ⚠️ **Prose alone does not satisfy the gate.** Keeping `#19370` and explaining in prose that it no longer resolves still exited 1 with two `[allocated-but-absent]` findings — the gate judges every bare `#N` on an **added** line against the board regardless of surrounding text. Reading its own sentence again resolves it: **a dead number dressed as a live link IS the dangling reference.** So the number is kept and spelled as what it now is — a historical card id, without the citation sigil — with the reason inline and the commit named. ⛔ The `owner/repo#N` qualifier was **explicitly not** used: it makes the gate skip probing by declaring a cross-repo reference. This is not one, and using it to buy silence would be evasion. ⇒ the two `[#19370]` citations that remain in `runtime-gate.ts` sit on lines this diff does **not** touch — they are #19486's landed text, outside the gate's diff scope and ⛔ not this PR's to rewrite. ### Verification on this head `check-issue-citations` (the diff-scoped form `lint.yml` runs) — **15 citations judged, 15 resolve, exit 0**. `--self-test` exit 0, 73 cases. All **63** derived gate families re-derived and re-run in ONE sweep, reconciled with `--ran`: 0 NOT-MEASURED, 0 UNRUN, none exiting 3. `@objectstack/rest` — the package shard 5/6's six failures lived in — **194 files / 3254 pass**, and `@objectstack/objectql` (shard 4/6) **303 / 5050**; ⛔ neither assumed from the earlier fix, both re-run here. All five ablations re-run with unchanged red counts, and ⭐ run only **after** the citation fix was committed and read back out of `git show HEAD:…` — the sequencing that lost a correction one round earlier. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8dba7aa commit 1f69917

10 files changed

Lines changed: 1156 additions & 14 deletions
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
"@objectstack/lint": minor
3+
---
4+
5+
**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)
6+
7+
Clause-②: no (narrowing)
8+
9+
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.
10+
11+
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.
12+
13+
- **`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.
14+
- **`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.
15+
- **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).
16+
- **`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.
17+
- **`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.
18+
- **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.
19+
- **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.
20+
21+
## Two readings that are part of the deliverable, not omissions
22+
23+
**`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.
24+
25+
**`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.
26+
27+
## The refusal set grows — and there is no FROM → TO, because nothing changed spelling
28+
29+
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:
30+
31+
- an action whose `visible` / `disabled` CEL does not parse, or names a field its bound object does not declare;
32+
- a hook whose `condition` does the same;
33+
- a report binding a dataset nothing declares, or grouping by a dimension or measure its dataset does not declare;
34+
- a report whose filter carries a literal empty combinator (`$and: []`, `$or: []`, `$not: {}`);
35+
- a report filtering by a dashboard date-range PRESET name (`last_30_days`, …) as if it were a value.
36+
37+
⚠️ **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.
38+
39+
`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.
40+
41+
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).
42+
43+
<!-- 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. -->

‎packages/lint/src/authoring-rules.ts‎

Lines changed: 110 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -466,8 +466,42 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
466466
// checks every script-node callable and every declared predicate the flow
467467
// carries, against the live object universe — the same parse `os build`
468468
// runs, now at the door Studio/REST/MCP authors actually use.
469+
//
470+
// [#19542] `action` and `hook` join under the ADR-0049 ruling 「declared ⇒
471+
// honoured; not honourable ⇒ retired」. Both types declare
472+
// `allowRuntimeCreate: true`, so Studio, REST `/meta` and an MCP/AI author
473+
// may mint one — and both already had their `TYPE_TO_STACK_KEY` row, inert
474+
// because no rule declared them here. This rule is the bridge the
475+
// measurement named for each: `recordsOf(stack.actions)` →
476+
// `checkAction('stack', action)` judges the written action's own `visible`
477+
// / `disabled` CEL, and `recordsOf(stack.hooks)` judges the written hook's
478+
// own `condition` — the WRITTEN item is the subject in both, not a
479+
// resolution universe for someone else's reference.
480+
//
481+
// It needs only `objects` to resolve `record.<field>`, and that is the one
482+
// collection every snapshot carries, so RUNTIME_NEEDS_FULL_SNAPSHOT does
483+
// not apply. A predicate whose `object` is outside the write's package
484+
// closure degrades to syntax-only rather than to a false verdict (`check`
485+
// passes `fields: undefined`), which is the safe direction.
486+
//
487+
// ⛔ The action/hook BODY rules (`validateActionBodyWrites`,
488+
// `validateHookBodyWrites`, `validateReadonly{Action,Hook}Writes`) do NOT
489+
// cross with them: they parse authored JS through typescript/sucrase, the
490+
// two dependencies `runtime-lazy-deps.test.ts` pins off the kernel boot
491+
// path outright (tier 1), and an action/hook write is exactly the snapshot
492+
// that would carry a body for them to parse.
493+
//
494+
// ⛔ Nor do `validateActionNameRefs` / `validateActionDispatchContract`:
495+
// they read `stack.actions` as a resolution UNIVERSE for a view's button
496+
// wiring, so an action write can only make a reference resolve — it can
497+
// only REMOVE findings, which the gate's differential already discards.
498+
//
499+
// MEASURED over the shipped corpus at the door's own snapshot shape before
500+
// crossing: 79 actions and 6 hooks (showcase 70/4, todo 8/1, crm 1/1) →
501+
// 0 differential findings, with lit synthetic probes refused per type in
502+
// `runtime-gate.inert-type-writes.test.ts`.
469503
surfaces: CLI_AND_RUNTIME,
470-
runtimeTypes: ['flow'],
504+
runtimeTypes: ['flow', 'action', 'hook'],
471505
run: (stack) =>
472506
validateStackExpressions(stack).map((i) => ({
473507
severity: i.severity ?? 'error',
@@ -715,8 +749,16 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
715749
input: 'parsed',
716750
commands: ALL,
717751
source: 'packages/lint/src/validate-preset-comparands.ts',
752+
// [#19542] `report` joins under the ADR-0049 ruling. `reports` is already
753+
// one of this rule's declared scan surfaces (`{ key: 'reports', kind:
754+
// 'report' }`), walked by `walkAuthoredFilters` into `reports[i]…` paths,
755+
// so the written report is the subject; the type declares
756+
// `allowRuntimeCreate: true` and had no `runtimeTypes` row anywhere, which
757+
// is the declared-not-enforced state the ruling resolves. Arm 2 binds
758+
// field types from `objects` / `datasets` and stays silent where they are
759+
// absent, exactly as it does for the five types already listed.
718760
surfaces: CLI_AND_RUNTIME,
719-
runtimeTypes: ['dashboard', 'view', 'object', 'page', 'flow'],
761+
runtimeTypes: ['dashboard', 'view', 'object', 'page', 'flow', 'report'],
720762
run: (stack) => validatePresetComparands(stack),
721763
},
722764
// #5330 — the LITERAL empty combinators (`$and: []`, `$or: []`, `$not: {}`,
@@ -740,8 +782,23 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
740782
// `page`, `dashboard`) is a one-line `runtimeTypes` edit once #4463 P2
741783
// opens them at the gate. Making that call here would widen the gate's
742784
// dispatch surface on this rule's authority, which is P2's decision.
785+
//
786+
// [#19542] `report` is that edit, taken on the ADR-0049 ruling's authority
787+
// rather than this rule's, and taken for ONE type only. It crosses TOGETHER
788+
// with `validatePresetComparands` above and for #7220's reason: both judge
789+
// the SAME authored filter literal on the SAME `{ key: 'reports' }`
790+
// surface, so an author refused for a bad preset comparand and waved
791+
// through for a literal `$and: []` on the same report could not predict
792+
// the door. ⛔ The other four filter-carrying types are untouched here —
793+
// this card is the six `allowRuntimeCreate` types, not P2's remainder.
794+
//
795+
// MEASURED over the shipped report corpus at the door's own snapshot shape
796+
// before crossing, and NON-VACUOUSLY: 9 reports (showcase 4, todo 5), of
797+
// which 5 carry an authored filter key this rule and its sibling walk
798+
// (`runtimeFilter`, one of them nested under `blocks[]`) — 0 findings, with
799+
// lit synthetic probes refused per arm.
743800
surfaces: CLI_AND_RUNTIME,
744-
runtimeTypes: ['flow'],
801+
runtimeTypes: ['flow', 'report'],
745802
run: (stack) => validateEmptyCombinators(stack),
746803
},
747804
// The reference-integrity suite (#3583 §5 D5) — itself a registry, of the
@@ -844,8 +901,32 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
844901
// crossing, at the door's own snapshot shape: 11 datasets
845902
// (platform-objects 5, showcase 4, crm 1, todo 1) — 0 findings, with a lit
846903
// synthetic probe refused.
904+
// [#19542] `report` joins under the ADR-0049 ruling, and the same
905+
// granularity mechanism keeps it NARROW: this entry says which WRITES
906+
// dispatch the suite, the suite's own per-member `runtimeTypes` says which
907+
// MEMBERS judge that snapshot. A `report` write reaches exactly
908+
// `validateChartBindings`. Every other member keeps its declaration.
909+
//
910+
// ⛔ `skill` is NOT here. It was crossed in an earlier revision of this
911+
// card and is held out on a measurement: `validateAiToolReferences`
912+
// resolves into `stack.tools` and `stack.actions`, neither of which the
913+
// per-write snapshot carries, so the shipped corpus's own AI-exposed
914+
// stack-level action reads as unresolved at the door and the rule ships a
915+
// false advisory into Studio. The member carries the measurement; the type
916+
// takes the ruling's group B treatment of `tool`, the same universe
917+
// obstacle read from the other side.
918+
//
919+
// ⛔ `action` and `hook` are deliberately NOT here, although this card
920+
// crosses both types on `validateStackExpressions` above. The suite carries
921+
// the four body-writes members, which parse authored JS through
922+
// typescript/sucrase — and an action/hook write is precisely the snapshot
923+
// that WOULD carry a body for them to parse, so dispatching the suite on
924+
// those two types is the one crossing that turns `runtime-lazy-deps.test.ts`
925+
// tier 1 («the parsers load NEVER») from a standing fact into a red. The
926+
// measurement that named their bridge named `validateStackExpressions`, a
927+
// CEL-only rule, for exactly this reason.
847928
surfaces: CLI_AND_RUNTIME,
848-
runtimeTypes: ['flow', 'view', 'object', 'dataset'],
929+
runtimeTypes: ['flow', 'view', 'object', 'dataset', 'report'],
849930
run: (stack, ctx) => validateReferenceIntegrity(stack, ctx),
850931
},
851932
// ADR-0078 / #5068 — the SDUI component-props gate. `PageComponent.properties`
@@ -1310,8 +1391,31 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
13101391
input: 'parsed',
13111392
commands: ALL,
13121393
source: 'packages/lint/src/lint-liveness-properties.ts',
1313-
surfaces: CLI_ONLY,
1314-
surfaceReason: RUNTIME_OBJECT_ADVISORY_VOLUME,
1394+
// [#19542] Group C of the ADR-0049 ruling — `email_template` and `mapping`,
1395+
// the two types whose only named candidate is this rule. Both declare
1396+
// `allowRuntimeCreate: true` and had no `runtimeTypes` row anywhere, so the
1397+
// only door a Studio/REST/MCP author has ran no authoring rule at all on
1398+
// them; this crossing is what honours the declaration.
1399+
//
1400+
// RUNTIME_OBJECT_ADVISORY_VOLUME — the reason that held this entry back —
1401+
// is about the OBJECT write door («~8 findings per object write … rendered
1402+
// in Studio since #4717»), and `object` is deliberately NOT declared below.
1403+
// The two types that are declared judge one flat collection each, so that
1404+
// reason does not reach them; it still holds for every type left off.
1405+
//
1406+
// ⚠️ MEASURED, and the report's first reading: this rule is LEDGER-DRIVEN
1407+
// and `continue`s on an empty warn map. `packages/spec/liveness/
1408+
// email_template.json` is 13 props / 0 warn keys and `mapping.json` is 7 /
1409+
// 0 (lit control, same script, same dir: `tool.json` 6/1, `object.json`
1410+
// 35/1), so these two writes dispatch this rule and it judges NOTHING
1411+
// today. That is the ruled end state, not a half-landing: the ruling
1412+
// dispatched the wiring and ⛔ no ledger population («the empty warn maps
1413+
// stay empty until a real property needs a row — zero pull, the wiring is
1414+
// the whole deliverable»). `runtime-gate.inert-type-writes.test.ts` pins
1415+
// both halves — that the rule is dispatched, and that it is silent — so
1416+
// the day a ledger row lands the door lights up with no second edit here.
1417+
surfaces: CLI_AND_RUNTIME,
1418+
runtimeTypes: ['email_template', 'mapping'],
13151419
run: (stack) =>
13161420
lintLivenessProperties(stack).map((f) => ({
13171421
severity: 'warning' as const,

0 commit comments

Comments
 (0)