diff --git a/.changeset/19474-six-inert-runtime-create-doors.md b/.changeset/19474-six-inert-runtime-create-doors.md new file mode 100644 index 00000000000..baa8a8faf51 --- /dev/null +++ b/.changeset/19474-six-inert-runtime-create-doors.md @@ -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.` 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). + + diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 8d50eced1a1..f32647ecf56 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -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.`, 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', @@ -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: {}`, @@ -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 @@ -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` @@ -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, diff --git a/packages/lint/src/reference-integrity-suite.ts b/packages/lint/src/reference-integrity-suite.ts index eae81029157..c6f3542ca35 100644 --- a/packages/lint/src/reference-integrity-suite.ts +++ b/packages/lint/src/reference-integrity-suite.ts @@ -380,7 +380,23 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [ // every page-door binding as unresolvable. Its page-typed sibling above // takes the default for the same reason. { name: 'validatePageVisualizationBindings', run: validatePageVisualizationBindings }, - { name: 'validateChartBindings', run: validateChartBindings }, + // [#19542] `runtimeTypes` gains `report` under the ADR-0049 ruling, and ONLY + // `report`. On a report write this member is the subject-reader: it opens + // with `recordsOf(stack.reports)` and resolves that report's own `dataset` / + // `rows` / `columns` / `values` and its chart's axes against `stack.datasets` + // — the one collection this resolution needs and the one the per-write + // snapshot already carries (#7529), so it has no missing-collection + // false-positive channel. ⛔ NOT `dashboard` or `view`: on those writes the + // report/board surfaces the rule walks are the collections the snapshot does + // NOT carry, which is the `dataset` door's own DARK pin one file over. + // + // ⚠️ A REFUSAL widening, like #15254's and #19143's: a report republished + // with a dangling dataset binding or a dimension the dataset does not + // declare is now refused (422) rather than stored silently. MEASURED over + // the shipped report corpus at the door's own snapshot shape before + // crossing: 9 reports (showcase 4, todo 5), 8 of them binding a real dataset + // with `rows`/`values` — 0 findings, with a lit synthetic probe refused. + { name: 'validateChartBindings', runtimeTypes: ['flow', 'report'], run: validateChartBindings }, // [#14105] One level BELOW the two members above it. `validateChartBindings` // and `validateWidgetBindings` resolve a presentation's binding against the // dataset (#7529/#8902); this one resolves the DATASET's own references — @@ -444,6 +460,45 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [ { name: 'validateTranslatableSections', run: validateTranslatableSections }, { name: 'validateFlowTemplatePaths', run: validateFlowTemplatePaths }, { name: 'validateAiSurfaceAffinity', run: validateAiSurfaceAffinity }, + // [#19542] NO `runtimeTypes`, i.e. the frozen `flow` default — and `skill` is + // HELD OUT of the ADR-0049 wiring deliberately, on a measurement, not + // forgotten. This member is the measurement's named bridge for the type and + // it does read the written skill as its subject (`recordsOf(stack.skills)`, + // findings pathed `skills[si].tools[ti]`), so on paper it crosses. What + // stops it is the UNIVERSE it resolves into. + // + // `collectToolUniverse` unions `PLATFORM_PROVIDED_TOOL_NAMES` ∪ + // `stack.tools` ∪ the materialised action family from `stack.actions` and + // every object's `actions`. A per-write snapshot carries `objects` — so an + // object-level `action_` resolves — but NEITHER `tools` NOR `actions`. + // Two of the three limbs are missing, so at this door the rule has no + // truthful `unresolved` verdict at all: only its CLEAN answers are reliable. + // + // ⚠️ Measured on the SHIPPED corpus, not synthetically. `app-showcase` + // declares exactly one AI-exposed action, `showcase_portfolio_snapshot`, and + // it exists at stack level only (todo's six are mirrored under + // `objects[].actions`, so those resolve; crm has none). A skill naming + // `action_showcase_portfolio_snapshot` is advised `ai-skill-tool-unresolved` + // at the door while the same member over the whole showcase stack answers + // `[]`. The advisory's own hint tells the author to declare the action and + // opt it in with `ai.exposed: true` — which is exactly what the author did — + // and it reaches `SaveMetaItemResponseSchema.advisories`, which Studio + // renders. A user would see a warning that is not true. + // + // ⛔ ADR-0109 does NOT bound this. Its 「the default path declares no tool + // records」 covers tool RECORDS; the `action_` family IS that default + // path, so the bound argues the opposite way. + // + // So the type takes the ruling's OWN group B treatment of `tool` — 「the + // `tools` universe rule … can only remove findings ⇒ a reading, not a + // ruling」 — which is this same rule read from the other side. Crossing it + // needs `actions` / `tools` carried in `RuntimeStackContext` + + // `CONTEXT_STACK_KEYS`, a `CLOSURE_CONTEXT_KEY_BY_TYPE` row and two more + // door gathers in `@objectstack/metadata-protocol` — a second package, a + // snapshot widening on EVERY gated write, and its own card. `skill` has no + // `TYPE_TO_STACK_KEY` row either, so the two absences are CONSISTENT and the + // gate dispatches nothing for it; `runtime-gate.inert-type-writes.test.ts` + // holds both absent and records the measurement that put them there. { name: 'validateAiToolReferences', run: validateAiToolReferences }, { name: 'validateAiAgentAuthoring', run: validateAiAgentAuthoring }, // Field names WRITTEN by an L2 hook body (`ctx.input.x = …`, diff --git a/packages/lint/src/runtime-gate.inert-type-writes.test.ts b/packages/lint/src/runtime-gate.inert-type-writes.test.ts new file mode 100644 index 00000000000..deade60cf93 --- /dev/null +++ b/packages/lint/src/runtime-gate.inert-type-writes.test.ts @@ -0,0 +1,750 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #19542 — the six write doors that dispatched NOTHING, under the ADR-0049 + * ruling 「declared ⇒ honoured; not honourable ⇒ retired」. FIVE are wired + * here; `skill` is held out on a measurement and has its own block, whose + * pins hold it absent rather than describing it. + * + * ## The state this closes + * + * `DEFAULT_METADATA_TYPE_REGISTRY` declares `action`, `hook`, `report`, + * `skill`, `email_template` and `mapping` with `allowRuntimeCreate: true` — + * Studio, REST `/meta` and an MCP/AI author may all mint one at runtime. + * Measured on `origin/main` before this card: no rule declared any of the six + * in `runtimeTypes`, so the gate filtered them out before it ever consulted + * `TYPE_TO_STACK_KEY` and a write of any of them built no snapshot and ran no + * rule at all. `action` and `hook` already HAD their stack-key rows; the two + * absences were consistent rather than contradictory, which is exactly why CI + * was green and the declaration was hollow anyway. + * + * ## Why this file exists rather than a diff + * + * The ruling carried its own NOT MEASURED item forward unchanged — 「whether a + * wired rule fires on a real write」 — and made acceptance behavioural: a + * `surfaces` / `runtimeTypes` field that merely changed is not the deliverable. + * So every WIRED type below has BOTH legs through the real door: a write the + * rule must judge IS judged, and a good write of the same type passes. The + * `seed: 'data'` note in `runtime-gate.ts` records what a file like this + * refuses to let happen — a mapping onto a collection nothing reads keeps every + * gate green and reports `rulesRun` while running on nothing. + * + * ## ⚠️ `skill` is the fourth of group A, and it is NOT wired + * + * Its bridge resolves into two collections the door does not carry, so on the + * shipped corpus a GOOD skill write does not pass clean — the card's own + * acceptance fails for it. An earlier revision crossed it and pinned that + * false advisory green; the at-tier review called pinning a measured falsehood + * the defect, and it was right. The type now takes the ruling's group B + * treatment: a reading first. Its block below holds both halves of the wiring + * ABSENT and keeps the measurement executable. + * + * ## ⚠️ Two of the five wired types are SILENT, on purpose, and that is pinned + * + * `email_template` and `mapping` bridge through `lintLivenessProperties`, which + * is LEDGER-DRIVEN and `continue`s on an empty warn map. Both ledgers carry 0 + * warn keys and the ruling dispatched ⛔ 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 for those two the honest reading is + * "the rule is dispatched and judges nothing today", and the zero carries a LIT + * CONTROL on the same instrument in the same process so it can never be + * confused with a broken dispatch or an unreadable ledger. ⚠️ Their wiring is + * proved by a TABLE PIN rather than a door reading — the size of that proof is + * stated where those cases are, and it is why the string assertion in the + * first block is load-bearing rather than decorative. + */ +import { describe, expect, it } from 'vitest'; + +import { AUTHORING_RULES } from './authoring-rules.js'; +import { lintLivenessProperties } from './lint-liveness-properties.js'; +import { REFERENCE_INTEGRITY_RULES } from './reference-integrity-suite.js'; +import { + buildRuntimeWriteSnapshots, + runRuntimeAuthoringRules, + runtimeAuthoringRulesFor, + runtimeGatedTypes, + stackKeyForType, + type RuntimeStackContext, +} from './runtime-gate.js'; + +/** + * The types this card WIRES, in the ruling's own grouping order. + * + * Group A is THREE, not the ruling's four: `skill` is held out on a + * measurement and has its own describe block below, whose pins hold it absent. + * ⛔ Adding it back here without carrying `actions` / `tools` in + * `RuntimeStackContext` re-ships the false advisory those pins record. + */ +const GROUP_A = ['action', 'hook', 'report'] as const; +const GROUP_C = ['email_template', 'mapping'] as const; + +/** + * The resolution universe every case is judged against — the two collections a + * per-write snapshot actually carries, with the field types and dimension names + * the cases below ask about. + */ +const OBJECTS = [ + { + name: 'acme_invoice', + label: 'Invoice', + sharingModel: 'private', + fields: { + status: { type: 'select', label: 'Status', options: [{ value: 'open', label: 'Open' }] }, + issued_on: { type: 'date', label: 'Issued' }, + total: { type: 'currency', label: 'Total' }, + done: { type: 'boolean', label: 'Done' }, + }, + // An OBJECT-level AI-exposed action. It rides in on `objects`, which is + // the ONLY limb of `collectToolUniverse` a per-write snapshot supplies — + // the half that made the `skill` crossing look safe until the stack-level + // case was measured. See the held-out block below. + actions: [ + { + name: 'acme_ping', + label: 'Ping', + type: 'script', + target: 'ping', + ai: { exposed: true, description: 'Ping the invoice.' }, + }, + ], + }, +]; + +const DATASETS = [ + { + name: 'acme_invoice_metrics', + label: 'Invoice Metrics', + object: 'acme_invoice', + dimensions: [{ name: 'status', label: 'Status', field: 'status', type: 'string' }], + measures: [{ name: 'total_sum', label: 'Total', aggregate: 'sum', field: 'total' }], + }, +]; + +const CONTEXT = { objects: OBJECTS, datasets: DATASETS }; + +const ruleIds = (findings: readonly { rule: string }[]) => findings.map((f) => f.rule); +const dump = (r: { errors: readonly unknown[]; advisories: readonly unknown[] }) => + JSON.stringify({ errors: r.errors, advisories: r.advisories }); + +// ───────────────────────────────────────────────────────────────────────────── + +describe('#19542 — the five wired doors are declared, mapped and reachable', () => { + it.each([...GROUP_A, ...GROUP_C])('`%s` dispatches at least one rule and maps to a stack key', (type) => { + // Three halves, because any one of them alone is the "looks wired, + // enforces nothing" state: the type is gated, some rule runs for it, and + // the gate can build a snapshot for it. Without the mapping the gate finds + // the rules, builds no snapshot and returns clean. + expect(runtimeGatedTypes()).toContain(type); + expect(runtimeAuthoringRulesFor(type).length, `no rule declares '${type}'`).toBeGreaterThan(0); + expect(stackKeyForType(type)).not.toBeNull(); + }); + + it('each stack key is the collection the crossed rules actually read', () => { + // The `seed: 'data'` failure, asked directly: a mapping is only worth + // anything when it names the key the rule opens on. + expect(stackKeyForType('action')).toBe('actions'); + expect(stackKeyForType('hook')).toBe('hooks'); + expect(stackKeyForType('report')).toBe('reports'); + // ⭐ These two are the WHOLE proof that group C's wiring is right — see the + // note over that block. Their rules judge nothing at this door, so no + // behavioural case can tell `'mappings'` from a `'mapping'` typo; ablating + // either key reds exactly this assertion and nothing else. + expect(stackKeyForType('email_template')).toBe('emailTemplates'); + expect(stackKeyForType('mapping')).toBe('mappings'); + // `skill` is held out — asserted absent in its own block, not here. + }); + + it('the written item really lands in that collection', () => { + // Proven through the door rather than off the table: a finding pathed + // `reports[0]…` can only come from a snapshot whose `reports` holds this + // write. + const result = runRuntimeAuthoringRules({ + type: 'report', + item: { name: 'acme_report', label: 'R', dataset: 'no_such_dataset' }, + context: CONTEXT, + }); + expect(result.errors.map((f) => f.path)).toContain('reports[0].dataset'); + }); +}); + +// ─── Group A · `action` ────────────────────────────────────────────────────── + +describe('#19542 — the `action` door (validateStackExpressions)', () => { + const action = (over: Record = {}) => ({ + name: 'acme_close', + label: 'Close', + objectName: 'acme_invoice', + type: 'script', + target: 'close', + ...over, + }); + + it('⭐ LIT — an action whose `visible` CEL does not parse is REFUSED', () => { + const result = runRuntimeAuthoringRules({ + type: 'action', + item: action({ visible: 'record.status ==' }), + context: CONTEXT, + }); + + expect(result.rulesRun).toContain('validateStackExpressions'); + expect(ruleIds(result.errors), dump(result)).toContain('expression-invalid'); + expect(result.errors[0]?.where).toContain("action 'acme_close'"); + }); + + it('⭐ LIT — an action bound to an object, naming a field it has not got, is REFUSED', () => { + // The field-resolution half, which only happens because the snapshot + // carries `objects`. `objectName` is the canonical binding key; the `object` + // spelling is an alias the strict schema renames one layer earlier, so it + // never reaches this gate. + const result = runRuntimeAuthoringRules({ + type: 'action', + item: action({ visible: 'record.no_such_field == true' }), + context: CONTEXT, + }); + + expect(ruleIds(result.errors), dump(result)).toContain('expression-invalid'); + expect(result.errors[0]?.message).toContain('no_such_field'); + }); + + it('⭐ CONTROL — a good action publishes clean', () => { + const result = runRuntimeAuthoringRules({ + type: 'action', + item: action({ visible: "record.status == 'open'" }), + context: CONTEXT, + }); + + expect(result.rulesRun).toContain('validateStackExpressions'); + expect(result.errors, dump(result)).toEqual([]); + expect(result.advisories, dump(result)).toEqual([]); + }); + + it('⭐ CONTROL — the shipped corpus action stays publishable', () => { + // `examples/app-crm` `crm_convert_lead`, verbatim, judged against its own + // object. A refusal widening whose population was never measured is how a + // door stops being usable. + const result = runRuntimeAuthoringRules({ + type: 'action', + item: { + name: 'crm_convert_lead', + label: 'Convert Lead', + objectName: 'crm_lead', + icon: 'ArrowRightCircle', + locations: ['list_item', 'record_header', 'record_more'], + type: 'flow', + target: 'crm_convert_lead_wizard', + refreshAfter: false, + visible: { dialect: 'cel', source: 'has(record.status) && record.status != "converted"' }, + recordIdParam: 'recordId', + }, + context: { + objects: [ + { + name: 'crm_lead', + label: 'Lead', + sharingModel: 'private', + fields: { status: { type: 'select', label: 'Status' } }, + }, + ], + }, + }); + + expect(result.errors, dump(result)).toEqual([]); + expect(result.advisories, dump(result)).toEqual([]); + }); +}); + +// ─── Group A · `hook` ──────────────────────────────────────────────────────── + +describe('#19542 — the `hook` door (validateStackExpressions)', () => { + const hook = (over: Record = {}) => ({ + name: 'acme_stamp', + object: 'acme_invoice', + events: ['beforeInsert'], + ...over, + }); + + it('⭐ LIT — a hook whose `condition` names a field the object has not got is REFUSED', () => { + const result = runRuntimeAuthoringRules({ + type: 'hook', + item: hook({ condition: 'record.no_such_field == true' }), + context: CONTEXT, + }); + + expect(result.rulesRun).toContain('validateStackExpressions'); + expect(ruleIds(result.errors), dump(result)).toContain('expression-invalid'); + expect(result.errors[0]?.message).toContain('no_such_field'); + expect(result.errors[0]?.where).toContain("hook 'acme_stamp'"); + }); + + it('⭐ LIT — a hook whose `condition` does not parse is REFUSED', () => { + const result = runRuntimeAuthoringRules({ + type: 'hook', + item: hook({ condition: 'record.done ==' }), + context: CONTEXT, + }); + + expect(ruleIds(result.errors), dump(result)).toContain('expression-invalid'); + }); + + it('⭐ CONTROL — a good hook publishes clean', () => { + const result = runRuntimeAuthoringRules({ + type: 'hook', + item: hook({ condition: "record.status == 'open'" }), + context: CONTEXT, + }); + + expect(result.rulesRun).toContain('validateStackExpressions'); + expect(result.errors, dump(result)).toEqual([]); + expect(result.advisories, dump(result)).toEqual([]); + }); + + it('⭐ CONTROL — the shipped corpus hook stays publishable', () => { + // `examples/app-showcase` `showcase_audit_task_completion`, verbatim. + const result = runRuntimeAuthoringRules({ + type: 'hook', + item: { + name: 'showcase_audit_task_completion', + object: 'showcase_task', + events: ['afterUpdate'], + condition: { dialect: 'cel', source: 'previous.done != true && record.done == true' }, + }, + context: { + objects: [ + { + name: 'showcase_task', + label: 'Task', + sharingModel: 'private', + fields: { done: { type: 'boolean', label: 'Done' } }, + }, + ], + }, + }); + + expect(result.errors, dump(result)).toEqual([]); + expect(result.advisories, dump(result)).toEqual([]); + }); +}); + +// ─── Group A · `report` ────────────────────────────────────────────────────── + +describe('#19542 — the `report` door (three rules, one document)', () => { + const report = (over: Record = {}) => ({ + name: 'acme_report', + label: 'Invoice Report', + type: 'summary', + dataset: 'acme_invoice_metrics', + rows: ['status'], + values: ['total_sum'], + ...over, + }); + + it('⭐ LIT — a report binding a dataset that does not exist is REFUSED', () => { + // `validateChartBindings`, the suite member crossed for `report`. It + // resolves against `stack.datasets`, the collection the snapshot carries. + const result = runRuntimeAuthoringRules({ + type: 'report', + item: report({ dataset: 'no_such_dataset' }), + context: CONTEXT, + }); + + expect(result.rulesRun).toContain('validateReferenceIntegrity'); + expect(ruleIds(result.errors), dump(result)).toContain('chart-dataset-unknown'); + }); + + it('⭐ LIT — a report grouping by a dimension its dataset does not declare is REFUSED', () => { + const result = runRuntimeAuthoringRules({ + type: 'report', + item: report({ rows: ['no_such_dimension'] }), + context: CONTEXT, + }); + + expect(ruleIds(result.errors), dump(result)).toContain('chart-dimension-unknown'); + }); + + it('⭐ LIT — a report whose filter is a literal empty combinator is REFUSED', () => { + // `validateEmptyCombinators`, crossed for `report` TOGETHER with + // `validatePresetComparands` below (#7220: the two judge the same authored + // filter literal on the same surface, so they cross or they do not). + const result = runRuntimeAuthoringRules({ + type: 'report', + item: report({ runtimeFilter: { $and: [] } }), + context: CONTEXT, + }); + + expect(result.rulesRun).toContain('validateEmptyCombinators'); + expect(ruleIds(result.errors), dump(result)).toContain('filter-empty-combinator'); + }); + + it('⭐ LIT — a report filtering by a dashboard date PRESET name is REFUSED', () => { + const result = runRuntimeAuthoringRules({ + type: 'report', + item: report({ runtimeFilter: { issued_on: { $gt: 'last_30_days' } } }), + context: CONTEXT, + }); + + expect(result.rulesRun).toContain('validatePresetComparands'); + expect(ruleIds(result.errors), dump(result)).toContain('filter-preset-comparand'); + }); + + it('⭐ CONTROL — a good report publishes clean', () => { + const result = runRuntimeAuthoringRules({ + type: 'report', + item: report({ runtimeFilter: { status: 'open' } }), + context: CONTEXT, + }); + + expect(result.rulesRun).toEqual( + expect.arrayContaining([ + 'validatePresetComparands', + 'validateEmptyCombinators', + 'validateReferenceIntegrity', + ]), + ); + expect(result.errors, dump(result)).toEqual([]); + expect(result.advisories, dump(result)).toEqual([]); + }); + + it('⭐ CONTROL — the shipped corpus report stays publishable', () => { + // `examples/app-todo` `completed_tasks` with its own `task_metrics` + // dataset, verbatim — a filter-carrying member of the measured population, + // so this control is not vacuous for the two filter rules either. + const result = runRuntimeAuthoringRules({ + type: 'report', + item: { + name: 'completed_tasks', + label: 'Completed Tasks', + description: 'All completed tasks with time tracking', + type: 'summary', + dataset: 'task_metrics', + rows: ['category'], + values: ['est_hours', 'actual_hours'], + runtimeFilter: { status: 'completed' }, + drilldown: true, + }, + context: { + objects: [ + { + name: 'todo_task', + label: 'Task', + sharingModel: 'private', + fields: { + status: { type: 'select', label: 'Status' }, + category: { type: 'text', label: 'Category' }, + estimated_hours: { type: 'number', label: 'Est' }, + actual_hours: { type: 'number', label: 'Actual' }, + }, + }, + ], + datasets: [ + { + name: 'task_metrics', + label: 'Task Metrics', + object: 'todo_task', + dimensions: [ + { name: 'status', label: 'Status', field: 'status', type: 'string' }, + { name: 'category', label: 'Category', field: 'category', type: 'string' }, + ], + measures: [ + { name: 'task_count', label: 'Tasks', aggregate: 'count' }, + { name: 'est_hours', label: 'Estimated Hours', aggregate: 'sum', field: 'estimated_hours' }, + { name: 'actual_hours', label: 'Actual Hours', aggregate: 'sum', field: 'actual_hours' }, + ], + }, + ], + }, + }); + + expect(result.errors, dump(result)).toEqual([]); + expect(result.advisories, dump(result)).toEqual([]); + }); +}); + +// ─── Group A · `skill` — HELD OUT, and this is the pin that holds it ──────── + +describe('#19542 — `skill` is held out of this landing, on a measurement', () => { + /** + * The card crossed `skill` in an earlier revision and the at-tier contract + * review returned FAIL on it. The ground was not that the limitation was + * missed — it was recorded and pinned in both directions — but that PINNING A + * MEASURED FALSEHOOD GREEN is itself the defect. These cases replace that pin + * with its opposite, so the reading cannot be quietly re-crossed. + * + * What was measured, on the SHIPPED corpus rather than a synthetic: + * `app-showcase` declares exactly one AI-exposed action and it exists at + * STACK level only (`showcase_portfolio_snapshot`; todo's six are mirrored + * under `objects[].actions`, so those resolve, and crm has none). A skill + * naming `action_showcase_portfolio_snapshot` was advised + * `ai-skill-tool-unresolved` at the door while the same rule over the whole + * stack answers `[]` — and the advisory reaches + * `SaveMetaItemResponseSchema.advisories`, which Studio renders, carrying a + * hint that prescribes exactly what the author had already done. + * + * Structurally: `collectToolUniverse` resolves into `stack.tools` and + * `stack.actions`, and the per-write snapshot carries NEITHER, so at this + * door the rule has no truthful `unresolved` verdict at all — only its clean + * answers are reliable. That is the ruling's own group B obstacle for `tool` + * («the `tools` universe rule … can only remove findings»), the same rule + * read from the other side, so `skill` takes group B's treatment: a reading + * first, not a wiring. Crossing it needs `actions` / `tools` carried in + * `RuntimeStackContext` + `CONTEXT_STACK_KEYS`, a + * `CLOSURE_CONTEXT_KEY_BY_TYPE` row and two more door gathers in + * `@objectstack/metadata-protocol` — a second package and its own card. + */ + it('⭐ DARK — a `skill` write dispatches NOTHING, and has no stack key', () => { + // Both absences together, because either alone is a half-state this whole + // file exists to refuse: a `runtimeTypes` declaration with no mapping runs + // on nothing, and a mapping with no declaration is inert. Held out means + // BOTH are absent — which is exactly what the gate reads as "not gated". + expect(runtimeAuthoringRulesFor('skill')).toEqual([]); + expect(stackKeyForType('skill')).toBeNull(); + expect(runtimeGatedTypes()).not.toContain('skill'); + }); + + it('⭐ DARK — `validateAiToolReferences` keeps the frozen `flow` default', () => { + // `['flow']` restated rather than imported: the suite's + // `DEFAULT_MEMBER_RUNTIME_TYPES` is module-private, and the assertion below + // is about the member NOT naming `skill`, which no default can supply. + const member = REFERENCE_INTEGRITY_RULES.find((r) => r.name === 'validateAiToolReferences'); + expect(member, 'the member left the suite — re-point this pin or retire it').toBeDefined(); + expect( + member!.runtimeTypes ?? ['flow'], + 'crossing this member needs `actions`/`tools` carried in RuntimeStackContext first — ' + + 'without them it advises `unresolved` about a reference that does resolve', + ).not.toContain('skill'); + }); + + it('⭐ LIT — the reason, reproduced: one skill, one rule, two universes', () => { + // The measurement that decided this, kept executable rather than described, + // so re-crossing the type without carrying the collections cannot look + // harmless. Left leg: the universe the door would hand the rule. Right leg: + // the same universe with `actions` present, as a whole stack has it. + const exposedAction = { + name: 'showcase_portfolio_snapshot', + label: 'Portfolio Snapshot', + type: 'script', + target: 'portfolioSnapshot', + ai: { exposed: true, description: 'Summarise the portfolio.' }, + }; + const writtenSkill = { + name: 'showcase_portfolio_skill', + label: 'Portfolio Skill', + description: 'Answers portfolio questions.', + tools: ['action_showcase_portfolio_snapshot'], + }; + const rule = REFERENCE_INTEGRITY_RULES.find((r) => r.name === 'validateAiToolReferences')!; + + // ⭐ The door's context shape is DERIVED, not restated. `actions` is handed + // to the real builder alongside `objects`; the builder carries only the + // collections `RuntimeStackContext` declares, so today it DROPS `actions` + // and the universe loses the `action_*` limb — which is the whole reading. + // + // ⛔ A hand-written `{ objects, skills }` here would pass for the wrong + // reason: measured, adding `actions` to `RuntimeStackContext` leaves such a + // pin green, so the day the obstacle is actually removed the pin would go + // on asserting a falsehood. Derived, this case goes RED on that change — + // which is the signal that `skill` can be crossed. + const doorContext = buildRuntimeWriteSnapshots({ + type: 'report', // any MAPPED type: what is wanted is the context half + item: { name: 'probe_report', label: 'Probe', type: 'summary' }, + context: { objects: OBJECTS, actions: [exposedAction] } as RuntimeStackContext, + })!.baseline; + expect( + Object.keys(doorContext), + 'if `actions` is carried now, the obstacle this block records is GONE — cross `skill` ' + + 'and replace these pins, rather than keeping a reading that no longer holds', + ).not.toContain('actions'); + + const atDoorShape = rule.run({ ...doorContext, skills: [writtenSkill] }, {}); + expect( + atDoorShape.map((f) => f.rule), + 'the snapshot shape yields the FALSE advisory — this is the reading that held `skill` out', + ).toContain('ai-skill-tool-unresolved'); + + // What the whole stack sees: same skill, same rule, clean. + const wholeStack = rule.run( + { objects: OBJECTS, actions: [exposedAction], skills: [writtenSkill] }, + {}, + ); + expect( + wholeStack, + 'lit control: given `stack.actions`, the reference resolves and the rule is silent', + ).toEqual([]); + }); +}); + +// ─── Group C · `email_template` and `mapping` ──────────────────────────────── + +describe('#19542 — group C: wired, dispatched, and silent by ledger (email_template · mapping)', () => { + /** + * ⚠️ Read the size of this group's proof before trusting it. + * + * Because these two rules judge NOTHING at this door, no behavioural case + * below can tell a RIGHT `TYPE_TO_STACK_KEY` key from a WRONG one: ablating + * `mapping: 'mappings'` to `'mapping'` — the `seed: 'data'` shape exactly — + * leaves every case in this block GREEN, and the same for + * `email_template: 'emailTemplates'`. What catches either is ONE string + * assertion, `each stack key is the collection the crossed rules actually + * read` in the first block of this file. + * + * So for group C the wiring is held by a TABLE PIN, not a door reading. That + * is acceptable under the ruling's fence (「the wiring is the whole + * deliverable」, ⛔ no ledger population) — but it is the honest size of the + * proof, and ⛔ that string assertion must not be deleted as redundant with + * these cases, because for these two rows it is the only thing there is. + */ + it.each(GROUP_C)('`%s` writes DO dispatch the ledger rule', (type) => { + const item = type === 'email_template' + ? { name: 'acme_welcome', label: 'Welcome', subject: 'Hi', bodyHtml: '

Hi

' } + : { name: 'acme_feed', label: 'Feed', sourceFormat: 'csv', targetObject: 'acme_invoice' }; + const result = runRuntimeAuthoringRules({ type, item, context: CONTEXT }); + + expect( + result.rulesRun, + `'${type}' must reach lintLivenessProperties — the wiring is the deliverable`, + ).toContain('lintLivenessProperties'); + }); + + it.each(GROUP_C)('`%s` — and the rule judges NOTHING today, because its ledger warns on nothing', (type) => { + // ⚠️ The report's first reading for these two, pinned rather than + // remembered. The ruling dispatched the wiring and ⛔ no ledger population, + // so this silence is the ruled end state. The day a property earns an + // `authorWarn` row the door lights up with no second edit — which is what + // the dispatch pin above is for. + const item = type === 'email_template' + ? { name: 'acme_welcome', label: 'Welcome', subject: 'Hi', bodyHtml: '

Hi

', category: 'workflow' } + : { name: 'acme_feed', label: 'Feed', sourceFormat: 'csv', targetObject: 'acme_invoice', mode: 'upsert' }; + const result = runRuntimeAuthoringRules({ type, item, context: CONTEXT }); + + expect(result.errors, dump(result)).toEqual([]); + expect(result.advisories, dump(result)).toEqual([]); + }); + + it('⭐ LIT CONTROL — the same instrument, in this same process, DOES fire on a ledger that warns', () => { + // Without this the two zeros above would be unreadable: a silent rule and + // an unresolvable ledger directory look identical from the outside. This + // asks `lintLivenessProperties` the one question whose answer is non-empty + // today (`object.externalSharingModel` is `authorWarn` in tree), so the + // zeros are attributable to the empty warn maps and to nothing else. + const findings = lintLivenessProperties({ + objects: [{ name: 'acme_invoice', externalSharingModel: 'read' }], + }); + + expect( + findings.map((f) => f.where + ' ' + f.message).join(' | '), + 'the ledger directory resolves and the rule produces findings here', + ).toContain('externalSharingModel'); + }); + + it('⭐ CONTROL — the shipped corpus email template and mapping stay publishable', () => { + // `examples/app-showcase`, verbatim — the whole authored population of + // both types across the example apps (1 each). + const email = runRuntimeAuthoringRules({ + type: 'email_template', + item: { + name: 'showcase_task_done_email', + label: 'Task Done Notification', + category: 'workflow', + locale: 'en-US', + subject: 'Task done: {{title}}', + bodyHtml: '

The task {{title}} was marked done.

', + bodyText: 'The task {{title}} was marked done.', + variables: [{ name: 'title', type: 'string', required: true, description: 'Task title' }], + active: true, + isSystem: false, + }, + context: CONTEXT, + }); + expect(email.errors, dump(email)).toEqual([]); + expect(email.advisories, dump(email)).toEqual([]); + + const mapping = runRuntimeAuthoringRules({ + type: 'mapping', + item: { + name: 'showcase_inquiry_feed', + label: 'Inquiry feed (marketing CSV)', + sourceFormat: 'csv', + targetObject: 'showcase_inquiry', + fieldMapping: [ + { source: 'Full Name', target: 'name', transform: 'none' }, + { source: 'E-mail', target: 'email', transform: 'none' }, + ], + mode: 'upsert', + upsertKey: ['email'], + }, + context: CONTEXT, + }); + expect(mapping.errors, dump(mapping)).toEqual([]); + expect(mapping.advisories, dump(mapping)).toEqual([]); + }); +}); + +// ─── The fences ────────────────────────────────────────────────────────────── + +describe('#19542 — DARK: what the five crossings did NOT widen', () => { + it('`action` and `hook` writes do NOT dispatch the reference-integrity suite', () => { + // The one crossing that would turn `runtime-lazy-deps.test.ts` tier 1 + // («typescript / sucrase load NEVER») from a standing fact into a red: the + // suite carries the four body-writes members, and an action/hook write is + // exactly the snapshot that WOULD carry an authored JS body for them to + // parse. Their bridge is `validateStackExpressions`, which judges CEL. + for (const type of ['action', 'hook'] as const) { + expect( + runtimeAuthoringRulesFor(type).map((r) => r.name), + `'${type}' writes must not reach the suite — the body-writes members parse JS`, + ).not.toContain('validateReferenceIntegrity'); + } + }); + + it('a `report` write reaches exactly ONE suite member, and a `skill` write none', () => { + // The entry-level `runtimeTypes` says which WRITES dispatch the suite; the + // per-member `runtimeTypes` says which MEMBERS judge that snapshot. Asked + // against the real table so a member widened without this file noticing + // fails here. + const membersFor = (type: string) => + REFERENCE_INTEGRITY_RULES.filter((m) => (m.runtimeTypes ?? ['flow']).includes(type)).map((m) => m.name); + + expect(membersFor('report')).toEqual(['validateChartBindings']); + // Held out — the entry does not dispatch a skill write and no member + // claims one, so the two halves agree. See the `skill` block above. + expect(membersFor('skill')).toEqual([]); + }); + + it('the ledger rule reaches ONLY the two types group C names', () => { + // `RUNTIME_OBJECT_ADVISORY_VOLUME` still holds for `object` — that reason + // is about ~8 advisories per object write rendered in Studio, and this + // crossing does not spend it. + const gated = runtimeGatedTypes().filter((t) => + runtimeAuthoringRulesFor(t).some((r) => r.name === 'lintLivenessProperties')); + expect(gated.sort()).toEqual(['email_template', 'mapping']); + }); + + it('crossing these five widened no OTHER type s roster', () => { + // The card is six metadata types, five of them wired. `translation` is the standing + // ungated control — its own ruling group (B) reads first. + expect(runtimeAuthoringRulesFor('translation')).toEqual([]); + expect(runtimeAuthoringRulesFor('doc')).toEqual([]); + expect(runtimeAuthoringRulesFor('external_catalog')).toEqual([]); + expect(runtimeAuthoringRulesFor('tool')).toEqual([]); + }); + + it('every crossed entry still answers the surface question as data', () => { + // The #4409 / #4463 mechanism, re-asked on exactly the entries this card + // edited: a rule on the runtime surface declares its types, and a rule off + // it records why. Silence is what both issues fixed. + const CROSSED = [ + 'validateStackExpressions', + 'validatePresetComparands', + 'validateEmptyCombinators', + 'validateReferenceIntegrity', + 'lintLivenessProperties', + ]; + for (const name of CROSSED) { + const entry = AUTHORING_RULES.find((r) => r.name === name); + expect(entry, `${name} left AUTHORING_RULES — re-point this pin or retire it`).toBeDefined(); + expect(entry!.surfaces).toContain('runtime-publish'); + expect((entry!.runtimeTypes ?? []).length, `${name} declares no runtime types`).toBeGreaterThan(0); + expect(entry!.surfaceReason, `${name} is on the runtime surface, so it owes no surfaceReason`) + .toBeUndefined(); + } + }); +}); diff --git a/packages/lint/src/runtime-gate.object-writes.test.ts b/packages/lint/src/runtime-gate.object-writes.test.ts index f4082d9307f..53d3771c02c 100644 --- a/packages/lint/src/runtime-gate.object-writes.test.ts +++ b/packages/lint/src/runtime-gate.object-writes.test.ts @@ -155,8 +155,39 @@ describe('the object write door dispatches at the adjudicated scope (#4716)', () expect(entry, `${name} left AUTHORING_RULES — re-point this fence or retire it`).toBeDefined(); expect(entry!.tier, `${name} changed tier — this fence pins the ADVISORY six; a severity ` + `change needs its own PR and re-opens the crossing question for the rule`).toBe('advisory'); - expect((entry!.surfaceReason ?? '').length, `${name} sits off the runtime surface with no ` - + `substantive reason`).toBeGreaterThanOrEqual(40); + // [#19542] The fence is about the OBJECT door, and until this card every + // fenced rule happened to be off the runtime surface ENTIRELY, so "is + // there a substantive `surfaceReason`?" was a faithful proxy for it. + // `lintLivenessProperties` crossed for `email_template` / `mapping` and + // broke the proxy without touching the thing it stood for — it owes no + // `surfaceReason` once it is on the surface, and the registry's own + // guard is what asks for one on a rule that is off it. + // + // So the fence now asks the NARROWER question, directly. ⚠️ Read that as + // what it is, because an earlier draft of this comment called it «the + // stronger arm» and that was false: `runtimeAuthoringRulesFor` IS + // `surfaces ∋ 'runtime-publish' ∧ runtimeTypes ∋ 'object'`, so the + // crossed arm is IMPLIED by the `atDoor.has(name) === false` assertion + // above and adds no discriminating power of its own. Against the clause + // it replaces it is strictly WEAKER: `surfaceReason ≥ 40`, with the + // wiring guard behind it, meant a fenced advisory rule could not sit on + // the runtime surface at all without prose; now one may cross to `view`, + // `permission` or `page` with no second pin. + // + // That relaxation is deliberate and is faithful to #4716's remit, which + // is the OBJECT door and nothing else — an object crossing is still + // refused here, pinned by ablation. What is NOT claimed is that the new + // arm guards anything the old one did beyond that door. A fenced rule + // crossing to some other door is a decision for whoever makes it; this + // fence no longer asks about it, and says so rather than implying it. + if (entry!.surfaces.includes('runtime-publish')) { + expect(entry!.runtimeTypes ?? [], `${name} is on the runtime surface AND declares ` + + `'object' — the #4716 adjudication fenced the advisory tier out of THIS door; ` + + `crossing it is a UX/volume decision with its own card`).not.toContain('object'); + } else { + expect((entry!.surfaceReason ?? '').length, `${name} sits off the runtime surface with no ` + + `substantive reason`).toBeGreaterThanOrEqual(40); + } } // And the five crossed rules really are gating tier — the exemption's // arithmetic (refusal risk lives only in `error`-capable rules) holds diff --git a/packages/lint/src/runtime-gate.ts b/packages/lint/src/runtime-gate.ts index fd75f17ce6e..0adae588e67 100644 --- a/packages/lint/src/runtime-gate.ts +++ b/packages/lint/src/runtime-gate.ts @@ -125,6 +125,48 @@ const TYPE_TO_STACK_KEY: Readonly> = { // `datasets[].object`. The door controls that prove each one fires live in // `runtime-gate.dataset-writes.test.ts`. dataset: 'datasets', + // [#19542] The three rows the ADR-0049 ruling 「declared ⇒ honoured; not + // honourable ⇒ retired」 needs for its groups A and C. `action` and `hook` + // above were already here and INERT — the gate filters by `runtimeTypes` + // before it consults this table, so a row with no declaring rule dispatches + // nothing — and the same ruling crosses their rules in this commit, which is + // what lights those two rows rather than any edit to them. + // + // ⛔ NOT mappings ahead of their rules (the `seed: 'data'` failure above). + // Each key below names a collection some rule crossed in THIS commit reads + // with the written item as the finding's SUBJECT: + // + // - `reports` — `validatePresetComparands` and `validateEmptyCombinators` + // scan it as a first-class authored-filter surface (`{ key: 'reports', + // kind: 'report' }`), and the suite member `validateChartBindings` opens + // with `recordsOf(stack.reports)` and resolves each report's dataset + // binding against `stack.datasets`, a collection the snapshot carries. + // - `emailTemplates` / `mappings` — `lintLivenessProperties` walks them + // through its own `TYPE_COLLECTIONS` rows, with `checkItem(type, item, …)` + // making the written item the subject. ⚠️ That rule is LEDGER-DRIVEN and + // `continue`s on an empty warn map: both ledgers carry 0 warn keys today, + // so these two rows are wired-and-silent BY CONSTRUCTION until a property + // needs a row. The ruling dispatched the wiring and ⛔ no ledger + // population («the empty warn maps stay empty until a real property needs + // a row»), so this is the ruled end state, not a half-landing. + // + // ⚠️ Read the size of that proof honestly, because it is smaller than the + // others on this table: since those two rules judge NOTHING at this door, + // no behavioural case can tell a right key from a wrong one, and a + // `mapping: 'mapping'` typo — the `seed: 'data'` shape exactly — leaves + // every group C door case GREEN. What catches it is one string assertion, + // `each stack key is the collection the crossed rules actually read` in + // `runtime-gate.inert-type-writes.test.ts`, measured by ablating both keys. + // A table pin, not a door reading. ⛔ Do not delete that assertion as + // redundant with the door cases — for these two rows it is the only proof. + // + // ⛔ `skill` is deliberately NOT here, and its absence is a measured reading + // rather than an omission — see the `validateAiToolReferences` member in + // `reference-integrity-suite.ts`, which carries the measurement and the + // reason, and the DARK pin that holds this row absent. + report: 'reports', + email_template: 'emailTemplates', + mapping: 'mappings', // [#19143 above, #19370 here] `position` / `app` — the two collections only // `security-role-word` judges. They arrive together with that rule's // crossing, never ahead of it: `DEFAULT_METADATA_TYPE_REGISTRY` declares both @@ -147,10 +189,30 @@ const TYPE_TO_STACK_KEY: Readonly> = { // and cancels, and a write's own collection already holds exactly one member // — its own item — so `positions[0]` IS this write and name-keying it would // say nothing the index does not (the `pages` reading in - // `runtime-gate.derived-name-keys.test.ts`). Eight of the twelve mappings + // `runtime-gate.derived-name-keys.test.ts`). Eleven of the fifteen mappings // above name a non-context key for the same reason — counted off this table, // where only `objects`, `permissions`, `books` and `datasets` are context - // collections. ⛔ Do not add a context row + // collections. (This sentence read «eight of the twelve» when the card that + // added the two rows above wrote it. That card is numbered 19370 — written + // WITHOUT the citation sigil on purpose, because it NO LONGER RESOLVES: it + // and its PR were filed by an account since banned, so both answer 404 while + // their work is landed and unaffected. A dead number dressed as a live link + // is the dangling reference `check:issue-citations` exists to refuse, so it + // is spelled as what it is: a historical card id. The live record is the + // merge commit `a227afa415f596269ed36aae0a0631c84270ccc9`, which carries + // that card's whole diff and is where its reasoning can still be read. + // ⛔ Do not re-point it at a rebuild — unlike its sibling below it has none, + // and guessing an upstream is how a dangling reference becomes a wrong one. + // + // «eight of the twelve» was true of the table it was written against; + // #19542's three rows landed in the same merge, so the count is restated + // against the merged table rather than left describing a table nobody has. + // It was briefly restated as «twelve of the sixteen», counted while #19542 + // still carried a fourth row for `skill` — that row was withdrawn and the + // count with it. The reasoning the sentence carries is unchanged throughout, + // and the figure is arithmetic over the rows above: ⛔ recount them rather + // than adjusting it.) + // ⛔ Do not add a context row // "for symmetry": `RuntimeStackContext`'s set is bounded by what the wired // rules RESOLVE (measured, not projected), and every member of it costs the // publish door one indexed `sys_metadata` read per write. diff --git a/packages/objectql/src/metadata-validation-sweep.test.ts b/packages/objectql/src/metadata-validation-sweep.test.ts index 7bb0071afd7..3f73cd262e5 100644 --- a/packages/objectql/src/metadata-validation-sweep.test.ts +++ b/packages/objectql/src/metadata-validation-sweep.test.ts @@ -44,6 +44,27 @@ import { function makeProtocol() { const registry = new SchemaRegistry({ multiTenant: false }); + // [#19542] The live resolution universe this harness hands the runtime + // publish gate. `find` is mocked to `[]` and nothing is ever read back, so + // without this seed the universe is permanently EMPTY — and since the + // report door opened, a `report` fixture must bind a dataset (ReportSchema + // refines it to required) that something can actually resolve. Seeding it + // here rather than saving it first is deliberate and matches the landed + // pattern in `protocol.dashboard-dataset-publish-gate.test.ts`: `creatable` + // below is SORTED, so a saved-first arrangement would silently depend on + // alphabetical order, and this harness never reads saves back anyway. + // + // ⛔ This is a TENANT the fixtures are judged against, not a relaxation: + // a report binding a dataset nobody declares is still refused, which is the + // #19542 door working. The dimension and measure names are exactly what the + // `report` fixture's `rows` / `values` select. + registry.registerItem('dataset', { + name: 'sweep_account_metrics', + label: 'Account Metrics', + object: 'sweep_account', + dimensions: [{ name: 'stage', label: 'Stage', field: 'stage', type: 'string' }], + measures: [{ name: 'amount_sum', label: 'Amount', aggregate: 'sum', field: 'amount' }], + }); const mockEngine: any = { registry, find: vi.fn().mockResolvedValue([]), @@ -73,7 +94,14 @@ const FIXTURES: Record = { // (`security-owd-unset` refuses absence), so a "valid" object // fixture must author its posture. sharingModel: 'private', - fields: { amount: { name: 'amount', label: 'Amount', type: 'number' } }, + // [#19542] `stage` joins `amount` so the dataset seeded into this + // harness's live universe is COHERENT with the object this tenant + // declares — its one dimension is over a field `sweep_account` really + // has. The `report` fixture groups by that dimension. + fields: { + amount: { name: 'amount', label: 'Amount', type: 'number' }, + stage: { name: 'stage', label: 'Stage', type: 'text' }, + }, }, invalid: { label: 'No Name' }, invalidatedField: 'name', @@ -150,6 +178,8 @@ const FIXTURES: Record = { }, report: { // ADR-0021 single-form: a report binds a dataset + selects values by name. + // The bound dataset is the fixture directly above, and its dimension and + // measure names are what `rows` / `values` select. valid: { name: 'sweep_report', label: 'Sweep', diff --git a/packages/objectql/src/overlay-precedence.test.ts b/packages/objectql/src/overlay-precedence.test.ts index 58fa0ff9284..7f2f04c7daf 100644 --- a/packages/objectql/src/overlay-precedence.test.ts +++ b/packages/objectql/src/overlay-precedence.test.ts @@ -75,6 +75,27 @@ const validReport = { function makeProtocol(opts: { environmentId?: string } = {}) { const registry = new SchemaRegistry({ multiTenant: false }); + // [#19542] The live resolution universe the runtime publish gate reads + // (`engine.registry.listItems`). `find` is mocked to `[]` below and nothing + // is read back, so without this seed the universe is permanently EMPTY — + // and since the report door opened, `validReport` binds a dataset that has + // to resolve against something. `ReportSchema` refines `dataset` to + // REQUIRED, so dropping the binding is not an option: a report either binds + // a dataset this tenant has, or it is not a valid report. + // + // ⛔ Not a relaxation of anything this file pins: the whitelist and hash + // assertions are untouched, and a report binding a dataset nobody declares + // is still refused at the door. This gives the fixture a tenant to be valid + // IN. Same pattern as `protocol.dashboard-dataset-publish-gate.test.ts`, + // and the same reason `validDashboard` above carries `widgets: []` — these + // fixtures exist to be ACCEPTED, so they must be authorable. + registry.registerItem('dataset', { + name: 'invoice_metrics', + label: 'Invoice Metrics', + object: 'invoice', + dimensions: [{ name: 'month', label: 'Month', field: 'invoice_date', type: 'date' }], + measures: [{ name: 'amount_sum', label: 'Amount', aggregate: 'sum', field: 'amount' }], + }); const mockEngine: any = { registry, find: vi.fn().mockResolvedValue([]), diff --git a/packages/rest/src/rest-server-meta-history-diff-org-scope.test.ts b/packages/rest/src/rest-server-meta-history-diff-org-scope.test.ts index 7ce88c6f9e6..7e80d1af2e0 100644 --- a/packages/rest/src/rest-server-meta-history-diff-org-scope.test.ts +++ b/packages/rest/src/rest-server-meta-history-diff-org-scope.test.ts @@ -277,7 +277,30 @@ function makeStubEngine() { async syncObjectSchema() { return true; }, registry: { registerItem: () => {}, registerObject: () => {}, - listItems: () => [], getItem: () => undefined, + // [#19542] The live resolution universe the runtime publish gate + // reads. It answered `[]` for every type, which was harmless while + // no rule judged the types this file writes — since the report door + // opened, `bodyFor('report')` binds `orders_ds` and + // `validateChartBindings` resolves that binding, so an empty + // universe refuses the fixture with `chart-dataset-unknown` before + // the READ this file exists to exercise is ever reached. + // + // ⛔ Not a relaxation: a report binding a dataset nobody declares is + // still refused. This gives the fixture a tenant to be valid in — + // `ReportSchema` refines `dataset` to REQUIRED, so there is no + // dataset-free report to fall back on. Its measure name is exactly + // what `bodyFor` selects in `values`. Same shape as the landed + // `protocol.dashboard-dataset-publish-gate.test.ts` double. + listItems: (type: string) => (type === 'dataset' + ? [{ + name: 'orders_ds', + label: 'Orders', + object: 'task', + dimensions: [{ name: 'status', label: 'Status', field: 'status', type: 'string' }], + measures: [{ name: 'order_count', label: 'Orders', aggregate: 'count' }], + }] + : []), + getItem: () => undefined, getObject: () => undefined, getPackage: () => undefined, getArtifactItem: () => undefined, isPackageDisabled: () => false, diff --git a/packages/rest/src/rest-server-meta-read-org-scope.test.ts b/packages/rest/src/rest-server-meta-read-org-scope.test.ts index 69e2926c0ba..29955672a1d 100644 --- a/packages/rest/src/rest-server-meta-read-org-scope.test.ts +++ b/packages/rest/src/rest-server-meta-read-org-scope.test.ts @@ -251,7 +251,30 @@ function makeStubEngine() { async syncObjectSchema() { return true; }, registry: { registerItem: () => {}, registerObject: () => {}, - listItems: () => [], getItem: () => undefined, + // [#19542] The live resolution universe the runtime publish gate + // reads. It answered `[]` for every type, which was harmless while + // no rule judged the types this file writes — since the report door + // opened, `bodyFor('report')` binds `orders_ds` and + // `validateChartBindings` resolves that binding, so an empty + // universe refuses the fixture with `chart-dataset-unknown` before + // the READ this file exists to exercise is ever reached. + // + // ⛔ Not a relaxation: a report binding a dataset nobody declares is + // still refused. This gives the fixture a tenant to be valid in — + // `ReportSchema` refines `dataset` to REQUIRED, so there is no + // dataset-free report to fall back on. Its measure name is exactly + // what `bodyFor` selects in `values`. Same shape as the landed + // `protocol.dashboard-dataset-publish-gate.test.ts` double. + listItems: (type: string) => (type === 'dataset' + ? [{ + name: 'orders_ds', + label: 'Orders', + object: 'task', + dimensions: [{ name: 'status', label: 'Status', field: 'status', type: 'string' }], + measures: [{ name: 'order_count', label: 'Orders', aggregate: 'count' }], + }] + : []), + getItem: () => undefined, getObject: () => undefined, getPackage: () => undefined, getArtifactItem: () => undefined, // The LIST door prunes items belonging to disabled packages; a