From d17dbc05ec57152b204280f921a6e7465d67ac45 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 04:14:31 +0000 Subject: [PATCH 01/10] wip(lint): wire the inert runtime-create rules for six types Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude --- packages/lint/src/authoring-rules.ts | 109 +++++++++++++++++- .../lint/src/reference-integrity-suite.ts | 46 +++++++- packages/lint/src/runtime-gate.ts | 35 ++++++ 3 files changed, 182 insertions(+), 8 deletions(-) diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 61e0fe10cf7..7be074c3c34 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. + // + // [#19474] `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', + // [#19474] `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,24 @@ 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. + // + // [#19474] `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. + // + // ⚠️ NOT MEASURED, and said rather than glossed: the shipped corpus's 9 + // reports (showcase 4, todo 5) carry NO `filters` block at all, so the + // 0-finding corpus reading is vacuous for this rule and for its sibling's + // report surface. What IS measured is the synthetic pair in + // `runtime-gate.inert-type-writes.test.ts` — one refused report, one lit + // control. 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 +902,24 @@ 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. + // [#19474] `skill` and `report` join under the ADR-0049 ruling, and the + // same granularity mechanism keeps both NARROW: this entry says which + // WRITES dispatch the suite, the suite's own per-member `runtimeTypes` says + // which MEMBERS judge that snapshot. A `skill` write reaches exactly + // `validateAiToolReferences`; a `report` write reaches exactly + // `validateChartBindings`. Every other member keeps its declaration. + // + // ⛔ `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', 'skill', 'report'], run: (stack, ctx) => validateReferenceIntegrity(stack, ctx), }, // ADR-0078 / #5068 — the SDUI component-props gate. `PageComponent.properties` @@ -1310,8 +1384,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, + // [#19474] 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..cf07cda319f 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 }, + // [#19474] `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,7 +460,33 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [ { name: 'validateTranslatableSections', run: validateTranslatableSections }, { name: 'validateFlowTemplatePaths', run: validateFlowTemplatePaths }, { name: 'validateAiSurfaceAffinity', run: validateAiSurfaceAffinity }, - { name: 'validateAiToolReferences', run: validateAiToolReferences }, + // [#19474] `runtimeTypes` gains `skill` under the ADR-0049 ruling — the + // measurement's named bridge for the type. It opens with + // `recordsOf(stack.skills)` and paths its findings `skills[si].tools[ti]`, + // so on a skill write the WRITTEN skill is the subject, not a universe for + // someone else's reference. + // + // ⚠️ Its tool universe is PARTIAL at this door, and that is a measured fact + // rather than an oversight: `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 object-level `action_` resolves) but + // no `tools` and no `actions` collection, so a skill naming a STACK-LEVEL + // declared tool or a stack-level `action_` reads as unresolved here + // while the same skill is clean on the whole stack. What bounds it: ADR-0109 + // states the default authoring path declares NO tool records at all, and + // this member is `severity: 'warning'` throughout — it advises, it can never + // refuse a publish. Both directions are pinned in + // `runtime-gate.inert-type-writes.test.ts` so the limitation is a recorded + // reading rather than a surprise. ⛔ Closing it means carrying `tools` / + // `actions` in `RuntimeStackContext`, which is an edit in + // `@objectstack/metadata-protocol`'s routing table as well — outside this + // card's file surface and its own decision. + // + // ⚠️ NOT MEASURED over a corpus: the shipped examples author ZERO skills, so + // there is no population to take a false-positive budget from. Said rather + // than glossed — the only evidence here is synthetic. + { name: 'validateAiToolReferences', runtimeTypes: ['flow', 'skill'], run: validateAiToolReferences }, { name: 'validateAiAgentAuthoring', run: validateAiAgentAuthoring }, // Field names WRITTEN by an L2 hook body (`ctx.input.x = …`, // `ctx.api.object('y').update({ x })`), resolved against the target object's diff --git a/packages/lint/src/runtime-gate.ts b/packages/lint/src/runtime-gate.ts index ce43f21ff60..06ccd366fe9 100644 --- a/packages/lint/src/runtime-gate.ts +++ b/packages/lint/src/runtime-gate.ts @@ -125,6 +125,41 @@ 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', + // [#19474] The four 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. + // - `skills` — `validateAiToolReferences` opens with `recordsOf(stack.skills)` + // and paths its findings `skills[si].tools[ti]`. + // - `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 — and + // `runtime-gate.inert-type-writes.test.ts` pins the silence so it stays a + // measured fact instead of an assumption. + // + // The door controls that prove what each row does — and, for the two above, + // what it does not — live in `runtime-gate.inert-type-writes.test.ts`. + report: 'reports', + skill: 'skills', + email_template: 'emailTemplates', + mapping: 'mappings', }; /** From 15db9c1c29da2b50f5c41c9c08a0e1940274df09 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 04:19:49 +0000 Subject: [PATCH 02/10] test(lint): behavioural door proof for the six wired runtime-create types Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude --- packages/lint/src/authoring-rules.ts | 11 +- .../runtime-gate.inert-type-writes.test.ts | 672 ++++++++++++++++++ 2 files changed, 677 insertions(+), 6 deletions(-) create mode 100644 packages/lint/src/runtime-gate.inert-type-writes.test.ts diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 7be074c3c34..68e6874225c 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -792,12 +792,11 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ // the door. ⛔ The other four filter-carrying types are untouched here — // this card is the six `allowRuntimeCreate` types, not P2's remainder. // - // ⚠️ NOT MEASURED, and said rather than glossed: the shipped corpus's 9 - // reports (showcase 4, todo 5) carry NO `filters` block at all, so the - // 0-finding corpus reading is vacuous for this rule and for its sibling's - // report surface. What IS measured is the synthetic pair in - // `runtime-gate.inert-type-writes.test.ts` — one refused report, one lit - // control. + // 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', 'report'], run: (stack) => validateEmptyCombinators(stack), 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..cf40ac1b7e3 --- /dev/null +++ b/packages/lint/src/runtime-gate.inert-type-writes.test.ts @@ -0,0 +1,672 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #19474 — the six write doors that dispatched NOTHING, under the ADR-0049 + * ruling 「declared ⇒ honoured; not honourable ⇒ retired」. + * + * ## 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 type below has BOTH legs through the real door: a write the wired + * 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. + * + * ## ⚠️ Two of the six are wired-and-SILENT, on purpose, and that is pinned too + * + * `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. + */ +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 { + runRuntimeAuthoringRules, + runtimeAuthoringRulesFor, + runtimeGatedTypes, + stackKeyForType, +} from './runtime-gate.js'; + +/** The six types this card wires, in the ruling's own grouping order. */ +const GROUP_A = ['action', 'hook', 'report', 'skill'] 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 why + // `action_acme_ping` resolves for a skill write and a stack-level action + // does not — see the partial-universe reading 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('#19474 — the six 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'); + expect(stackKeyForType('skill')).toBe('skills'); + expect(stackKeyForType('email_template')).toBe('emailTemplates'); + expect(stackKeyForType('mapping')).toBe('mappings'); + }); + + 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('#19474 — 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('#19474 — 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('#19474 — 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` ─────────────────────────────────────────────────────── + +describe('#19474 — the `skill` door (validateAiToolReferences)', () => { + const skill = (tools: string[]) => ({ + name: 'acme_skill', + label: 'Invoice Skill', + description: 'Answers invoice questions.', + tools, + }); + + it('⭐ LIT — a skill naming a tool nothing can resolve IS judged (advisory, never a refusal)', () => { + // The whole reading for this type, stated rather than implied: the rule + // FIRES on a real write — which is the ruling's own NOT MEASURED item — + // and it fires at `warning`, so it advises and can never block a publish. + const result = runRuntimeAuthoringRules({ + type: 'skill', + item: skill(['forecast_revenue']), + context: CONTEXT, + }); + + expect(result.rulesRun).toContain('validateReferenceIntegrity'); + expect(result.errors, 'this member is warning-tier throughout').toEqual([]); + expect(ruleIds(result.advisories), dump(result)).toContain('ai-skill-tool-unresolved'); + expect(result.advisories[0]?.path).toBe('skills[0].tools[0]'); + }); + + it('⭐ CONTROL — a skill naming a PLATFORM tool publishes clean', () => { + const result = runRuntimeAuthoringRules({ + type: 'skill', + item: skill(['query_data']), + context: CONTEXT, + }); + + expect(result.rulesRun).toContain('validateReferenceIntegrity'); + expect(result.errors, dump(result)).toEqual([]); + expect(result.advisories, dump(result)).toEqual([]); + }); + + it('⭐ CONTROL — a skill naming an OBJECT-level action tool publishes clean', () => { + // The action family resolves here because the materialised universe is + // built from `stack.actions` ∪ every object's `actions`, and `objects` is + // a collection the snapshot carries. + const result = runRuntimeAuthoringRules({ + type: 'skill', + item: skill(['action_acme_ping']), + context: CONTEXT, + }); + + expect(result.errors, dump(result)).toEqual([]); + expect(result.advisories, dump(result)).toEqual([]); + }); + + it('⚠️ RECORDED — the tool universe is PARTIAL at this door, and this is what that costs', () => { + // Not a wish: the measured shape of the crossing. `collectToolUniverse` + // unions the platform registry ∪ `stack.tools` ∪ the action family from + // `stack.actions` and every object's `actions`. A per-write snapshot + // carries 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. + // + // Pinned in BOTH directions so it can only change deliberately: the same + // skill, same rule, judged once at the door and once on a stack that + // carries the collection. + const atTheDoor = runRuntimeAuthoringRules({ + type: 'skill', + item: skill(['acme_declared_tool']), + context: CONTEXT, + }); + expect( + ruleIds(atTheDoor.advisories), + 'the door cannot see `stack.tools`, so it advises — ADR-0109 says the default authoring ' + + 'path declares no tool records, and this member can never refuse, which is what bounds it', + ).toContain('ai-skill-tool-unresolved'); + + const onTheWholeStack = REFERENCE_INTEGRITY_RULES.find((r) => r.name === 'validateAiToolReferences')! + .run({ skills: [skill(['acme_declared_tool'])], tools: [{ name: 'acme_declared_tool' }] }, {}); + expect( + onTheWholeStack, + 'lit control on the same rule: given the collection, the same reference resolves', + ).toEqual([]); + }); +}); + +// ─── Group C · `email_template` and `mapping` ──────────────────────────────── + +describe('#19474 — group C: wired, dispatched, and silent by ledger (email_template · mapping)', () => { + 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('#19474 — DARK: what the six 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 `skill` write reaches exactly ONE suite member, and a `report` write exactly one', () => { + // 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('skill')).toEqual(['validateAiToolReferences']); + expect(membersFor('report')).toEqual(['validateChartBindings']); + }); + + 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 six widened no OTHER type s roster', () => { + // The card is six metadata types. `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(); + } + }); +}); From f47be86271764014ff3d984e9908e970a38418c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 04:26:57 +0000 Subject: [PATCH 03/10] chore(lint): changeset, and re-point the #4716 Q2 fence at the object door directly Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude --- .../19474-six-inert-runtime-create-doors.md | 38 +++++++++++++++++++ .../src/runtime-gate.object-writes.test.ts | 24 +++++++++++- 2 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 .changeset/19474-six-inert-runtime-create-doors.md 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..2b3dab0b71f --- /dev/null +++ b/.changeset/19474-six-inert-runtime-create-doors.md @@ -0,0 +1,38 @@ +--- +"@objectstack/lint": minor +--- + +Six metadata write doors now judge what walks through them. `action`, `hook`, `report`, `skill`, `email_template` and `mapping` each declared `allowRuntimeCreate: true` and reached ZERO author-time rules at the runtime publish gate; they now dispatch the rules that were already written for them (#19474). + +Each of the six 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 the six 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 the six 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 `skill`**, and its per-member axis admits exactly ONE member each: `validateChartBindings` for a report (it resolves the report's `dataset` / `rows` / `columns` / `values` against `stack.datasets`, a carried collection) and `validateAiToolReferences` for a skill. +- **`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` / `skill` / `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`, `ai-skill-tool-unresolved`) 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. `skill` has **no measured budget**: the example corpus authors zero skills, so the only evidence for that type is synthetic, and it is said here rather than glossed. + +## 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. + +**A `skill` write is judged with a PARTIAL tool universe.** `validateAiToolReferences` resolves against 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_` resolves) but neither `tools` nor `actions`, so a skill naming a STACK-LEVEL declared tool reads as unresolved at this door while the same skill 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. + +## Migration + +**A publish of an `action`, `hook` or `report` that used to succeed can now be refused (HTTP 422, `INVALID_METADATA`).** The receipt names the rule id and the offending path plus the string that was written. To clear a refusal: + +| Refusal | Fix | +| --- | --- | +| `expression-invalid` on an action's `visible` / `disabled`, or a hook's `condition` | Correct the CEL. A syntax fault is named with its position; an `unknown field` names the object it was resolved against — point the ref at a column that object declares, or bind the action with `objectName`. | +| `chart-dataset-unknown` / `chart-dimension-unknown` on a report | Point `dataset` at a declared dataset, and `rows` / `columns` / `values` at dimension and measure NAMES that dataset declares (post-ADR-0021 result rows are keyed by dimension name, not by the base field). | +| `filter-empty-combinator` on a report filter | Delete the empty `$and: []` / `$or: []` / `$not: {}` key — it constrains nothing. | +| `filter-preset-comparand` on a report filter | A dashboard date-range PRESET name (`last_30_days`, …) is not a filter value; write a real comparand. | + +A `skill` write is never refused by this change — it can only gain an advisory. `email_template` and `mapping` writes are unchanged in behaviour today. + +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/runtime-gate.object-writes.test.ts b/packages/lint/src/runtime-gate.object-writes.test.ts index b58e732d63b..684cc284b10 100644 --- a/packages/lint/src/runtime-gate.object-writes.test.ts +++ b/packages/lint/src/runtime-gate.object-writes.test.ts @@ -145,8 +145,28 @@ 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); + // [#19474] 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 question directly, and the crossed arm is + // the STRONGER of the two: a declared `runtimeTypes` that omits `object` + // is mechanical, where a `surfaceReason` is prose that can go stale. + // ⛔ Neither arm is a way around the fence — `object` appearing in + // `runtimeTypes` fails here exactly as it did before, and the + // `atDoor.has(name)` assertion above is unchanged. + 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 From 1662df50b502c664019d0ffd6ba939c9797a65d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 06:17:04 +0000 Subject: [PATCH 04/10] fix(lint): hold `skill` out of the runtime-create wiring, and declare the changeset breaking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The at-tier contract review returned FAIL on two grounds. Ground 1 — the `skill` door shipped a false advisory on the ADR-0109 default path and the tests pinned it green. `validateAiToolReferences` resolves into `stack.tools` and `stack.actions`; a per-write snapshot carries neither, so at that door the rule has no truthful `unresolved` verdict at all. Measured on the shipped corpus: app-showcase's only AI-exposed action is stack-level, and a skill naming it is advised unresolved at the door while the same rule over the whole stack answers clean -- an advisory that reaches Studio and prescribes what the author had already done. `skill` is held out of this landing and takes the ruling's group B treatment of `tool`, the same universe obstacle read from the other side. Both halves of the wiring are held ABSENT by pins and the measurement is kept executable beside them. Group A lands as three types. Ground 2 -- the changeset now opens with the BREAKING-for-runtime-metadata-writes banner, declares `Clause-②: no (narrowing)` and carries one ADR-0087 marker. Also corrected, flagged but not a ground: the re-pointed #4716 Q2 fence comment claimed the new arm was stronger. It is implied by the `atDoor` assertion and is strictly weaker than the clause it replaced; the comment now says so, and why the relaxation is still faithful to that fence's object-door remit. And group C's proof is recorded where a reader meets it as a table pin, not a door reading. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude --- .../19474-six-inert-runtime-create-doors.md | 20 +- packages/lint/src/authoring-rules.ts | 20 +- .../lint/src/reference-integrity-suite.ts | 63 +++-- .../runtime-gate.inert-type-writes.test.ts | 239 +++++++++++------- .../src/runtime-gate.object-writes.test.ts | 23 +- packages/lint/src/runtime-gate.ts | 25 +- 6 files changed, 245 insertions(+), 145 deletions(-) diff --git a/.changeset/19474-six-inert-runtime-create-doors.md b/.changeset/19474-six-inert-runtime-create-doors.md index 2b3dab0b71f..4e576282d9b 100644 --- a/.changeset/19474-six-inert-runtime-create-doors.md +++ b/.changeset/19474-six-inert-runtime-create-doors.md @@ -2,25 +2,27 @@ "@objectstack/lint": minor --- -Six metadata write doors now judge what walks through them. `action`, `hook`, `report`, `skill`, `email_template` and `mapping` each declared `allowRuntimeCreate: true` and reached ZERO author-time rules at the runtime publish gate; they now dispatch the rules that were already written for them (#19474). +**BREAKING for runtime metadata writes** — five metadata write doors now judge what walks through them. `action`, `hook`, `report`, `email_template` and `mapping` each declared `allowRuntimeCreate: true` and reached ZERO author-time rules at the runtime publish gate; they now dispatch the rules already written for them, so an `action`, `hook` or `report` publish that used to succeed can now be refused (#19474) -Each of the six 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 the six 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 the six 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. +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 `skill`**, and its per-member axis admits exactly ONE member each: `validateChartBindings` for a report (it resolves the report's `dataset` / `rows` / `columns` / `values` against `stack.datasets`, a carried collection) and `validateAiToolReferences` for a skill. +- **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` / `skill` / `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`, `ai-skill-tool-unresolved`) 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. `skill` has **no measured budget**: the example corpus authors zero skills, so the only evidence for that type is synthetic, and it is said here rather than glossed. +- **`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. -**A `skill` write is judged with a PARTIAL tool universe.** `validateAiToolReferences` resolves against 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_` resolves) but neither `tools` nor `actions`, so a skill naming a STACK-LEVEL declared tool reads as unresolved at this door while the same skill 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. +**`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. ## Migration @@ -33,6 +35,8 @@ ADR-0049's 「声明即强制」 admits two resolutions — honour the declarati | `filter-empty-combinator` on a report filter | Delete the empty `$and: []` / `$or: []` / `$not: {}` key — it constrains nothing. | | `filter-preset-comparand` on a report filter | A dashboard date-range PRESET name (`last_30_days`, …) is not a filter value; write a real comparand. | -A `skill` write is never refused by this change — it can only gain an advisory. `email_template` and `mapping` writes are unchanged in behaviour today. +`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 a1a85b941c5..6a975006096 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -901,13 +901,21 @@ 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. - // [#19474] `skill` and `report` join under the ADR-0049 ruling, and the - // same granularity mechanism keeps both NARROW: this entry says which - // WRITES dispatch the suite, the suite's own per-member `runtimeTypes` says - // which MEMBERS judge that snapshot. A `skill` write reaches exactly - // `validateAiToolReferences`; a `report` write reaches exactly + // [#19474] `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 @@ -918,7 +926,7 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ // measurement that named their bridge named `validateStackExpressions`, a // CEL-only rule, for exactly this reason. surfaces: CLI_AND_RUNTIME, - runtimeTypes: ['flow', 'view', 'object', 'dataset', 'skill', 'report'], + runtimeTypes: ['flow', 'view', 'object', 'dataset', 'report'], run: (stack, ctx) => validateReferenceIntegrity(stack, ctx), }, // ADR-0078 / #5068 — the SDUI component-props gate. `PageComponent.properties` diff --git a/packages/lint/src/reference-integrity-suite.ts b/packages/lint/src/reference-integrity-suite.ts index cf07cda319f..1e45707bbd8 100644 --- a/packages/lint/src/reference-integrity-suite.ts +++ b/packages/lint/src/reference-integrity-suite.ts @@ -460,33 +460,46 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [ { name: 'validateTranslatableSections', run: validateTranslatableSections }, { name: 'validateFlowTemplatePaths', run: validateFlowTemplatePaths }, { name: 'validateAiSurfaceAffinity', run: validateAiSurfaceAffinity }, - // [#19474] `runtimeTypes` gains `skill` under the ADR-0049 ruling — the - // measurement's named bridge for the type. It opens with - // `recordsOf(stack.skills)` and paths its findings `skills[si].tools[ti]`, - // so on a skill write the WRITTEN skill is the subject, not a universe for - // someone else's reference. + // [#19474] 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. // - // ⚠️ Its tool universe is PARTIAL at this door, and that is a measured fact - // rather than an oversight: `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 object-level `action_` resolves) but - // no `tools` and no `actions` collection, so a skill naming a STACK-LEVEL - // declared tool or a stack-level `action_` reads as unresolved here - // while the same skill is clean on the whole stack. What bounds it: ADR-0109 - // states the default authoring path declares NO tool records at all, and - // this member is `severity: 'warning'` throughout — it advises, it can never - // refuse a publish. Both directions are pinned in - // `runtime-gate.inert-type-writes.test.ts` so the limitation is a recorded - // reading rather than a surprise. ⛔ Closing it means carrying `tools` / - // `actions` in `RuntimeStackContext`, which is an edit in - // `@objectstack/metadata-protocol`'s routing table as well — outside this - // card's file surface and its own decision. + // `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. // - // ⚠️ NOT MEASURED over a corpus: the shipped examples author ZERO skills, so - // there is no population to take a false-positive budget from. Said rather - // than glossed — the only evidence here is synthetic. - { name: 'validateAiToolReferences', runtimeTypes: ['flow', 'skill'], run: validateAiToolReferences }, + // ⚠️ 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 = …`, // `ctx.api.object('y').update({ x })`), resolved against the target object's diff --git a/packages/lint/src/runtime-gate.inert-type-writes.test.ts b/packages/lint/src/runtime-gate.inert-type-writes.test.ts index cf40ac1b7e3..7c115a2aff1 100644 --- a/packages/lint/src/runtime-gate.inert-type-writes.test.ts +++ b/packages/lint/src/runtime-gate.inert-type-writes.test.ts @@ -2,7 +2,9 @@ /** * #19474 — the six write doors that dispatched NOTHING, under the ADR-0049 - * ruling 「declared ⇒ honoured; not honourable ⇒ retired」. + * 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 * @@ -21,13 +23,23 @@ * 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 type below has BOTH legs through the real door: a write the wired + * 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. * - * ## ⚠️ Two of the six are wired-and-SILENT, on purpose, and that is pinned too + * ## ⚠️ `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 @@ -36,7 +48,10 @@ * 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. + * 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'; @@ -50,8 +65,15 @@ import { stackKeyForType, } from './runtime-gate.js'; -/** The six types this card wires, in the ruling's own grouping order. */ -const GROUP_A = ['action', 'hook', 'report', 'skill'] as const; +/** + * 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; /** @@ -70,9 +92,10 @@ const OBJECTS = [ total: { type: 'currency', label: 'Total' }, done: { type: 'boolean', label: 'Done' }, }, - // An OBJECT-level AI-exposed action. It rides in on `objects`, which is why - // `action_acme_ping` resolves for a skill write and a stack-level action - // does not — see the partial-universe reading below. + // 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', @@ -103,7 +126,7 @@ const dump = (r: { errors: readonly unknown[]; advisories: readonly unknown[] }) // ───────────────────────────────────────────────────────────────────────────── -describe('#19474 — the six doors are declared, mapped and reachable', () => { +describe('#19474 — 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 @@ -120,9 +143,13 @@ describe('#19474 — the six doors are declared, mapped and reachable', () => { expect(stackKeyForType('action')).toBe('actions'); expect(stackKeyForType('hook')).toBe('hooks'); expect(stackKeyForType('report')).toBe('reports'); - expect(stackKeyForType('skill')).toBe('skills'); + // ⭐ 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', () => { @@ -430,85 +457,96 @@ describe('#19474 — the `report` door (three rules, one document)', () => { }); }); -// ─── Group A · `skill` ─────────────────────────────────────────────────────── - -describe('#19474 — the `skill` door (validateAiToolReferences)', () => { - const skill = (tools: string[]) => ({ - name: 'acme_skill', - label: 'Invoice Skill', - description: 'Answers invoice questions.', - tools, - }); - - it('⭐ LIT — a skill naming a tool nothing can resolve IS judged (advisory, never a refusal)', () => { - // The whole reading for this type, stated rather than implied: the rule - // FIRES on a real write — which is the ruling's own NOT MEASURED item — - // and it fires at `warning`, so it advises and can never block a publish. - const result = runRuntimeAuthoringRules({ - type: 'skill', - item: skill(['forecast_revenue']), - context: CONTEXT, - }); - - expect(result.rulesRun).toContain('validateReferenceIntegrity'); - expect(result.errors, 'this member is warning-tier throughout').toEqual([]); - expect(ruleIds(result.advisories), dump(result)).toContain('ai-skill-tool-unresolved'); - expect(result.advisories[0]?.path).toBe('skills[0].tools[0]'); - }); - - it('⭐ CONTROL — a skill naming a PLATFORM tool publishes clean', () => { - const result = runRuntimeAuthoringRules({ - type: 'skill', - item: skill(['query_data']), - context: CONTEXT, - }); - - expect(result.rulesRun).toContain('validateReferenceIntegrity'); - expect(result.errors, dump(result)).toEqual([]); - expect(result.advisories, dump(result)).toEqual([]); - }); - - it('⭐ CONTROL — a skill naming an OBJECT-level action tool publishes clean', () => { - // The action family resolves here because the materialised universe is - // built from `stack.actions` ∪ every object's `actions`, and `objects` is - // a collection the snapshot carries. - const result = runRuntimeAuthoringRules({ - type: 'skill', - item: skill(['action_acme_ping']), - context: CONTEXT, - }); - - expect(result.errors, dump(result)).toEqual([]); - expect(result.advisories, dump(result)).toEqual([]); - }); - - it('⚠️ RECORDED — the tool universe is PARTIAL at this door, and this is what that costs', () => { - // Not a wish: the measured shape of the crossing. `collectToolUniverse` - // unions the platform registry ∪ `stack.tools` ∪ the action family from - // `stack.actions` and every object's `actions`. A per-write snapshot - // carries 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. - // - // Pinned in BOTH directions so it can only change deliberately: the same - // skill, same rule, judged once at the door and once on a stack that - // carries the collection. - const atTheDoor = runRuntimeAuthoringRules({ - type: 'skill', - item: skill(['acme_declared_tool']), - context: CONTEXT, - }); +// ─── Group A · `skill` — HELD OUT, and this is the pin that holds it ──────── + +describe('#19474 — `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( - ruleIds(atTheDoor.advisories), - 'the door cannot see `stack.tools`, so it advises — ADR-0109 says the default authoring ' - + 'path declares no tool records, and this member can never refuse, which is what bounds it', + 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')!; + + // What a per-write snapshot can see: `objects` only. The action is + // stack-level, so the `action_*` limb of the universe is simply not there. + const atDoorShape = rule.run({ objects: OBJECTS, 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'); - const onTheWholeStack = REFERENCE_INTEGRITY_RULES.find((r) => r.name === 'validateAiToolReferences')! - .run({ skills: [skill(['acme_declared_tool'])], tools: [{ name: 'acme_declared_tool' }] }, {}); + // What the whole stack sees: same skill, same rule, clean. + const wholeStack = rule.run( + { objects: OBJECTS, actions: [exposedAction], skills: [writtenSkill] }, + {}, + ); expect( - onTheWholeStack, - 'lit control on the same rule: given the collection, the same reference resolves', + wholeStack, + 'lit control: given `stack.actions`, the reference resolves and the rule is silent', ).toEqual([]); }); }); @@ -516,6 +554,23 @@ describe('#19474 — the `skill` door (validateAiToolReferences)', () => { // ─── Group C · `email_template` and `mapping` ──────────────────────────────── describe('#19474 — 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

' } @@ -604,7 +659,7 @@ describe('#19474 — group C: wired, dispatched, and silent by ledger (email_tem // ─── The fences ────────────────────────────────────────────────────────────── -describe('#19474 — DARK: what the six crossings did NOT widen', () => { +describe('#19474 — 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 @@ -619,7 +674,7 @@ describe('#19474 — DARK: what the six crossings did NOT widen', () => { } }); - it('a `skill` write reaches exactly ONE suite member, and a `report` write exactly one', () => { + 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 @@ -627,8 +682,10 @@ describe('#19474 — DARK: what the six crossings did NOT widen', () => { const membersFor = (type: string) => REFERENCE_INTEGRITY_RULES.filter((m) => (m.runtimeTypes ?? ['flow']).includes(type)).map((m) => m.name); - expect(membersFor('skill')).toEqual(['validateAiToolReferences']); 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', () => { @@ -640,8 +697,8 @@ describe('#19474 — DARK: what the six crossings did NOT widen', () => { expect(gated.sort()).toEqual(['email_template', 'mapping']); }); - it('crossing these six widened no OTHER type s roster', () => { - // The card is six metadata types. `translation` is the standing + 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([]); diff --git a/packages/lint/src/runtime-gate.object-writes.test.ts b/packages/lint/src/runtime-gate.object-writes.test.ts index 510773c347d..bf28352989c 100644 --- a/packages/lint/src/runtime-gate.object-writes.test.ts +++ b/packages/lint/src/runtime-gate.object-writes.test.ts @@ -163,12 +163,23 @@ describe('the object write door dispatches at the adjudicated scope (#4716)', () // `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 question directly, and the crossed arm is - // the STRONGER of the two: a declared `runtimeTypes` that omits `object` - // is mechanical, where a `surfaceReason` is prose that can go stale. - // ⛔ Neither arm is a way around the fence — `object` appearing in - // `runtimeTypes` fails here exactly as it did before, and the - // `atDoor.has(name)` assertion above is unchanged. + // 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; ` diff --git a/packages/lint/src/runtime-gate.ts b/packages/lint/src/runtime-gate.ts index af1a3c2ff72..5e1f85cd191 100644 --- a/packages/lint/src/runtime-gate.ts +++ b/packages/lint/src/runtime-gate.ts @@ -125,7 +125,7 @@ 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', - // [#19474] The four rows the ADR-0049 ruling 「declared ⇒ honoured; not + // [#19474] 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 @@ -141,8 +141,6 @@ const TYPE_TO_STACK_KEY: Readonly> = { // 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. - // - `skills` — `validateAiToolReferences` opens with `recordsOf(stack.skills)` - // and paths its findings `skills[si].tools[ti]`. // - `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 @@ -150,14 +148,23 @@ const TYPE_TO_STACK_KEY: Readonly> = { // 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 — and - // `runtime-gate.inert-type-writes.test.ts` pins the silence so it stays a - // measured fact instead of an assumption. + // a row»), so this is the ruled end state, not a half-landing. // - // The door controls that prove what each row does — and, for the two above, - // what it does not — live in `runtime-gate.inert-type-writes.test.ts`. + // ⚠️ 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', - skill: 'skills', email_template: 'emailTemplates', mapping: 'mappings', // [#19143 above, #19370 here] `position` / `app` — the two collections only From 72de2b946301aa59f624da807ae7fc383e82b81e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 06:18:33 +0000 Subject: [PATCH 05/10] docs(changeset): state the refusal set as prose, not a rewrite table `check-adr-0087-registration` refused `not-required (no-migration-prescription)` against the framed FROM/TO table the changeset carried: a changeset shipping instructions for rewriting a consumer's content cannot also claim no consumer rewrites anything. The claim was the thing that was wrong shape, not the marker. Nothing here is retired or renamed and there is no old spelling to translate: every defect now refused at the runtime door was ALREADY refused by `os build`, `os validate` and `os lint`, with the same rule id, severity and fix-it text. Only the set of doors widens. The section now states which writes join the refusal set -- the shape the sibling #19370 changeset uses -- and says plainly that the rule's own hint carries the correction at the moment of refusal, so there is nothing for `objectstack migrate meta` to reach. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude --- .../19474-six-inert-runtime-create-doors.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/.changeset/19474-six-inert-runtime-create-doors.md b/.changeset/19474-six-inert-runtime-create-doors.md index 4e576282d9b..887641a31d4 100644 --- a/.changeset/19474-six-inert-runtime-create-doors.md +++ b/.changeset/19474-six-inert-runtime-create-doors.md @@ -24,16 +24,17 @@ ADR-0049's 「声明即强制」 admits two resolutions — honour the declarati **`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. -## Migration +## The refusal set grows — and there is no FROM → TO, because nothing changed spelling -**A publish of an `action`, `hook` or `report` that used to succeed can now be refused (HTTP 422, `INVALID_METADATA`).** The receipt names the rule id and the offending path plus the string that was written. To clear a refusal: +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: -| Refusal | Fix | -| --- | --- | -| `expression-invalid` on an action's `visible` / `disabled`, or a hook's `condition` | Correct the CEL. A syntax fault is named with its position; an `unknown field` names the object it was resolved against — point the ref at a column that object declares, or bind the action with `objectName`. | -| `chart-dataset-unknown` / `chart-dimension-unknown` on a report | Point `dataset` at a declared dataset, and `rows` / `columns` / `values` at dimension and measure NAMES that dataset declares (post-ADR-0021 result rows are keyed by dimension name, not by the base field). | -| `filter-empty-combinator` on a report filter | Delete the empty `$and: []` / `$or: []` / `$not: {}` key — it constrains nothing. | -| `filter-preset-comparand` on a report filter | A dashboard date-range PRESET name (`last_30_days`, …) is not a filter value; write a real comparand. | +- 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. From 80827521b55224601f0b0feb297088fa4d691629 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 09:27:17 +0000 Subject: [PATCH 06/10] fix(objectql): give two report fixtures a tenant to be valid in; correct a false count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test Core shards 4/6 and 5/6 were red on the previous head and neither the dev nor the review had run the package that hosts them: @objectstack/objectql declares no dependency on @objectstack/lint, so it was invisible to a dependents-only sweep, and it is where the runtime write path is exercised. Root cause, one defect in two tests: `metadata-validation-sweep.test.ts` and `overlay-precedence.test.ts` each author a `report` that binds a dataset their tenant does not declare, into a harness whose live universe is permanently empty (`find` mocked to `[]`, nothing read back). Since the report door opened, `validateChartBindings` resolves that binding and refuses it — `chart-dataset-unknown`. The refusal is TRUE: the reports really do bind nothing. `ReportSchema` refines `dataset` to required, so dropping the binding is not available — a report either binds a dataset the tenant has or it is not a report. Both harnesses now seed that dataset into the registry universe, the landed pattern from `protocol.dashboard-dataset-publish-gate.test.ts`. ⛔ No test skipped, disabled or quarantined; no rule weakened; every whitelist, hash and rejection assertion untouched. Also: `runtime-gate.ts` claimed 「Twelve of the sixteen mappings」 and 「#19474's four rows」. Dropping `skill` falsified both -- it is eleven of fifteen and three rows, counted from the table itself, and it contradicted this file's own correct 「three rows」 68 lines above. Comments ship in dist, so this shipped. The provenance note about #19370's original wording is kept, with the withdrawn-row step recorded rather than erased. Two round-2 review folds: the hold-out's LIT pin now DERIVES the door snapshot through `buildRuntimeWriteSnapshots` instead of hard-coding `{ objects, skills }` -- ablated, it now goes red when `actions` joins `RuntimeStackContext`, which is the signal that `skill` can be crossed; and the changeset headline no longer says five doors judge when two of them judge nothing. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude --- .../19474-six-inert-runtime-create-doors.md | 2 +- .../runtime-gate.inert-type-writes.test.ts | 27 ++++++++++++++-- .../src/metadata-validation-sweep.test.ts | 32 ++++++++++++++++++- .../objectql/src/overlay-precedence.test.ts | 21 ++++++++++++ 4 files changed, 77 insertions(+), 5 deletions(-) diff --git a/.changeset/19474-six-inert-runtime-create-doors.md b/.changeset/19474-six-inert-runtime-create-doors.md index 887641a31d4..1d1318a9792 100644 --- a/.changeset/19474-six-inert-runtime-create-doors.md +++ b/.changeset/19474-six-inert-runtime-create-doors.md @@ -2,7 +2,7 @@ "@objectstack/lint": minor --- -**BREAKING for runtime metadata writes** — five metadata write doors now judge what walks through them. `action`, `hook`, `report`, `email_template` and `mapping` each declared `allowRuntimeCreate: true` and reached ZERO author-time rules at the runtime publish gate; they now dispatch the rules already written for them, so an `action`, `hook` or `report` publish that used to succeed can now be refused (#19474) +**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 (#19474) Clause-②: no (narrowing) diff --git a/packages/lint/src/runtime-gate.inert-type-writes.test.ts b/packages/lint/src/runtime-gate.inert-type-writes.test.ts index 7c115a2aff1..9bd95d45b28 100644 --- a/packages/lint/src/runtime-gate.inert-type-writes.test.ts +++ b/packages/lint/src/runtime-gate.inert-type-writes.test.ts @@ -59,10 +59,12 @@ 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'; /** @@ -531,9 +533,28 @@ describe('#19474 — `skill` is held out of this landing, on a measurement', () }; const rule = REFERENCE_INTEGRITY_RULES.find((r) => r.name === 'validateAiToolReferences')!; - // What a per-write snapshot can see: `objects` only. The action is - // stack-level, so the `action_*` limb of the universe is simply not there. - const atDoorShape = rule.run({ objects: OBJECTS, skills: [writtenSkill] }, {}); + // ⭐ 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', diff --git a/packages/objectql/src/metadata-validation-sweep.test.ts b/packages/objectql/src/metadata-validation-sweep.test.ts index 7bb0071afd7..44fdfa3ebbc 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 }); + // [#19474] 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 + // #19474 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' } }, + // [#19474] `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..a2fbbcf03e8 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 }); + // [#19474] 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([]), From 26b7274a8bef33d0d90f6c0cd50c16a73f1a119b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 09:50:51 +0000 Subject: [PATCH 07/10] fix(rest): give the org-scope report fixtures a tenant to be valid in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The other half of the red Test Core shards, same defect one package over. `rest-server-meta-read-org-scope.test.ts` and `rest-server-meta-history-diff-org-scope.test.ts` both write a `report` whose `bodyFor` binds `orders_ds`, into a stub engine whose registry double answers `listItems: () => []` for every type. That was harmless while no rule judged a report; since the report door opened, `validateChartBindings` resolves the binding and the write is refused with `chart-dataset-unknown` before the READ each file exists to exercise is reached -- six cases, all of them the `report` arm of a per-type sweep. The refusal is TRUE and `ReportSchema` refines `dataset` to required, so the fixture needs a tenant to be valid in rather than a binding to drop. Both doubles now answer `dataset` with `orders_ds`, whose measure name is exactly what `bodyFor` selects. ⛔ No test skipped, no rule weakened, every org-scope and history assertion untouched. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude --- ...server-meta-history-diff-org-scope.test.ts | 25 ++++++++++++++++++- .../rest-server-meta-read-org-scope.test.ts | 25 ++++++++++++++++++- 2 files changed, 48 insertions(+), 2 deletions(-) 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..2705933bbda 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, + // [#19474] 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..d74b4bfdcd5 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, + // [#19474] 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 From 00b5f59dec25aa941654e150182127d0d8a47b2d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 10:13:37 +0000 Subject: [PATCH 08/10] docs(lint): correct the mapping count that dropping `skill` falsified MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `runtime-gate.ts` claimed 「Twelve of the sixteen mappings」 and 「#19474's four rows」. Counted from the table itself: fifteen rows sit above `position`, four of them context collections, so it is ELEVEN of FIFTEEN -- and #19474 landed THREE rows, not four. The figures were true at the round-1 head; withdrawing the `skill` row falsified both, and they contradicted this file's own correct 「The three rows」 68 lines above. The build does not strip comments, so this sentence ships in dist/index.js, dist/runtime.js and both .cjs. The provenance note is kept and extended rather than erased: #19370's original 「eight of the twelve」 was true of the table it was written against, and the withdrawn-row step is now recorded instead of leaving a silent jump. The figure is arithmetic over the rows above it, so the note says to recount rather than adjust. ⚠️ This correction was made once before and LOST: it was uncommitted when an ablation's restore leg ran `git checkout HEAD -- runtime-gate.ts`, which discarded it silently at exit 0. Caught by reading the committed blob back instead of trusting the edit. AGENTS.md says it: commit the fix FIRST, then ablate. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude --- packages/lint/src/runtime-gate.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/lint/src/runtime-gate.ts b/packages/lint/src/runtime-gate.ts index 5e1f85cd191..77d30590b2b 100644 --- a/packages/lint/src/runtime-gate.ts +++ b/packages/lint/src/runtime-gate.ts @@ -189,13 +189,18 @@ 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`). Twelve of the sixteen 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. (This sentence read «eight of the twelve» when #19370 wrote - // it; #19474's four rows landed in the same merge, so the count is restated - // against the merged table rather than left describing a table nobody has. - // The reasoning it carries is unchanged.) ⛔ Do not add a context row + // it, which was true of the table it was written against; #19474'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 #19474 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. From 19988fccab406ccf96da58fb4b6adf5b667f85e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 10:30:56 +0000 Subject: [PATCH 09/10] docs: re-point the card citations the account ban made dangling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:issue-citations` reports `[allocated-but-absent]`: cards #19474 and #19370 were filed by an account since banned, so both answer 404 while their comments, timelines and landed work all still resolve. ⛔ Neither was deleted. #19474 -> #19542, the verbatim rebuild. Confirmed rather than guessed: #19542 resolves 200, its title opens `[rebuild of #19474]`, its body states the original is unreachable and tabulates the same 404s, and the PR already closes it. TWENTY-FIVE sites, not the nine the gate printed -- its own tail says the red is a LOWER BOUND because it stops at the first non-zero exit, and the rest were in the objectql and rest fixtures and the lint tests, all of which sit on the same judged `packages/**/src/**/*.ts` surface. The changeset is re-pointed too: it is not a judged surface, but it ships verbatim into CHANGELOG.md, which is the most durable reader-facing prose this repo has. #19370 is NOT re-pointed, and that is the point. It is historical provenance -- the sentence records what that card's author wrote and when it was true -- and it has no rebuild. Its PR is unreachable too (404, same ban), so naming it would only mint a second dangling reference. Taken instead is the gate's second remedy: keep the number, say in prose that it no longer resolves, and name the live record -- the merge commit a227afa415f596269ed36aae0a0631c84270ccc9, which carries that card's whole diff. ⛔ No wiring, test or hold-out touched: comment and prose only. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude --- .../19474-six-inert-runtime-create-doors.md | 2 +- packages/lint/src/authoring-rules.ts | 10 +++---- .../lint/src/reference-integrity-suite.ts | 4 +-- .../runtime-gate.inert-type-writes.test.ts | 16 +++++------ .../src/runtime-gate.object-writes.test.ts | 2 +- packages/lint/src/runtime-gate.ts | 27 ++++++++++++------- .../src/metadata-validation-sweep.test.ts | 6 ++--- .../objectql/src/overlay-precedence.test.ts | 2 +- ...server-meta-history-diff-org-scope.test.ts | 2 +- .../rest-server-meta-read-org-scope.test.ts | 2 +- 10 files changed, 41 insertions(+), 32 deletions(-) diff --git a/.changeset/19474-six-inert-runtime-create-doors.md b/.changeset/19474-six-inert-runtime-create-doors.md index 1d1318a9792..baa8a8faf51 100644 --- a/.changeset/19474-six-inert-runtime-create-doors.md +++ b/.changeset/19474-six-inert-runtime-create-doors.md @@ -2,7 +2,7 @@ "@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 (#19474) +**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) diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 6a975006096..f32647ecf56 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -467,7 +467,7 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ // carries, against the live object universe — the same parse `os build` // runs, now at the door Studio/REST/MCP authors actually use. // - // [#19474] `action` and `hook` join under the ADR-0049 ruling 「declared ⇒ + // [#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 @@ -749,7 +749,7 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ input: 'parsed', commands: ALL, source: 'packages/lint/src/validate-preset-comparands.ts', - // [#19474] `report` joins under the ADR-0049 ruling. `reports` is already + // [#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 @@ -783,7 +783,7 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ // 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. // - // [#19474] `report` is that edit, taken on the ADR-0049 ruling's authority + // [#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' }` @@ -901,7 +901,7 @@ 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. - // [#19474] `report` joins under the ADR-0049 ruling, and the same + // [#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 @@ -1391,7 +1391,7 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ input: 'parsed', commands: ALL, source: 'packages/lint/src/lint-liveness-properties.ts', - // [#19474] Group C of the ADR-0049 ruling — `email_template` and `mapping`, + // [#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 diff --git a/packages/lint/src/reference-integrity-suite.ts b/packages/lint/src/reference-integrity-suite.ts index 1e45707bbd8..c6f3542ca35 100644 --- a/packages/lint/src/reference-integrity-suite.ts +++ b/packages/lint/src/reference-integrity-suite.ts @@ -380,7 +380,7 @@ 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 }, - // [#19474] `runtimeTypes` gains `report` under the ADR-0049 ruling, and ONLY + // [#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` @@ -460,7 +460,7 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [ { name: 'validateTranslatableSections', run: validateTranslatableSections }, { name: 'validateFlowTemplatePaths', run: validateFlowTemplatePaths }, { name: 'validateAiSurfaceAffinity', run: validateAiSurfaceAffinity }, - // [#19474] NO `runtimeTypes`, i.e. the frozen `flow` default — and `skill` is + // [#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)`, diff --git a/packages/lint/src/runtime-gate.inert-type-writes.test.ts b/packages/lint/src/runtime-gate.inert-type-writes.test.ts index 9bd95d45b28..deade60cf93 100644 --- a/packages/lint/src/runtime-gate.inert-type-writes.test.ts +++ b/packages/lint/src/runtime-gate.inert-type-writes.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * #19474 — the six write doors that dispatched NOTHING, under the ADR-0049 + * #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. @@ -128,7 +128,7 @@ const dump = (r: { errors: readonly unknown[]; advisories: readonly unknown[] }) // ───────────────────────────────────────────────────────────────────────────── -describe('#19474 — the five wired doors are declared, mapped and reachable', () => { +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 @@ -169,7 +169,7 @@ describe('#19474 — the five wired doors are declared, mapped and reachable', ( // ─── Group A · `action` ────────────────────────────────────────────────────── -describe('#19474 — the `action` door (validateStackExpressions)', () => { +describe('#19542 — the `action` door (validateStackExpressions)', () => { const action = (over: Record = {}) => ({ name: 'acme_close', label: 'Close', @@ -255,7 +255,7 @@ describe('#19474 — the `action` door (validateStackExpressions)', () => { // ─── Group A · `hook` ──────────────────────────────────────────────────────── -describe('#19474 — the `hook` door (validateStackExpressions)', () => { +describe('#19542 — the `hook` door (validateStackExpressions)', () => { const hook = (over: Record = {}) => ({ name: 'acme_stamp', object: 'acme_invoice', @@ -327,7 +327,7 @@ describe('#19474 — the `hook` door (validateStackExpressions)', () => { // ─── Group A · `report` ────────────────────────────────────────────────────── -describe('#19474 — the `report` door (three rules, one document)', () => { +describe('#19542 — the `report` door (three rules, one document)', () => { const report = (over: Record = {}) => ({ name: 'acme_report', label: 'Invoice Report', @@ -461,7 +461,7 @@ describe('#19474 — the `report` door (three rules, one document)', () => { // ─── Group A · `skill` — HELD OUT, and this is the pin that holds it ──────── -describe('#19474 — `skill` is held out of this landing, on a measurement', () => { +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 @@ -574,7 +574,7 @@ describe('#19474 — `skill` is held out of this landing, on a measurement', () // ─── Group C · `email_template` and `mapping` ──────────────────────────────── -describe('#19474 — group C: wired, dispatched, and silent by ledger (email_template · 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. * @@ -680,7 +680,7 @@ describe('#19474 — group C: wired, dispatched, and silent by ledger (email_tem // ─── The fences ────────────────────────────────────────────────────────────── -describe('#19474 — DARK: what the five crossings did NOT widen', () => { +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 diff --git a/packages/lint/src/runtime-gate.object-writes.test.ts b/packages/lint/src/runtime-gate.object-writes.test.ts index bf28352989c..53d3771c02c 100644 --- a/packages/lint/src/runtime-gate.object-writes.test.ts +++ b/packages/lint/src/runtime-gate.object-writes.test.ts @@ -155,7 +155,7 @@ 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'); - // [#19474] The fence is about the OBJECT door, and until this card every + // [#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 diff --git a/packages/lint/src/runtime-gate.ts b/packages/lint/src/runtime-gate.ts index 77d30590b2b..c5093a04835 100644 --- a/packages/lint/src/runtime-gate.ts +++ b/packages/lint/src/runtime-gate.ts @@ -125,7 +125,7 @@ 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', - // [#19474] The three rows the ADR-0049 ruling 「declared ⇒ honoured; not + // [#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 @@ -192,14 +192,23 @@ const TYPE_TO_STACK_KEY: Readonly> = { // `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. (This sentence read «eight of the twelve» when #19370 wrote - // it, which was true of the table it was written against; #19474'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 #19474 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.) + // collections. (This sentence read «eight of the twelve» when the card that + // added the two rows above wrote it — #19370, which 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. 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 this + // number at a rebuild: unlike its sibling below, #19370 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 diff --git a/packages/objectql/src/metadata-validation-sweep.test.ts b/packages/objectql/src/metadata-validation-sweep.test.ts index 44fdfa3ebbc..3f73cd262e5 100644 --- a/packages/objectql/src/metadata-validation-sweep.test.ts +++ b/packages/objectql/src/metadata-validation-sweep.test.ts @@ -44,7 +44,7 @@ import { function makeProtocol() { const registry = new SchemaRegistry({ multiTenant: false }); - // [#19474] The live resolution universe this harness hands the runtime + // [#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 @@ -56,7 +56,7 @@ function makeProtocol() { // // ⛔ 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 - // #19474 door working. The dimension and measure names are exactly what 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', @@ -94,7 +94,7 @@ const FIXTURES: Record = { // (`security-owd-unset` refuses absence), so a "valid" object // fixture must author its posture. sharingModel: 'private', - // [#19474] `stage` joins `amount` so the dataset seeded into this + // [#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. diff --git a/packages/objectql/src/overlay-precedence.test.ts b/packages/objectql/src/overlay-precedence.test.ts index a2fbbcf03e8..7f2f04c7daf 100644 --- a/packages/objectql/src/overlay-precedence.test.ts +++ b/packages/objectql/src/overlay-precedence.test.ts @@ -75,7 +75,7 @@ const validReport = { function makeProtocol(opts: { environmentId?: string } = {}) { const registry = new SchemaRegistry({ multiTenant: false }); - // [#19474] The live resolution universe the runtime publish gate reads + // [#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 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 2705933bbda..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,7 @@ function makeStubEngine() { async syncObjectSchema() { return true; }, registry: { registerItem: () => {}, registerObject: () => {}, - // [#19474] The live resolution universe the runtime publish gate + // [#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 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 d74b4bfdcd5..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,7 @@ function makeStubEngine() { async syncObjectSchema() { return true; }, registry: { registerItem: () => {}, registerObject: () => {}, - // [#19474] The live resolution universe the runtime publish gate + // [#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 From 1ac5e9779b48c7d038d2073fc3e279131813b1aa Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 10:32:00 +0000 Subject: [PATCH 10/10] docs(lint): spell the dead card id without the citation sigil MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prose remedy alone is not enough: `check:issue-citations` judges every bare `#N` on an added line against the board, so keeping the hash form and explaining it still reports `[allocated-but-absent]` -- measured, exit 1 on two sites. Read the gate's own sentence again and it says what to do: 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, 19370, without the sigil -- with the reason stated inline, and the live record named as the merge commit a227afa415f596269ed36aae0a0631c84270ccc9. ⛔ Not evasion of the gate and ⛔ not a qualifier dodge: `owner/repo#N` would have skipped the probe by declaring a cross-repo reference, which this is not. The citation is removed because it no longer cites anything. Measured after: `node scripts/check-issue-citations.mjs` (the diff-scoped form lint.yml runs, with a token) exits 0 -- 15 citations judged, 15 resolve. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude --- packages/lint/src/runtime-gate.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/lint/src/runtime-gate.ts b/packages/lint/src/runtime-gate.ts index c5093a04835..0adae588e67 100644 --- a/packages/lint/src/runtime-gate.ts +++ b/packages/lint/src/runtime-gate.ts @@ -193,13 +193,16 @@ const TYPE_TO_STACK_KEY: Readonly> = { // above name a non-context key for the same reason — counted off this table, // where only `objects`, `permissions`, `books` and `datasets` are context // collections. (This sentence read «eight of the twelve» when the card that - // added the two rows above wrote it — #19370, which NO LONGER RESOLVES: it + // 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. 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 this - // number at a rebuild: unlike its sibling below, #19370 has none, and - // guessing an upstream is how a dangling reference becomes a wrong one. + // 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