You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 1f69917
Browse filesBrowse the repository at this point in the historyBrowse files
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>
**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. -->
0 commit comments