From 78957f802c0a189970697c601b7ece91bf79e18f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 04:00:21 +0000 Subject: [PATCH 1/7] feat(lint)!: refuse a flow CEL root the flow does not bind (wip) Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- packages/lint/src/flow-cel-root-scope.ts | 352 ++++++++++++++++++++++ packages/lint/src/validate-expressions.ts | 83 ++++- 2 files changed, 431 insertions(+), 4 deletions(-) create mode 100644 packages/lint/src/flow-cel-root-scope.ts diff --git a/packages/lint/src/flow-cel-root-scope.ts b/packages/lint/src/flow-cel-root-scope.ts new file mode 100644 index 00000000000..3d34a2be22c --- /dev/null +++ b/packages/lint/src/flow-cel-root-scope.ts @@ -0,0 +1,352 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * @module flow-cel-root-scope + * + * **The build door's judge of the ROOTS a flow CEL expression reads.** A flow + * expression whose root identifier the run does not bind fails that run with + * `Unknown variable: X` — measured on the flow scope the engine builds + * (`AutomationEngine.celScope` over `seedRunVariables`' map): `user.id == "u1"`, + * `ctx.user.id == "u1"`, `os.user.id == "u1"` and `foo.bar == 1` each fault, while + * `objectstack validate` answered nothing for any of them. The three user + * spellings are the trap: formulas, row-level security and the client bind the + * run's user under them (`buildScope` in `@objectstack/formula`), and flow CEL + * binds it as `current_user` only, so an author who writes the alias by habit + * ships a flow that faults. This module refuses such a root at build, naming + * `current_user` for the user spellings and the in-scope names for any other. + * + * ## One judge, every flow CEL site + * + * The caller (`validateStackExpressions`) hands it each flow CEL source it + * already visits: a node's `config.condition` (the start node's is the trigger + * gate), an edge's `condition`, a `decision` branch's `expression`, a `screen` + * field's `visibleWhen`, and the CEL value envelopes of an `assignment` and of a + * `create_record` / `update_record` `fields` map. All of them evaluate in the + * one scope `celScope` builds, so one bound set per flow judges them all. + * + * ## The bound set — the flow's own static bindings + * + * - **the engine's roots**: `record` and `previous` (`seedRunVariables` binds + * `previous` on every run, `null` on a create; `celScope` binds `record` to + * the trigger record, or to the variables themselves when there is none), + * `vars` and `current_user` (`celScope` binds both after the spread). A + * `$`-named variable has no CEL spelling at all — `$runId == "x"` does not + * parse — so the `$` names never reach this judge: they stay the text-slot + * rule's (`flow-text-slot-template.ts`), and this module keeps no list of them; + * - **every name the flow binds**: {@link collectFlowVariableNames} — declared + * variables, `outputVariable` / `errorVariable` / `iteratorVariable` / + * `indexVariable`, `assignment` targets, node ids — plus the two bindings that + * reader does not collect: a `screen` field's `name` (a resume lands the + * collected values under their plain names) and a `loop`'s default iterator + * `item` when the node names none; + * - **record fields**: the trigger record's fields are flattened to top-level + * names. Every field of EVERY object the stack declares counts, with the + * registry-injected columns — not only the trigger object's — because the run + * flattens whichever record its entrance hands it: a flow action passes the + * action's own record to any flow it starts, a `subflow` child inherits its + * parent's record, and a `map` child gets each record item. A field the stack + * declares anywhere is therefore a root this door cannot prove unbound. + * + * ## When the door cannot prove a root unbound, it stands down + * + * A false refusal of a working flow is worse than the trap, so the whole flow is + * left unjudged when its run can bind a name the reader cannot see: + * + * - a node whose type is not one of {@link CLOSED_BINDER_NODE_TYPES}. The node + * type namespace is open (ADR-0018), and the executors that hand out the LIVE + * variable map are open by measurement: `script` (a registered function gets + * `FlowFunctionContext.variables`) and `connector_action` + * (`ConnectorActionContext.variables`) may write any name; `wait`, `subflow` + * and `map` declare `resumeAuthority: 'any'`, and a resume folds the caller's + * `variables` bag in under its plain names (`applyResumeSignal`); + * - a `screen` with no input contract — an `object-form` screen, or one that + * declares no fields — because `refuseInvalidScreenInput` checks a resume bag + * only against a declared field list, so a contract-less screen folds any bag; + * - a trigger object the stack does not declare (a platform object, another + * package's): its row's keys are not in hand. + * + * ⚠️ The boundary that remains: a record of an object this stack does not + * declare, handed in by a flow action, a parent `subflow` or a `map` item, can + * carry a key the reader has never seen. Measured over the example apps and + * hotcrm, no flow reads a bare root that is not its own binding, an engine root + * or a field of its trigger object. + */ + +import { firstUndeclaredReference, parseCelToAst, SCOPE_ROOTS } from '@objectstack/formula'; +import { + APPROVAL_NODE_TYPE, + APPROVAL_REVISE_NODE_TYPE, + LOOP_NODE_TYPE, + PARALLEL_NODE_TYPE, + TRY_CATCH_NODE_TYPE, +} from '@objectstack/spec/automation'; + +import { collectFlowVariableNames, type FlowGraphLike, type FlowVariableHost } from './flow-variable-scope.js'; +import { nearestName, recordsOf } from './object-graph.js'; + +type AnyRec = Record; + +/** + * The roots the flow engine binds on every run, whatever the flow declares: + * `record` and `previous` (`seedRunVariables`, and `celScope`'s `record` arm), + * `vars` and `current_user` (bound by `celScope` after the variables are spread). + * Not the `$` names — those have no CEL spelling (module note). + */ +const ENGINE_BOUND_ROOTS: readonly string[] = ['record', 'previous', 'vars', 'current_user']; + +/** + * The node types whose variable writes this reader models completely — every + * name each one can bind is a key of its own `config` that + * {@link collectFlowVariableNames} or {@link flowCelRootScope} reads, or its node + * id. Measured from the executors: + * + * | type | binds | + * |---|---| + * | `start`, `end`, `decision`, `http`, `notify`, `delete_record` | nothing but its node id's outputs | + * | `assignment` | its targets | + * | `create_record`, `update_record`, `get_record` | `outputVariable` | + * | `loop` | `iteratorVariable` (default `item`), `indexVariable` | + * | `parallel` | nothing; its branches are graphs of their own | + * | `try_catch` | `errorVariable` (default `$error`) | + * | `screen` with an input contract | its fields' names, `outputVariable` | + * | `approval`, `approval_revise` | nothing; their service resumes with outputs only (`resumeAuthority: 'service'`) | + * + * Every other type — a plugin's, and `script`, `connector_action`, `wait`, + * `subflow`, `map` (module note) — makes the bound set unprovable. + */ +const CLOSED_BINDER_NODE_TYPES: ReadonlySet = new Set([ + 'start', + 'end', + 'decision', + 'assignment', + 'create_record', + 'update_record', + 'delete_record', + 'get_record', + 'http', + 'notify', + 'screen', + LOOP_NODE_TYPE, + PARALLEL_NODE_TYPE, + TRY_CATCH_NODE_TYPE, + APPROVAL_NODE_TYPE, + APPROVAL_REVISE_NODE_TYPE, +]); + +/** The run-user spellings formulas, RLS and the client accept and flow CEL does not bind. */ +const RUN_USER_ALIAS_ROOTS: ReadonlySet = new Set(['user', 'ctx', 'os']); + +/** What the door knows about one flow's CEL scope. */ +export interface FlowCelRootScope { + /** + * Every root the run can bind, in the order a did-you-mean prefers them: the + * trigger object's fields first (a flattened condition's commonest typo), then + * the flow's own bindings, the engine's roots, and the other objects' fields. + * Meaningful only when {@link provable}. + */ + readonly bound: ReadonlySet; + /** + * `false` ⇒ the run can bind a name the reader cannot see, and no root of this + * flow is judged. {@link openedBy} says why. + */ + readonly provable: boolean; + /** The first reason the bound set is not provable, for tests and diagnostics. */ + readonly openedBy?: string; +} + +/** The flow-level slice the scope reads. */ +export interface FlowCelRootHost extends FlowVariableHost { + readonly nodes?: unknown; +} + +/** One root a flow CEL source reads that the flow does not bind. */ +export interface UnboundFlowCelRoot { + /** The root identifier (`user`). */ + readonly root: string; + /** The member names read directly off it (`user.id` → `id`), in source order. */ + readonly members: readonly string[]; +} + +/** + * The bound set of one flow, or the reason it cannot be proved. + * + * @param graphs every graph of the flow (`collectFlowGraphs`), so a region's + * nodes count — one variable map serves the whole run. + * @param triggerObject the start node's `objectName`, when it names one. + * @param fieldIndex object name → its field names, injected columns included. + */ +export function flowCelRootScope( + flow: FlowCelRootHost, + graphs: readonly FlowGraphLike[], + triggerObject: string | undefined, + fieldIndex: ReadonlyMap, +): FlowCelRootScope { + const bound = new Set(triggerObject !== undefined ? fieldIndex.get(triggerObject) ?? [] : []); + for (const name of collectFlowVariableNames(flow, graphs)) bound.add(name); + for (const name of ENGINE_BOUND_ROOTS) bound.add(name); + let openedBy: string | undefined; + const open = (reason: string): void => { openedBy ??= reason; }; + + for (const graph of graphs) { + for (const node of recordsOf(graph.nodes)) { + const type = typeof node.type === 'string' ? node.type : ''; + const id = typeof node.id === 'string' ? node.id : '?'; + if (!CLOSED_BINDER_NODE_TYPES.has(type)) { + open(`node '${id}' (${type || 'no type'}) can bind names the reader cannot see`); + continue; + } + const config = (node.config && typeof node.config === 'object' && !Array.isArray(node.config) + ? node.config + : {}) as AnyRec; + if (type === LOOP_NODE_TYPE && !(typeof config.iteratorVariable === 'string' && config.iteratorVariable)) { + // `LoopConfigSchema` defaults the iterator to `item`; a config read before + // that parse carries no key, and the run still binds the default. + bound.add('item'); + } + if (type === 'screen') { + const fields = Array.isArray(config.fields) ? recordsOf(config.fields) : []; + if (config.kind === 'object-form' || fields.length === 0) { + open(`screen '${id}' declares no input contract, so a resume can bind any name`); + continue; + } + for (const field of fields) { + if (typeof field.name === 'string' && field.name) bound.add(field.name); + } + } + } + } + + if (triggerObject !== undefined && !fieldIndex.has(triggerObject)) { + open(`trigger object '${triggerObject}' is not declared in this stack, so its fields are not in hand`); + } + for (const names of fieldIndex.values()) for (const name of names) bound.add(name); + + return openedBy === undefined ? { bound, provable: true } : { bound, provable: false, openedBy }; +} + +/** cel-js's comprehension macros: the receiver calls that bind their first argument in the rest. */ +const COMPREHENSION_MACROS: ReadonlySet = new Set(['all', 'exists', 'exists_one', 'map', 'filter']); + +interface CelNode { + readonly op: string; + readonly args: unknown; +} + +function isCelNode(value: unknown): value is CelNode { + return !!value && typeof value === 'object' && !Array.isArray(value) && typeof (value as AnyRec).op === 'string'; +} + +function boundIdentifier(value: unknown): string | undefined { + return isCelNode(value) && value.op === 'id' && typeof value.args === 'string' ? value.args : undefined; +} + +/** + * The FREE root identifiers of a parsed CEL source, with the members read off + * each. A name a comprehension macro (`all`, `exists`, `exists_one`, `map`, + * `filter`) or `cel.bind` binds is free only outside the part it is bound in — + * the same scoping cel-js applies (`lib/macros.js`). + */ +function freeRoots(ast: unknown): Map { + const out = new Map(); + const note = (name: string, member: string | undefined): void => { + const members = out.get(name) ?? []; + if (member !== undefined && !members.includes(member)) members.push(member); + out.set(name, members); + }; + const walk = (node: unknown, scoped: ReadonlySet, member?: string): void => { + if (Array.isArray(node)) { + for (const child of node) walk(child, scoped); + return; + } + if (!isCelNode(node)) return; + const args = node.args; + if (node.op === 'id') { + if (typeof args === 'string' && !scoped.has(args)) note(args, member); + return; + } + if ((node.op === '.' || node.op === '.?') && Array.isArray(args)) { + walk(args[0], scoped, typeof args[1] === 'string' ? args[1] : undefined); + return; + } + if (node.op === 'rcall' && Array.isArray(args)) { + const [name, receiver, list] = args as [unknown, unknown, unknown]; + const variable = Array.isArray(list) ? boundIdentifier(list[0]) : undefined; + if (typeof name === 'string' && COMPREHENSION_MACROS.has(name) && Array.isArray(list) && list.length >= 2 && variable) { + walk(receiver, scoped); + walk(list.slice(1), new Set([...scoped, variable])); + return; + } + if (name === 'bind' && boundIdentifier(receiver) === 'cel' && Array.isArray(list) && list.length === 3 && variable) { + walk(list[1], scoped); + walk(list[2], new Set([...scoped, variable])); + return; + } + walk(receiver, scoped); + walk(list, scoped); + return; + } + if (node.op === 'value') return; + walk(args, scoped); + }; + walk(ast, new Set()); + return out; +} + +/** + * Whether `name`, read as a bare identifier, is a VARIABLE the run must bind — + * as opposed to a name CEL declares itself (a type such as `int`, the `cel` + * namespace). Answered by the strict checker, which knows every such name; a + * platform scope root (`user`, `ctx`, `os`, `data`, …) is declared there as a + * convenience for other surfaces, and is a variable here. + */ +function isVariableReference(name: string): boolean { + if ((SCOPE_ROOTS as readonly string[]).includes(name)) return true; + return firstUndeclaredReference(name) === name; +} + +/** + * The roots `source` reads that `scope` does not bind, in source order — empty + * when the scope is not provable, or when the source does not parse (the syntax + * check owns that verdict: one broken expression, one finding). + */ +export function unboundFlowCelRoots(source: string, scope: FlowCelRootScope): UnboundFlowCelRoot[] { + if (!scope.provable) return []; + const ast = parseCelToAst(source); + if (!ast) return []; + const out: UnboundFlowCelRoot[] = []; + for (const [root, members] of freeRoots(ast)) { + if (scope.bound.has(root) || !isVariableReference(root)) continue; + out.push({ root, members }); + } + return out; +} + +/** + * The refusal for one unbound root. + * + * A run-user spelling (`user`, `ctx.user`, `os.user`) is told to write + * `current_user`; any other root is named with what the flow could have bound + * it, and the nearest in-scope name when there is one. + */ +export function unboundFlowCelRootMessage(unbound: UnboundFlowCelRoot, scope: FlowCelRootScope): string { + const { root, members } = unbound; + const userSpelling = root === 'user' ? 'user' : RUN_USER_ALIAS_ROOTS.has(root) && members.includes('user') ? `${root}.user` : undefined; + if (userSpelling !== undefined) { + return ( + `\`${userSpelling}\` is not bound in a flow expression, so the run fails with \`Unknown variable: ${root}\`: ` + + 'a flow binds the run\'s user as `current_user` only — the `user`, `ctx.user` and `os.user` spellings that ' + + 'formulas, row-level security and the client accept are not bound here. ' + + `Write \`current_user\` in its place (\`${userSpelling}.id\` → \`current_user.id\`); for a flow that can run ` + + 'without a user, guard it: `current_user != null ? current_user.id : null`.' + ); + } + const near = nearestName(root, scope.bound); + return ( + `\`${root}\` is not bound in this flow's expression scope, so the run fails with \`Unknown variable: ${root}\`: ` + + 'no variable, `outputVariable`, iterator, index or error variable, `assignment` target, node id or screen field ' + + `of this flow is named \`${root}\`, no object in the stack declares a field \`${root}\`, and the engine binds ` + + 'only `record`, `previous`, `vars` and `current_user`. ' + + `Declare \`${root}\` as a flow variable or bind it with a node, or correct the name` + + (near ? ` — did you mean \`${near}\`?` : '.') + ); +} diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index c404d97dbbc..c0883b1d330 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -122,6 +122,7 @@ import { referenceCarrierOf, REFERENCE_VALUE_TYPES } from '@objectstack/spec/dat import { EvalUserSchema } from '@objectstack/spec/identity'; import { collectFlowVariableNames, shadowedFieldReads, shadowedFieldMessage } from './flow-variable-scope.js'; +import { flowCelRootScope, unboundFlowCelRoots, unboundFlowCelRootMessage } from './flow-cel-root-scope.js'; import { injectedColumnsFor, unprovisionedInjectedColumnsFor } from './system-fields.js'; import { findUnguardedNullableOperands, nullGuardMessage } from './validate-null-guards.js'; import type { NullGuardOutcome } from './validate-null-guards.js'; @@ -1156,6 +1157,24 @@ function isBareReferenceToAny(diagnostic: string, roots: readonly string[]): boo return roots.some((root) => diagnostic.startsWith(`bare reference \`${root}\``)); } +/** + * [#22565] Whether `diagnostic` is the flattened-scope near-miss WARNING + * `validateExpression` gives a bare non-field identifier on a flow condition + * ("`X` is not a field of `obj` — did you mean …? If `X` is a flow variable this + * is safe to ignore") about one of `roots`. + * + * The flow walk withdraws that warning at a site where the unbound-root judge + * refuses the same root: the judge has read the flow's whole bound set, so "if it + * is a flow variable" is already answered no, and one broken root earns one + * finding. Matched on the leading sentence for the reason + * {@link isBareReferenceToAny} gives; a reword upstream makes the withdrawal miss + * LOUDLY — the root then carries two findings and the `toHaveLength(1)` pin in + * `validate-expressions.flow-cel-root.test.ts` goes red. + */ +function isFlattenedNearMissFor(diagnostic: string, roots: readonly string[]): boolean { + return roots.some((root) => diagnostic.startsWith(`\`${root}\` is not a field of`)); +} + /** * [#20078] The field-level predicate slots a read THROUGH a reference field is * refused on: the two field-rule slots the server evaluates on a write, and a @@ -2111,9 +2130,13 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression * engine's `registerFlow` throw: build and run time must agree about what * registers, and a malformed envelope used to be stored verbatim and rendered * by `notify` as JSON with nothing said at any layer. + * + * @returns whether the slot was refused on SHAPE, so the caller can skip the + * root judgment (#22565) — a value that is not an envelope has no source to + * read roots from. */ - const checkDeclaredValue = (where: string, raw: unknown): void => { - if (raw == null) return; + const checkDeclaredValue = (where: string, raw: unknown): { refused: boolean } => { + if (raw == null) return { refused: false }; // `celSourceOf` — not a second read of `.source`: the same helper every // other slot in this file locates its finding with. const source = celSourceOf(raw) ?? ''; @@ -2121,7 +2144,7 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression if (!shape.success) { // Already prefixed by the spec's own refinement — do not say it twice. for (const issue of shape.error.issues) issues.push({ where, message: issue.message, source, severity: 'error' }); - return; + return { refused: true }; } const res = validateExpression('value', raw as { dialect?: string; source?: string }); for (const e of res.errors) { @@ -2130,6 +2153,7 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression for (const w of res.warnings) { issues.push({ where, message: w.message, source: w.source, severity: 'warning' }); } + return { refused: false }; }; // ── Flows ────────────────────────────────────────────────────────── @@ -2186,6 +2210,47 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression // depend on traversal order. `collectFlowGraphs` is still called once. const declaredVariables = collectFlowVariableNames(flow, graphs); + /** + * [#22565] The flow CEL ROOT judgment. A root the run does not bind fails + * that run with `Unknown variable: X`, and before this pass nothing at build + * said so: `user.id == "u1"` — the run-user spelling formulas, RLS and the + * client accept — validated clean and faulted at the first evaluation, + * because flow CEL binds the run's user as `current_user` only. + * + * One bound set per flow, built once like `declaredVariables` above and for + * the same reason (one variable map serves the whole run): the flow's own + * bindings, the engine's roots and every object's fields — see + * `flow-cel-root-scope.ts` for the set, and for the flows it stands down on + * because their run can bind a name the reader cannot see. `error`: the run + * faults on the root, every time the expression is evaluated. + */ + const rootScope = flowCelRootScope(flow, graphs, objectName, fieldIndex); + + /** + * [#22565] Refuse each root `raw` reads that the flow does not bind. + * + * @param from the index in `issues` this site's own findings start at — a + * flattened-scope near-miss warning `check()` pushed there about a root + * refused here is withdrawn ({@link isFlattenedNearMissFor}). + * @param role a `value` slot's plain string is a literal, so only its CEL + * envelope is read; a predicate slot is CEL as a string too. + */ + const refuseUnboundRoots = (where: string, raw: unknown, from: number, role: 'predicate' | 'value'): void => { + if (role === 'value' && !(raw && typeof raw === 'object' && (raw as AnyRec).dialect === 'cel')) return; + const source = celSourceOf(raw); + if (!source) return; + const unbound = unboundFlowCelRoots(source, rootScope); + if (unbound.length === 0) return; + const refused = unbound.map((u) => u.root); + for (let i = issues.length - 1; i >= from; i--) { + const issue = issues[i]!; + if (issue.severity === 'warning' && isFlattenedNearMissFor(issue.message, refused)) issues.splice(i, 1); + } + for (const u of unbound) { + issues.push({ where, message: unboundFlowCelRootMessage(u, rootScope), source, severity: 'error' }); + } + }; + /** * [#14089] The one bare-identifier case a flattened flow scope may not stay * silent about: a name bound as BOTH a declared flow variable and a field on @@ -2275,8 +2340,10 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression const cfg = (node.config ?? {}) as AnyRec; const nodeCondWhere = `${at} · node '${node.id}' (${node.type}) condition`; if (!checkStructuralCondition(nodeCondWhere, cfg.condition).refused) { + const from = issues.length; check(nodeCondWhere, cfg.condition, objectName); warnShadowedFieldReads(nodeCondWhere, cfg.condition); + refuseUnboundRoots(nodeCondWhere, cfg.condition, from, 'predicate'); } // Descriptor-declared expression slots (#4027). Before this, the traversal @@ -2328,10 +2395,14 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression // and a value envelope is evaluated in the flow-variable scope by // design — there is no field reading to displace. if (found.entry.role === 'value') { - checkDeclaredValue(slotWhere, found.value); + const from = issues.length; + if (!checkDeclaredValue(slotWhere, found.value).refused) { + refuseUnboundRoots(slotWhere, found.value, from, 'value'); + } continue; } if (found.entry.role !== 'predicate') continue; + const slotFrom = issues.length; // [#15572] A slot refused on SHAPE gets no second diagnostic: the // shadowing warning is about which scope a CEL source resolves in, // and a value that is not CEL text has no source to resolve. @@ -2364,6 +2435,8 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression // moves no accept set and judges no bare identifier for being bare // (the 2026-09-01 option-C ruling's letter). warnShadowedFieldReads(slotWhere, found.value); + // [#22565] The same scope again, judged for roots the run cannot bind. + refuseUnboundRoots(slotWhere, found.value, slotFrom, 'predicate'); } // [#19939] The `{…}` template dialect is retired from the value slots // (the C half of #11182 ruling D, which this replaced: until then a @@ -2466,8 +2539,10 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression for (const edge of recordsOf(graph.edges)) { const edgeCondWhere = `${at} · edge '${edge.id}' (${edge.source}→${edge.target}) condition`; if (!checkStructuralCondition(edgeCondWhere, edge.condition).refused) { + const from = issues.length; check(edgeCondWhere, edge.condition, objectName); warnShadowedFieldReads(edgeCondWhere, edge.condition); + refuseUnboundRoots(edgeCondWhere, edge.condition, from, 'predicate'); } } // No `checkNullGuards` on node/edge conditions — and NOT for the reason From 69b0c3f793371c7ab3162b691fb9fe4c84d5a896 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 04:11:03 +0000 Subject: [PATCH 2/7] test(lint): pin the flow CEL root judge; declare the bindings fixtures read Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- packages/lint/src/lint-flow-patterns.test.ts | 5 + ...validate-expressions.flow-cel-root.test.ts | 255 ++++++++++++++++++ .../lint/src/validate-expressions.test.ts | 48 +++- packages/lint/src/validate-expressions.ts | 6 +- 4 files changed, 309 insertions(+), 5 deletions(-) create mode 100644 packages/lint/src/validate-expressions.flow-cel-root.test.ts diff --git a/packages/lint/src/lint-flow-patterns.test.ts b/packages/lint/src/lint-flow-patterns.test.ts index 9352e9a1e2b..9d357842dbe 100644 --- a/packages/lint/src/lint-flow-patterns.test.ts +++ b/packages/lint/src/lint-flow-patterns.test.ts @@ -2612,6 +2612,11 @@ describe('#16405 — an `http` node payload is not a region, and both #1315 rule return { flows: [{ name: 'stamp_owner', label: 'Stamp owner', type: 'autolaunched', + // The variables the prescriptions below read, bound as their hints + // say they must be: the flow's own `source` (`$source`'s remedy) and + // the `me` a `get_record` binds (`$User.Email`'s). The build door + // refuses a root the flow does not bind (#22565), as the run does. + variables: [{ name: 'source', type: 'object' }, { name: 'me', type: 'object' }], nodes: [ { id: 'start', type: 'start', label: 'Start' }, { id: 'write', type, label: 'Write', config }, diff --git a/packages/lint/src/validate-expressions.flow-cel-root.test.ts b/packages/lint/src/validate-expressions.flow-cel-root.test.ts new file mode 100644 index 00000000000..5978a7d2fbd --- /dev/null +++ b/packages/lint/src/validate-expressions.flow-cel-root.test.ts @@ -0,0 +1,255 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #22565 — the build door refuses a flow CEL root the flow does not bind. + * + * Measured on the flow scope the engine builds (`AutomationEngine.celScope` + * over `seedRunVariables`' map): `user.id == "u1"`, `ctx.user.id == "u1"`, + * `os.user.id == "u1"` and `foo.bar == 1` each fail the run with + * `Unknown variable: `, while `validateStackExpressions` answered 0 issues + * for every one of them. The three user spellings are the ones formulas, RLS and + * the client bind; flow CEL binds the run's user as `current_user` only. + * + * The judge lives in `flow-cel-root-scope.ts`; these pins drive it through the + * door `objectstack validate` and the runtime publish gate both call. + */ + +import { describe, expect, it } from 'vitest'; + +import { flowCelRootScope, unboundFlowCelRoots } from './flow-cel-root-scope.js'; +import { validateStackExpressions } from './validate-expressions.js'; + +type AnyRec = Record; + +const OBJECTS = [{ name: 'acct', fields: { status: { type: 'text' }, amount: { type: 'number' } } }]; + +/** start → (edge carrying `edgeCondition`) → done, plus any `middle` nodes, on an optional trigger object. */ +function flowStack(opts: { + edgeCondition?: unknown; + startCondition?: unknown; + variables?: AnyRec[]; + middle?: AnyRec[]; + objectName?: string; + objects?: AnyRec[]; +}): AnyRec { + return { + objects: opts.objects ?? OBJECTS, + flows: [{ + name: 'f', + label: 'F', + type: 'autolaunched', + ...(opts.variables ? { variables: opts.variables } : {}), + nodes: [ + { + id: 'start', + type: 'start', + config: { + ...(opts.objectName ? { objectName: opts.objectName } : {}), + ...(opts.startCondition !== undefined ? { condition: opts.startCondition } : {}), + }, + }, + ...(opts.middle ?? []), + { id: 'done', type: 'end', config: {} }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'done', ...(opts.edgeCondition !== undefined ? { condition: opts.edgeCondition } : {}) }, + ], + }], + }; +} + +const errorsOf = (stack: AnyRec) => validateStackExpressions(stack).filter((i) => (i.severity ?? 'error') === 'error'); +/** + * The judge's own findings — every refusal it writes says the root "is not bound + * in" a flow scope. Controls and stand-downs read these, so a node-config refusal + * about an unrelated key can neither mask nor fake a verdict here. + */ +const rootFindings = (stack: AnyRec) => errorsOf(stack).filter((i) => / is not bound in (a flow expression|this flow's expression scope)/.test(i.message)); +const CURRENT_USER_REMEDY = 'a flow binds the run\'s user as `current_user` only'; + +describe('the triage pins (#22565)', () => { + it('an edge condition `user.id == "u1"` is refused with the `current_user` remedy', () => { + const found = errorsOf(flowStack({ edgeCondition: 'user.id == "u1"' })); + expect(found).toHaveLength(1); + expect(found[0]!.where).toBe("flow 'f' · edge 'e1' (start→done) condition"); + expect(found[0]!.source).toBe('user.id == "u1"'); + expect(found[0]!.message).toMatch(/^`user` is not bound in a flow expression, so the run fails with `Unknown variable: user`/); + expect(found[0]!.message).toContain(CURRENT_USER_REMEDY); + expect(found[0]!.message).toContain('(`user.id` → `current_user.id`)'); + }); + + it('control: the same condition on a flow that declares a variable `user` passes', () => { + expect(validateStackExpressions(flowStack({ + edgeCondition: 'user.id == "u1"', + variables: [{ name: 'user', type: 'object' }], + }))).toEqual([]); + }); + + it('control: the remedy itself — `current_user.id == "u1"` — passes', () => { + expect(validateStackExpressions(flowStack({ edgeCondition: 'current_user.id == "u1"' }))).toEqual([]); + }); +}); + +describe('every run-user spelling formulas, RLS and the client accept', () => { + it.each([ + ['ctx.user.id == "u1"', 'ctx.user', 'ctx'], + ['os.user.id == "u1"', 'os.user', 'os'], + ['has(user.id)', 'user', 'user'], + ['user.?id.orValue("") == "u1"', 'user', 'user'], + ['"admin" in user.positions', 'user', 'user'], + ])('`%s` is refused naming `current_user`', (source, spelling, root) => { + const found = errorsOf(flowStack({ edgeCondition: source })); + expect(found).toHaveLength(1); + expect(found[0]!.message.startsWith(`\`${spelling}\` is not bound in a flow expression, so the run fails with \`Unknown variable: ${root}\``)).toBe(true); + expect(found[0]!.message).toContain(CURRENT_USER_REMEDY); + expect(found[0]!.message).toContain(`(\`${spelling}.id\` → \`current_user.id\`)`); + }); + + it('`ctx` / `os` read for something other than the user get the general refusal, naming the root', () => { + const found = errorsOf(flowStack({ edgeCondition: 'os.env == "prod"' })); + expect(found).toHaveLength(1); + expect(found[0]!.message).toMatch(/^`os` is not bound in this flow's expression scope/); + expect(found[0]!.message).not.toContain('current_user` in its place'); + }); +}); + +describe('every flow CEL site the door visits', () => { + const USER = 'user.id == "u1"'; + const env = (source: string) => ({ dialect: 'cel', source }); + + it.each([ + ['the start node\'s trigger gate', flowStack({ startCondition: USER }), "flow 'f' · node 'start' (start) condition"], + ['an edge', flowStack({ edgeCondition: USER }), "flow 'f' · edge 'e1' (start→done) condition"], + ['another node\'s `config.condition`', flowStack({ middle: [{ id: 'gate', type: 'decision', config: { condition: USER } }] }), + "flow 'f' · node 'gate' (decision) condition"], + ['a decision branch', flowStack({ middle: [{ id: 'pick', type: 'decision', config: { conditions: [{ label: 'y', expression: USER }] } }] }), + "flow 'f' · node 'pick' (decision) decision branch expression at config.conditions[0].expression"], + ['a screen field `visibleWhen`', flowStack({ middle: [{ id: 'ask', type: 'screen', config: { fields: [{ name: 'note', type: 'text', visibleWhen: USER }] } }] }), + "flow 'f' · node 'ask' (screen) screen field visibleWhen at config.fields[0].visibleWhen"], + ['an assignment value envelope', flowStack({ middle: [{ id: 'set', type: 'assignment', config: { assignments: { who: env('user.id') } } }] }), + "flow 'f' · node 'set' (assignment) assignment value at config.assignments.who"], + ['a create_record field envelope', flowStack({ middle: [{ id: 'mk', type: 'create_record', config: { objectName: 'acct', fields: { status: env('user.id') } } }] }), + "flow 'f' · node 'mk' (create_record) create_record field value at config.fields.status"], + ['an update_record field envelope', flowStack({ middle: [{ id: 'up', type: 'update_record', config: { objectName: 'acct', filter: { id: '{record.id}' }, fields: { status: env('user.id') } } }] }), + "flow 'f' · node 'up' (update_record) update_record field value at config.fields.status"], + ])('%s', (_site, stack, where) => { + const found = rootFindings(stack); + expect(found.map((i) => i.where)).toEqual([where]); + expect(found[0]!.message).toContain(CURRENT_USER_REMEDY); + }); + + it('an expression inside an ADR-0031 region body is judged against the same flow-wide set', () => { + const found = rootFindings(flowStack({ + middle: [{ + id: 'sweep', type: 'loop', + config: { collection: '{rows}', iteratorVariable: 'row', body: { nodes: [{ id: 'inner', type: 'decision', config: { conditions: [{ label: 'y', expression: 'user.id == row.owner' }] } }], edges: [] } }, + }], + variables: [{ name: 'rows', type: 'list' }], + })); + expect(found).toHaveLength(1); + expect(found[0]!.where).toContain("loop 'sweep' body"); + expect(found[0]!.message).toContain(CURRENT_USER_REMEDY); + }); + + it('a value slot\'s plain string is a literal, so its text is not read for roots', () => { + expect(rootFindings(flowStack({ middle: [{ id: 'set', type: 'assignment', config: { assignments: { who: 'user.id' } } }] }))).toEqual([]); + }); +}); + +describe('controls — one per binding kind the run has', () => { + it.each<[string, Parameters[0]]>([ + ['a declared variable', { edgeCondition: 'quota > 1', variables: [{ name: 'quota', type: 'number' }] }], + ['an `outputVariable`', { edgeCondition: 'acc.status == "x"', middle: [{ id: 'read', type: 'get_record', config: { objectName: 'acct', filter: { id: '{record.id}' }, outputVariable: 'acc' } }] }], + ['a node id (its outputs land under it)', { edgeCondition: 'read.record != null', middle: [{ id: 'read', type: 'get_record', config: { objectName: 'acct', filter: { id: '{record.id}' } } }] }], + ['an `assignment` target', { edgeCondition: 'flag == true', middle: [{ id: 'set', type: 'assignment', config: { assignments: { flag: true } } }] }], + ['a `loop` iterator and index', { edgeCondition: 'row != null && i >= 0', variables: [{ name: 'rows', type: 'list' }], middle: [{ id: 'sweep', type: 'loop', config: { collection: '{rows}', iteratorVariable: 'row', indexVariable: 'i', body: { nodes: [], edges: [] } } }] }], + ['a `loop` with no iterator binds the default `item`', { edgeCondition: 'item != null', variables: [{ name: 'rows', type: 'list' }], middle: [{ id: 'sweep', type: 'loop', config: { collection: '{rows}', body: { nodes: [], edges: [] } } }] }], + ['a `try_catch` `errorVariable`', { edgeCondition: 'caught != null', middle: [{ id: 'guard', type: 'try_catch', config: { errorVariable: 'caught', try: { nodes: [], edges: [] }, catch: { nodes: [], edges: [] } } }] }], + ['a screen field (a resume binds it under its name)', { edgeCondition: 'approve == true', middle: [{ id: 'ask', type: 'screen', config: { fields: [{ name: 'approve', type: 'boolean' }] } }] }], + ['a trigger field, read bare', { edgeCondition: 'status == "open" && amount > 5', objectName: 'acct' }], + ['a field of another object the stack declares (an action, a subflow parent or a map item can hand its record in)', { edgeCondition: 'region == "eu"', objectName: 'acct', objects: [...OBJECTS, { name: 'branch', fields: { region: { type: 'text' } } }] }], + ['a registry-injected column', { edgeCondition: 'owner_id != null', objectName: 'acct' }], + ['the engine roots', { edgeCondition: 'record != null && previous == null && vars.x == null && current_user != null' }], + ['a `$` variable through `vars` (a `$` name has no CEL spelling)', { edgeCondition: 'vars["$record"] != null' }], + ['a comprehension macro variable', { edgeCondition: '[1, 2].exists(user, user > 1) && [1].map(x, x > 0, x + 1).size() == 1' }], + ['a `cel.bind` variable', { edgeCondition: 'cel.bind(user, 2, user > 1)' }], + ['a CEL type name', { edgeCondition: 'type(record) == map && int("1") == 1' }], + ])('%s', (_kind, opts) => { + expect(rootFindings(flowStack(opts))).toEqual([]); + }); + + it('a binding declared by a node AFTER the reading edge still counts — one variable map serves the whole run', () => { + expect(rootFindings(flowStack({ + edgeCondition: 'later == 1', + middle: [{ id: 'tail', type: 'assignment', config: { assignments: { later: 1 } } }], + }))).toEqual([]); + }); + + it('a `cel.bind` variable is bound in its body only — the initializer reads the flow\'s scope', () => { + const found = rootFindings(flowStack({ edgeCondition: 'cel.bind(v, v, v > 1)' })); + expect(found).toHaveLength(1); + expect(found[0]!.message).toMatch(/^`v` is not bound in this flow's expression scope/); + }); +}); + +describe('the door stands down where the run can bind a name it cannot see', () => { + it.each<[string, AnyRec]>([ + ['a `script` (its function gets the live variable map)', { id: 'fn', type: 'script', config: { function: 'enrich' } }], + ['a `connector_action` (its handler gets the live variable map)', { id: 'push', type: 'connector_action', config: { connectorId: 'slack', actionId: 'post' } }], + ['a `wait` (a resume folds the caller\'s bag)', { id: 'hold', type: 'wait', config: { eventType: 'signal', signalName: 'go' } }], + ['a `subflow` (a resume folds the caller\'s bag)', { id: 'child', type: 'subflow', config: { flowName: 'other' } }], + ['a `map` (a resume folds the caller\'s bag)', { id: 'each', type: 'map', config: { collection: '{rows}', flowName: 'other' } }], + ['a plugin node type', { id: 'ai', type: 'ai_classify', config: {} }], + ['a screen with no fields', { id: 'ok', type: 'screen', config: { waitForInput: true } }], + ['an object-form screen', { id: 'form', type: 'screen', config: { kind: 'object-form', objectName: 'acct', idVariable: 'saved' } }], + ])('%s', (_why, node) => { + expect(rootFindings(flowStack({ edgeCondition: 'user.id == "u1" && foo == 1', middle: [node] }))).toEqual([]); + }); + + it('a trigger object the stack does not declare', () => { + expect(rootFindings(flowStack({ edgeCondition: 'user.id == "u1"', objectName: 'sys_user' }))).toEqual([]); + }); + + it('RED CONTROL — the same condition on the same flow with no opening node is refused, twice', () => { + expect(rootFindings(flowStack({ edgeCondition: 'user.id == "u1" && foo == 1' })).map((i) => i.message.slice(0, 6))) + .toEqual(['`user`', '`foo` ']); + }); + + it('the scope says why', () => { + const scope = flowCelRootScope({ nodes: [] }, [{ nodes: [{ id: 'fn', type: 'script', config: {} }] }], undefined, new Map()); + expect(scope.provable).toBe(false); + expect(scope.openedBy).toContain("node 'fn' (script)"); + expect(unboundFlowCelRoots('user.id == "u1"', scope)).toEqual([]); + }); +}); + +describe('the wider family — any root nothing binds (rides the same judge)', () => { + it('`foo.bar == 1` is refused naming the root and what could have bound it', () => { + const found = errorsOf(flowStack({ edgeCondition: 'foo.bar == 1' })); + expect(found).toHaveLength(1); + expect(found[0]!.message).toBe( + '`foo` is not bound in this flow\'s expression scope, so the run fails with `Unknown variable: foo`: no variable, ' + + '`outputVariable`, iterator, index or error variable, `assignment` target, node id or screen field of this flow is ' + + 'named `foo`, no object in the stack declares a field `foo`, and the engine binds only `record`, `previous`, ' + + '`vars` and `current_user`. Declare `foo` as a flow variable or bind it with a node, or correct the name.', + ); + }); + + it('each unbound root is its own finding; a bound one beside it is not', () => { + const found = errorsOf(flowStack({ edgeCondition: 'alpha == 1 && status == "x" && beta == 2', objectName: 'acct' })); + expect(found.map((i) => i.message.slice(0, 7))).toEqual(['`alpha`', '`beta` ']); + }); + + it('a near-miss of a trigger field is ONE finding: the refusal carries the did-you-mean, the advisory is withdrawn', () => { + const issues = validateStackExpressions(flowStack({ edgeCondition: 'stauts == "open"', objectName: 'acct' })); + expect(issues).toHaveLength(1); + expect(issues[0]!.severity).toBe('error'); + expect(issues[0]!.message).toContain('did you mean `status`?'); + }); + + it('a source that does not parse is the syntax check\'s alone — one broken expression, one finding', () => { + const issues = validateStackExpressions(flowStack({ edgeCondition: 'user.id ==' })); + expect(issues).toHaveLength(1); + expect(issues[0]!.message).not.toContain('is not bound'); + }); +}); diff --git a/packages/lint/src/validate-expressions.test.ts b/packages/lint/src/validate-expressions.test.ts index bc826435f0a..00bb2af7a09 100644 --- a/packages/lint/src/validate-expressions.test.ts +++ b/packages/lint/src/validate-expressions.test.ts @@ -312,7 +312,10 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => { expect(issues).toHaveLength(0); }); - // #1928 tier 3 — a likely field typo in a flow condition is a non-blocking warning. + // #1928 tier 3 — a likely field typo in a flow condition is a non-blocking + // warning WHERE THE DOOR CANNOT TELL whether a flow variable answers to the + // name: here a `script` node's registered function gets the live variable + // map and may bind any name (#22565 stands down on such a flow). it('warns (severity=warning) on a likely field typo in a flow condition', () => { const issues = validateStackExpressions({ objects: [{ name: 'crm_opportunity', fields: { stage: { type: 'select' }, amount: { type: 'currency' } } }], @@ -320,6 +323,7 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => { name: 'opp_won', nodes: [ { id: 'start', type: 'start', config: { objectName: 'crm_opportunity', condition: 'stagee == "closed_won"' } }, + { id: 'enrich', type: 'script', config: { function: 'enrich_opportunity' } }, ], edges: [], }], @@ -329,11 +333,35 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => { expect(issues[0].message).toMatch(/did you mean `stage`/); }); - it('does not warn when the bare ref is far from any field (likely a flow variable)', () => { + // [#22565] RE-JUDGED: on a flow whose every binding the door can read, the + // same typo binds nothing at all, so the run fails `Unknown variable: + // stagee`. One finding, at `error`, still naming the field it is near — the + // near-miss warning is withdrawn, not doubled. + it('refuses (severity=error) the same typo on a flow whose bindings are all in hand, with the did-you-mean', () => { + const issues = validateStackExpressions({ + objects: [{ name: 'crm_opportunity', fields: { stage: { type: 'select' }, amount: { type: 'currency' } } }], + flows: [{ + name: 'opp_won', + nodes: [ + { id: 'start', type: 'start', config: { objectName: 'crm_opportunity', condition: 'stagee == "closed_won"' } }, + ], + edges: [], + }], + }); + expect(issues).toHaveLength(1); + expect(issues[0].severity).toBe('error'); + expect(issues[0].message).toMatch(/^`stagee` is not bound in this flow's expression scope/); + expect(issues[0].message).toMatch(/did you mean `stage`/); + }); + + it('does not warn when the bare ref is far from any field (a flow variable)', () => { const issues = validateStackExpressions({ objects: [{ name: 'crm_opportunity', fields: { stage: { type: 'select' } } }], flows: [{ name: 'renewal', + // [#22565] Declared: the run binds a flow variable only when the flow + // says so, and an undeclared `expiring_deals` faults it. + variables: [{ name: 'expiring_deals', type: 'list' }], nodes: [{ id: 'start', type: 'start', config: { objectName: 'crm_opportunity' } }], edges: [{ id: 'e1', source: 'start', target: 'end', condition: 'expiring_deals.length > 0' }], }], @@ -825,6 +853,8 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => { objects: [{ name: 'crm_opportunity', fields: { amount: { type: 'currency' } } }], flows: [{ name: 'opp_flow', + // [#22565] Declared — see the `expiring_deals` fixture above. + variables: [{ name: 'expiring_count', type: 'number' }], nodes: [{ id: 'start', type: 'start', config: { objectName: 'crm_opportunity' } }], edges: [{ id: 'e1', source: 'start', target: 'end', condition: 'amount / 100 > 5 && expiring_count * 2 > 3' }], }], @@ -2598,6 +2628,10 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => { objects, flows: [{ name: 'convert_lead', + // [#22565] The variable the branch reads, declared — app-crm binds it + // with a node; a flow that binds nothing named `lead_record` faults + // `Unknown variable: lead_record` on the branch, and is refused for it. + variables: [{ name: 'lead_record', type: 'object' }], nodes: [ { id: 'start', type: 'start', config: { objectName: 'crm_lead' } }, { id: 'check', type: 'decision', config: { conditions: [{ label: 'Yes', expression }] } }, @@ -3498,6 +3532,10 @@ describe('validateStackExpressions — reads only keys the spec declares (meta-t // Its one "key" is `Map.prototype.get`; the metadata key it is built // from is read off the tabled `obj` receiver (`obj.attachedOnRead`). 'attachedOnReadIndex', + // [#22565] The flow CEL root judge's findings for one site, one unbound + // root at a time. Their keys are that module's own `{ root, members }` and + // the array's own methods — never metadata keys. + 'unbound', 'unboundRoot', ]); expect(receivers.filter((r) => !tabled.has(r) && !PLUMBING.has(r))).toEqual([]); }); @@ -4811,6 +4849,9 @@ describe('a blank string in a ledger predicate slot (#17493)', () => { const flowStack = (...middle: Record[]) => ({ flows: [{ name: 'blank_flow', + // [#22565] The variable the non-blank controls read, declared: an + // `amount` the flow does not bind is refused as an unbound root. + variables: [{ name: 'amount', type: 'number' }], nodes: [{ id: 'start', type: 'start' }, ...middle], edges: [], }], @@ -4903,6 +4944,9 @@ describe('a decision branch with no `expression` (#19961)', () => { const flowStack = (...branches: Record[]) => ({ flows: [{ name: 'absent_flow', + // [#22565] The variable the valid branch reads, declared — see the + // #17493 fixture above. + variables: [{ name: 'amount', type: 'number' }], nodes: [{ id: 'start', type: 'start' }, { id: 'check', type: 'decision', config: { conditions: branches } }], edges: [], }], diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index c0883b1d330..1a7324e7d29 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -2241,13 +2241,13 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression if (!source) return; const unbound = unboundFlowCelRoots(source, rootScope); if (unbound.length === 0) return; - const refused = unbound.map((u) => u.root); + const refused = unbound.map((unboundRoot) => unboundRoot.root); for (let i = issues.length - 1; i >= from; i--) { const issue = issues[i]!; if (issue.severity === 'warning' && isFlattenedNearMissFor(issue.message, refused)) issues.splice(i, 1); } - for (const u of unbound) { - issues.push({ where, message: unboundFlowCelRootMessage(u, rootScope), source, severity: 'error' }); + for (const unboundRoot of unbound) { + issues.push({ where, message: unboundFlowCelRootMessage(unboundRoot, rootScope), source, severity: 'error' }); } }; From 6f46ea6bb91b6d141ef8ee894a4e3577e17130e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 04:27:07 +0000 Subject: [PATCH 3/7] feat(spec): register the flow CEL unbound-root refusal (ADR-0087 D3, step 18) Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- docs/protocol-upgrade-guide.md | 19 ++++--- packages/spec/spec-changes.json | 38 ++++++++----- .../18.flow-cel-unbound-root-refused.ts | 44 +++++++++++++++ packages/spec/src/migrations/registry.ts | 53 +++++++++++++++++++ 4 files changed, 134 insertions(+), 20 deletions(-) create mode 100644 packages/spec/src/migrations/entries/semantic/18.flow-cel-unbound-root-refused.ts diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 0f23876693b..20c68d99f23 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -473,7 +473,7 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-conte ## Protocol 17 → 18 -Protocol 18 extends the publish-time refusal of unresolved placeholders, which protocol 17 applied to datasource connection config, to the memory driver's config-material persistence keys: `persistence.path` (file persistence and the `auto` override) and `persistence.key` (localStorage and the `auto` override) refuse `${…}` placeholder syntax at publish. Nothing resolves a placeholder there — the driver would create a literal `./${DATA_DIR}/…` path or write under the literal localStorage key — the same authored-under-a-false-belief shape, one surface over. The memory driver's `initialData` stays deliberately unjudged: it carries arbitrary record values, where a literal `${…}` may be legitimate data. It also retires `MetadataPluginConfig.additionalTypes` (ADR-0049 enforce-or-remove): the key was documented as THE plugin kind-declaration channel and read by nothing — the manager's type registry is seeded once from `DEFAULT_METADATA_TYPE_REGISTRY` and never merged with it, so authoring it configured nothing. A kind enters the live set as a side effect of registering an item of that kind. It also refuses malformed field `scale`/`precision` declarations: both are digit counts, so a non-integer or negative value (`scale: 2.5`, `precision: -1`) has no defined meaning — the write-time `scale` check, which refuses an over-scale value rather than rounding it, deliberately left it unenforced rather than invent floor/round semantics, which made the declaration silently inert. The schema now refuses both at parse (`z.number().int().min(0)`); the mechanical conversion deletes a malformed value from old sources and stored rows (behaviour-preserving), and the semantic entry tells the author to re-declare the count they meant. Finally, it removes the `objects["*"].allowExport` grant from the shipped admin permission sets — `admin_full_access`, `organization_admin` and the derived `organization_admin_no_bypass`. Measured on 17.0.0 GA, that wildcard made the export axis undeniable for an org admin: an application could declare an object exportable by nobody and the platform exported it anyway, with no supported opt-out, because a code-package set cannot be edited (`403 [not_overridable]`) and the admin held no app-authored set in which to write the per-object `false` that would have won. It is the earlier removal of `member_default`'s CRUD wildcard applied to the export axis, which had kept its wildcard by omission rather than by decision. From 18 an admin exports exactly what an app-authored set grants — a posture the same run measured to be already precise. Unlike everything else in this step it changes no schema, so nothing refuses at publish: the upgrade signal is behavioural and belongs here. Finally, it converges `record:chatter` / `record:discussion` `position` on the renderer's vocabulary (maintainer ruling 2026-08-15): the schema declared `sidebar`/`inline`/`drawer` — values no renderer branch ever compared, so the schema's own `sidebar` default silently rendered in flow while the value that actually docks the panel (`right`) was refused at publish. The row now speaks `bottom`/`right`/`left`; the mechanical conversion rewrites the old spellings (`sidebar` → `right`, `inline` → `bottom`, `drawer` → `right`), and the three schema defaults (`position`, `collapsible`, `defaultCollapsed`) are dropped per the `maxVisible` principle — renderer fallbacks stay the renderer's facts. It also retires `targetVariable` on `element:text_input` and `element:record_picker` (ADR-0049 enforce-or-remove): a declarative hint with zero readers in any repo — the live binding runs the other direction, resolved from the page variable whose `source` names the component's `id` (PageVariableSchema) — so an author who wrote only `targetVariable` got an input that wrote nothing, with a success receipt. The mechanical conversion strips the key from old sources (pure lossless delete — it never had an effect to lose); the tombstone's prescription says how to declare the binding that works. Finally, it retires the whole `element:filter` element (ADR-0049 enforce-or-remove at ELEMENT grain — the wider finding that the `targetVariable` retirement recorded and left for its own card): no renderer for the element ever shipped in any repo — objectui registers none, Studio's designer palette lists it as a no-renderer exclusion, and the 2026-06 page-liveness audit recorded it rendering "Unknown component type" — so every one of its six authorable keys was a capability claim nothing kept. All six are retiredKey tombstones; the mechanical conversion strips them from old sources (pure lossless deletes) and leaves the bare node, which the parse then refuses by name — delete the component. List surfaces own their filtering: a view's `userFilters` quick-filter bar / the list toolbar's filter builder. It also retires the whole `element:form` element (ADR-0049 enforce-or-remove at ELEMENT grain — the `element:filter` shape one element over, recorded by that retirement's own verdict sweep): no renderer for the element ever shipped in any repo — objectui registers none, Studio's designer palette lists it as a no-renderer exclusion naming the live replacement, and the 2026-06 page-liveness audit recorded it rendering "Unknown component type" — so every one of its six authorable keys was a capability claim nothing kept. All six are retiredKey tombstones; the mechanical conversion strips them from old sources (pure lossless deletes) and leaves the bare node, which the parse then refuses by name — delete the component. Use the object-bound `object-form` block instead — rendered, designer-publishable, its props declared for the component-props gate, and carrying the same intent (`objectName`, `fields`, `mode`, `submitText`). It also closes the two explicit column lists on relationship fields: `field.inlineColumns` entries are now the strict, name-keyed InlineGridColumnSchema (mirroring the objectui grid renderer's measured reads — objectui aligned the widget to `name` and retired the `field` spelling with no tolerant alias), and `field.relatedListColumns` entries are child field-name strings (the only form the related-list renderer hydrates fully). Both were z.array(z.any()) — a mis-keyed column published clean and rendered as blank cells with the right row count. The mechanical conversion respells inline `{ field }` entries as `{ name }` and folds related-list column objects to their identity string; unknown keys are named rejections at publish from this major. It also retires `measures..filters` on analytics cubes (ADR-0049 enforce-or-remove): a declared per-metric raw-SQL filter with zero consumers — both SQL strategies aggregate the metric's `sql` and never read `filters`, so a hand-authored `filters: [{ sql: "stage = 'closed_won'" }]` parsed, registered, and silently returned the UNFILTERED aggregate under the author's metric name (the same defect the dataset path had, on a hand-authored cube; the dataset half was repaired through its own structured channel when the analytics strategy began compiling each dataset measure's `filter`). The raw-SQL fragment also ran against the platform's structured-FilterCondition direction — it cannot be parameterized, re-targeted per driver dialect, or walked by the lint filter rules. The mechanical conversion strips the key from old sources (pure lossless delete — it never had an effect to lose); filter at query time with `where`, or use an ADR-0021 dataset measure's structured `filter` (a metric's own `sql` is a column reference, see `cube-member-sql-expression-retired`). Finally, it retires the stack `themes` carrier and `ThemeSchema` whole (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-21, disposition B: 退役授权面): the pipeline was live from the authoring gate through artifact ingest and stopped there — zero non-test readers of stored `theme` items, `theme` never a registered metadata type, no first-party app mounting the spec-aware provider, nothing selecting an active theme — so an authored theme shipped through every green gate and changed nothing on screen. `app.branding` stays the one colour surface; objectui's ThemeEngine/ThemeContext and their unit tests are retained. Semantic rather than mechanical: an authored palette has no lossless target (N themes vs M apps is a judgment), so the entry prescribes the hand move instead of deleting authored content silently. It also retires the `record:highlights` highlight-field `icon` (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-21, executing the 2026-08-20 census verdict): a declared key with zero read points in any direction — objectui's renderer normalized the authored object and carried `icon` into a highlight chip with no icon slot, `useRegisterHighlightFields` registers field NAMES only (structurally unable to carry it), and the Studio designer publishes the field list as plain strings — while six author-facing surfaces advertised the key (the shape that got the reference-rail `icon` refused, on the highlight chip). The mechanical conversion strips the key from the object entries of every `record:highlights` `fields[]` (pure lossless delete — the chip renders label and value only, so it never had an effect to lose); there is no replacement, and the live neighbour `readonly`, declared because the chip's read-only gate reads it, is untouched. It also retires the import mapping `lookup` transform's steering params (ADR-0049 enforce-or-remove — the sub-walk half of the 17.0.0 mapping cleanup that retired `extractQuery` / `errorPolicy` / `batchSize`): `fieldMapping[].params.object` / `.fromField` / `.toField` / `.autoCreate` declared a per-entry reference-resolution dialect the import path never implemented — `lookup` copies the cell through and resolution runs off the target field's own metadata — and `autoCreate` read as create-if-missing while an unresolved reference actually fails the row (`import_reference_not_found`), with or without the key. The eleven alias spellings convert to guidance so every spelling lands on the prescription; the mechanical conversion strips the four keys from stored sources (pure lossless deletes — none ever had an effect to lose). Finally, it retires the component-translation copy key `pages..components..submitLabel` and its `submit` alias (ADR-0049; maintainer ruling 2026-08-22): the face is measured, not mirrored — each copy key exists because some component in `ComponentPropsMap` declares it — and `submitLabel`'s only declarer was `element:form`, retired whole above, so the key had no declared component left to translate and the resolver overlay was its only reader. Retire won over re-anchor because the live form surface (`object-form`) speaks `submitText` (`I18nLabelSchema`), localizable at its own authoring site; re-anchoring would have widened the face for one word. The mechanical conversion strips the key from stored bundles and items (pure lossless delete — nothing read it once `element:form` was retired), at the acknowledged cost of dropping the bespoke-component route for that one word. Finally, it retires `page.components[].responsive` and the whole `ResponsiveConfig` layout vocabulary it carried (ADR-0049 D2; maintainer ruling 2026-08-22): the key was the destination the `dashboard.widgets[].responsive` tombstone prescribed as the live alternative, and a two-repo measurement (tsc-probe methodology with positive and negative controls) found the claim false — objectui's two implementations of the contract (`useResponsiveConfig`, `ResponsiveProtocol`) had zero callers and nothing read `.responsive` off a page component, so the prescribed migration moved an inert key to an inert key while the platform's own error message vouched for it. The same change repairs every shipped text that carried that redirect. `ResponsiveConfigSchema`, its two breakpoint maps and the `BreakpointName` enum had no other authorable carrier and leave with the key (RETIRED_DEFS_BY_MAJOR[18]); the live per-breakpoint channel on a page component is `responsiveStyles` (ADR-0065), which objectui really compiles. The mechanical conversion strips the key from stored pages (pure lossless delete — it never had an effect to lose). Finally, it retires nine of the eleven members of the plugin manifest's `contributes` block (ADR-0049 enforce-or-remove; triage graded 2026-08-21, cloud census leg discharged clean 2026-08-24): `events`, `menus`, `themes`, `translations`, `actions`, `drivers`, `fieldTypes`, `functions` and `commands`. A census of all three repos, with controls, measured that the whole monorepo contains exactly one non-test read of `manifest.contributes`, and it reads `kinds`; the other nine members parsed, entered the manifest, and changed nothing, while published docs and the schema's own JSDoc kept teaching them (`commands` documented Commander.js resolution the CLI dropped for oclif; `fieldTypes` advertised a registration seam that never existed). All nine are retiredKey tombstones mirroring `loading`; `kinds` survives (live reader), and `routes` was left to a ruling of its own, which retired it as well (the `plugin-manifest-contributes-routes-retired` entry). D3 semantic, no D2 conversion: a manifest is not a stack collection member, so a conversion would be a transform with no seam that ever runs. On the surviving `kinds` bucket it also retires the `globs` sub-field (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-24): the schema promised that declaring `globs` enables file-type discovery, but discovery globs `filePatterns` off the metadata type registry — which `contributes.kinds` does not extend, as `metadata-plugin.zod.ts` records outright — so an authored `globs` was accepted, stored, served back through `GET /metadata/kind`, and never consulted (zero value reads; the only non-test occurrences were the schema declaration and two type positions). The `kind` bucket itself and its `id` are untouched; file-type discovery stays single-channel on `filePatterns`. D3 semantic `plugin-manifest-kind-globs-retired`, same no-seam reasoning. Finally, it retires `object-grid`'s `defaultSort` (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-25, decision-inbox batch 4 — the producer half of objectui's `table.defaultSort` retirement, which the maintainer's 2026-08-22 「接受所有」 ruling on objectui's sort sink ordered): the legacy second spelling of `sort`, a single `{ field, order }` pair the renderer read only when `sort` was absent (measured at the `.objectui-sha` pin `190fbd01d`, `plugin-grid/src/ObjectGrid.tsx:1244-1246` and `:2847`, which wraps it `[schema.defaultSort]` — the exact array shape `sort` carries). One intent, two spellings; objectui's mirror schema is parity-test-only and parses nothing at runtime, so only the spec strictObject can refuse the key. The mechanical conversion carries the pair over — renamed to `sort` and wrapped in the array shape — when `sort` is absent, and strips it as a pure lossless delete when `sort` is present (the renderer's own precedence made it unread then). Finally, it retires the object-permission lifecycle bits `allowRestore` and `allowPurge` (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-26, decision-inbox batch 5, which chose retiring the two bits over gating operations that do not exist): the `restore` / `purge` ObjectQL operations the bits claimed to gate have never existed — no destructive lifecycle verb is in the engine's dispatch vocabulary, which a test pins — so granting the bits delivered nothing, and an author who declared `allowPurge: false` believed a lock on GDPR hard-deletion existed when the operation itself did not. Both keys are retiredKey tombstones; the evaluator's pre-mapping rows retired in the same batch (a dispatched `restore`/`purge` stays denied fail-closed via the DESTRUCTIVE_OPERATIONS backstop, so there is no ungated window), and the mechanical conversion strips the keys from every object grant in `permissions[].objects` (pure lossless delete — they never had an effect to lose). `allowTransfer` is ENFORCED — the server guards who may rewrite a record's owner — and stays. The keys return with the M2 lifecycle initiative (feature + RBAC in one batch), which stays open as their anchor. Finally, it narrows the per-option `default` key OUT of the form-view options vocabulary (ADR-0049 declared-but-unenforced; maintainer ruling 2026-08-28 on the console form renderer's analysis, disposition 甲): `SelectOptionSchema` serves two surfaces and only the OBJECT-field face reads `default` (enforced there by a maintainer ruling of 2026-08-10 — `applyFieldDefaults` falls back to the option marked `default: true`; that face, its alias rows and its precedence pin are untouched). On a form-view field's option list the key parsed clean and nothing read it — the insert-path fallback consults the object definition's options, never a form view's, and no form renderer seeds a value from it (measured against the console's form controls, none of which reads the key; the ruled census found ZERO authored occurrences across the tree, the example apps and the published *.form.ts corpus). The FormView vocabulary's own option shape (`FormSelectOptionSchema`, ui/view.zod.ts) now refuses the key with the prescription; the mechanical conversion strips it from stored sources (pure lossless delete — it never had an effect on this surface to lose). It also retires the paper metadata-customization protocol whole (ADR-0049 enforce-or-remove, maintainer ruling 2026-08-29): `kernel/metadata-customization.zod.ts` — the three-layer platform/user patch-overlay model with field-level change tracking and a 3-way-merge story — was exported, documented as the customization architecture, and implemented ONLY by an unreachable `packages/metadata` limb (no route served the paper `…/overlay`/`…/effective` endpoints; the four optional service members were called only by their own unit tests). ADR-0126 §6 wall 4 supersedes it on the record ("nothing may build against it"). The module's seven defs and the three section-5 API contracts leave via RETIRED_DEFS_BY_MAJOR; the authorable carriers `MetadataPluginConfig.customizationPolicies` / `.mergeStrategy` and `MetadataManagerConfig.persistence.overlayWritable` are retiredKey tombstones (no D2 conversion — plugin/manager configs are not stack collection members, the additionalTypes reasoning). The customization that actually ships: ADR-0005's org overlay and ADR-0126's packaged-metadata model. Finally, it canonicalizes the legacy objectql field-key dialect `reference_to` → `reference` on lookup/master_detail fields (the server half of the maintainer's 2026-08-31 ruling that the server normalizes the protocol and the renderer only executes it). `FieldSchema` has always refused `reference_to` by name, but stored `sys_metadata` rows written by seams that bypass the parse still carry it, held up today only by objectui's `reference ?? reference_to` fallback arms — which the ruling's objectui half deletes. The mechanical conversion renames the key (the house precedence for a shadowed alias: a canonical `reference` wins, a disagreeing pair is kept for the author), replays on every stored-row rehydration so the serve face only ever emits the canonical spelling, and `os migrate meta` rewrites old sources; the authoring-surface rejection with its rename prescription is unchanged. It also retires `connector.errorMapping` (ADR-0049 enforce-or-remove; triage ruling 2026-09-02): `ErrorMappingConfig` (4 keys) and its `ErrorMappingRule[]` (7 keys) were authorable through `ConnectorSchema` — and, via `DeclarativeConnectorEntrySchema`, through `stack.connectors[]` and the `/meta/connector` door — and read by nothing: no provider, dispatcher or materializer ever mapped an external error through the rules, so `unmappedBehavior` configured nothing and a rule's `userMessage` was never shown to anyone. That spelling is the live API-error channel's (`ApiError.userMessage`), so an author who wrote a rule here reasonably believed they were marking a refusal for an end user; the failure was silent in both directions. The carrier key is a retiredKey tombstone on the non-strict `ConnectorSchema` (a bare deletion would be a silent strip), the three defs — `integration/ErrorMappingConfig`, `integration/ErrorMappingRule` and the orphaned `integration/ConnectorErrorCategory` enum — leave via RETIRED_DEFS_BY_MAJOR, and the mechanical conversion strips the block from `connectors[]` (pure lossless delete; it never had an effect to lose). It also retires the fourteen hour/minute/day-shaped deadline keys of the incident-response, training and change-management families (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-02): six on the incident-response schemas, five on the training schemas and three nested in the change-management schemas, every one on the published surface and read by nothing — the schemas are mounted by no stack key and registered as no metadata type — so a compliance author who wrote `triageDeadlineHours: 4` held a deadline the platform never kept. All fourteen are retiredKey tombstones (the schemas are not strict; a bare deletion would be a silent strip) with no D2 conversion, for the additionalTypes reason: none of these schemas is a stack collection member, so the chain has no seam. It then retires those three compliance-shaped families WHOLE (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-05, ruled A, not roadmapped): the nineteen defs of `system/incident-response.zod.ts`, `system/training.zod.ts` and `system/change-management.zod.ts` — roughly a hundred declared keys, exported from `@objectstack/spec/system`, mounted by no stack key, registered as no metadata type, absent from the liveness ledgers, read by nothing repo-wide (examples, skills and objectui at the pinned sha included) — leave via RETIRED_DEFS_BY_MAJOR with one D3 semantic entry per family; the fourteen deadline-key tombstones leave with their defs' source and their RETIRED_KEYS_BY_MAJOR[18] entries stay as history. Boolean capability claims such as `notifyRegulators`, `requirePostIncidentReview`, `trackCompletion` and `approval.required` were the sharpest declared-≠-enforced shape left: an author writing `notifyRegulators: true` held a compliance promise the platform never kept. And it resolves the branch the deadline-key ruling held open — no roadmapped e-signature consumer — so `ESignatureConfig.expirationDays` / `reminderDays` (`data/document.zod.ts`, defaults 30 / 7 days, read by nothing) are retiredKey tombstones with no D2 conversion (`document` is no stack collection member), registered in RETIRED_KEYS_BY_MAJOR[18] with one D3 semantic entry. Finally, it moves the unit of every duration-shaped `z.number()` key whose unit lived only in its description into the key name (maintainer ruling 2026-09-02, no grandfathered baseline): `hook.timeout` and `job.timeout` become `timeoutMs` (mechanical rename, retired from the load path), and the five keys with no stack seam — `MetadataManagerConfig.cache.ttl` / `cache.databaseLoader.ttl` (seconds and milliseconds fourteen lines apart under one name), `DriverOptions.timeout`, and the tenant `connectionPool.idleTimeout` / `accessControl.sessionTimeout` whose unit the reference pages never published — are retiredKey tombstones with a semantic entry each, naming the suffixed key. The `data`, `ui`, `ai` and `integration` remainder closes the same sweep: `dashboard.refreshInterval` → `refreshIntervalSeconds`, the connector pair `health.circuitBreaker.monitoringWindow` → `monitoringWindowMs` and `triggers[].interval` → `intervalSeconds` (both halves later absorbed by the removal of the block each key lived in — see the connector retirements below), and the two datasource config keys `memory config.persistence.autoSaveInterval` → `autoSaveIntervalMs` (BOTH union arms — the `auto` arm forwards the same value to the same file adapter, so splitting them would have left one value with two spellings) and `turso config.timeout` → `timeoutMs` all convert, because a dashboard, a connector and a datasource are stack collection members stored as rows; the two with no seam — `ConversationAnalytics.duration`, computed at runtime and never authored, and `NoSQLQueryOptions.timeout`, a per-call driver argument — are retiredKey tombstones with a semantic entry each. That remainder is what takes `check:duration-unit-keys` to zero offenders over `packages/spec/src/**`; the gate goes red again by design when its declared population widens beyond that subtree. It also retires the three outer keys of `MetadataManagerConfig.cache` — `enabled`, `ttlSeconds` (the duration rename's respelling of `ttl`, never shipped) and `maxSize` — that the rename above surfaced (ADR-0049 enforce-or-remove): declared, defaulted and published, read by nothing — `MetadataManager` hands only `cache.databaseLoader` to the loader — so `cache: { enabled: false }` switched nothing off. All three are retiredKey tombstones registered in RETIRED_KEYS_BY_MAJOR[18] with one D3 semantic entry and no D2 conversion (a manager config is no stack collection member); the rename is folded into the removal, so `cache.ttl` now prescribes deletion rather than a hop to a retired key. It also retires the seven cron-typed positions nothing evaluated (ADR-0049; the 2026-09-06 ruling retired each family rather than marking it experimental): the two export-schedule crons, `ScheduleState.cronExpression`, `DataSyncConfig.schedule`, `CacheWarmup.schedule` and the two disaster-recovery crons were parsed into the cron envelope and read by nothing (the D7 ledger row `cron-declared-unwired`). All seven are DELETED OUTRIGHT — no retiredKey tombstone, no RETIRED_KEYS_BY_MAJOR[18] entry, no D2 conversion and no D3 semantic entry — so this step replays nothing for them and `migrate meta` lists no edit: the keys simply stop existing. That the chain is silent does NOT make the deletion silent to an author: the PARSE strips (no schema here is `.strict()`), but above it `lintUnknownAuthoringKeys` names the dropped key for the one position a stack manifest reaches — `os validate` and `os build` both print `connectors..syncConfig.schedule: 'schedule' is not a declared connector key, so its value is dropped at load.`, and `os validate --strict` EXITS 1 on that warning. The other six positions are unreachable from a manifest, so for those the parse-level strip is the whole of it. That is the maintainer ruling of 2026-09-10 on the retirement PR, taken over the seat recommendation to keep the connector D2, on the reading that customers do not upgrade major by major in order. It also retires the `type: 'page'` LIST-VIEW mount and its `pageName` binding (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-09 「撤」). The member was added so a view could render nothing of its own and delegate to an already-published page, but only the spec half landed: no renderer ever routed it — objectui's list-view switch shares its default arm with `grid` — so a page view drew an empty table where the page belonged, and the three parse refusals policing the binding policed a mount that never mounted anything. The enum VALUE carries its prescription on the `type` enum's own error map (an enum-value narrowing has no tombstone to hang one on, the `exportOptions` 'pdf' precedent); `pageName` is a retiredKey tombstone on both list-view doors. The D2 conversion STRIPS both keys rather than rewriting `type` to `'grid'`: `type` defaults to `grid` in the schema, so deleting it lands the row on exactly what it already rendered without this registry guessing a view type. The surviving page mount is the app navigation item (`PageNavItem.pageName`), untouched. It also retires `object-kanban`'s `quickAdd` (ADR-0049 enforce-or-remove; the spec half of the director-seat ruling of 2026-09-08 that the board grows no inline record-creation path and retires the key). The board FORWARDED the key into the shared renderer but the affordance is gated on both `quickAdd` and `onQuickAdd`, and `onQuickAdd` is a host-supplied FUNCTION JSON cannot carry and no producer puts on an `object-kanban` node — so the gate was permanently false. The drop was NOT silent, and that is what made it worse than silence: objectui's html tier reported the published key as `unknown-prop`, the same diagnostic a typo gets, so an author following the contract met a tool contradicting it with no way to tell which side was wrong. A retiredKey tombstone on `ObjectKanbanPropsSchema` with one D2 conversion that is a pure lossless DELETE (the key never had an effect to preserve) scoped by component `type`. Delete the key; `object-kanban` offers no quick-add control. It also retires the bare STRING `sort` clause on the list-view doors (ruled 2026-09-07: the legacy string clause is retired, one spelling, the array). This is the PRODUCER half of the seam whose consumer half shipped in objectui first: `convertSortToQueryParams` now refuses a runtime string, so `ListViewSchema.sort` was minting documents its own consumer rejects — a document that validated upstream failed downstream, and the author was told off by the wrong layer. Like the `type` value above it is a VALUE narrowing with no tombstone to hang a prescription on, so the surviving array member's own error map carries it, keyed on `issue.input` being a string. The D2 conversion REWRITES rather than strips, because the clause is losslessly mechanical: `'created_at desc'` is the tuple `{ field, order }`, a bare field name meant ascending and is written out as `order: 'asc'`, and the comma-separated multi-key form becomes one entry per key in the same order. A string that does not parse as that grammar — the `'-field'` dialect above all — is left alone and meets the door instead: that dialect belongs to `RecordRelatedListProps.sort`, never reaches `convertSortToQueryParams`, and retiring it was NOT ruled. It also removes `page.assignedProfiles` (ADR-0090 D2 / ADR-0049 enforce-or-remove; maintainer ruling 2026-09-12 「同意」). The key was authorable on the published `PageSchema` and named for the Profile concept ADR-0090 D2 deleted, while the schema's own alias table CORRECTED an authored `profiles:` into it — two files from `security/permission.zod.ts` answering the same word with "no Profile concept". Measured across this repository and objectui it had zero readers, so a page that "assigned profiles" was open to every caller who could reach it. It is a retiredKey tombstone on `PageSchema` — the def is still parsed from the `page` root, so there is an author to teach — and the two alias entries became refusals naming the permission-set route. The D2 conversion STRIPS the key — there is no lossless target, because which permission set a given profile name corresponds to is a judgement no walker can make, which is what the paired D3 semantic entry is for. Finally, it removes `aria` from the chart config (ADR-0049 enforce-or-remove; maintainer decision of 2026-09-12 — judge the protocol wrong for this one key). It is the last member of the `aria` family retired for the same measured reason as `dashboard.aria` and `dashboard.widgets[].aria` before it: an ARIA block an author can declare and nothing lowers to the DOM. It survived those two sweeps by depth — it sits inside the widget’s `chartConfig` bag, which no drill had reached until the per-key pass recorded in `liveness/dashboard.json`. That pass found `aria` to be the one `ChartConfigSchema` key with no reader on EITHER face: the chart implementation declares no `aria` prop, the presentation lowering names it nowhere, and the react block omits it from ``’s `dataProps`. Remove rather than enforce, because the same chart config already carries a WORKING accessible-name channel in `description` (lowered as `role="img"` + `aria-label`), and giving `aria` a reader would put two accessible-name sources on one element behind a precedence rule nobody has written — one node, one accessibility vocabulary. The tombstone rides `ChartConfigSchema` and therefore copies into `ReportChartSchema`, so the key is registered twice; the D2 conversion STRIPS it from all three authored sites (`dashboards[].widgets[].chartConfig`, `reports[].chart`, `reports[].blocks[].chart`) as a pure lossless delete — it never had an effect to lose. The two alias spellings that pointed at it, `accessibility` and `ariaProps`, became refusals carrying the same prescription rather than renames onto a tombstone. It also states, and enforces, who owns a dataset-bound chart's STRUCTURE (ADR-0021; maintainer ruling 2026-09-12): the dataset decides which series exist and which column each one reads, `chartConfig` carries appearance, and `dashboard.widgets[].chartConfig`'s `type`, `xAxis`, `yAxis` and `series` are refused by name on that carrier — the widget's own `type` is the chart family and `dimensions`/`values` are the selection. An authored `yAxis[].field` was a live membership channel: the renderer synthesised a series from it when the chart declared none, so one authored axis could silently re-point a dataset-bound series at another column and the chart still drew. The D2 conversion strips the four keys from dashboard widgets only — `ReportChartSchema` and the inline-data react `` tier keep their own axes — and the paired semantic entry carries what the stripped keys were saying, because an authored axis field may name a column the widget never selected and no walker can move that intent into the dataset. Finally, it splits the translation bundle type in two (maintainer ruling 2026-09-13: settings copy belongs to the platform): the platform bundle keeps all eleven groups and the per-app bundle (`stack.translations`, `defineTranslationBundle`) no longer declares `settings`, which is keyed by `SettingsManifest.namespace` and only platform code declares a manifest. Both bundles load into ONE served tree, so an app-authored `settings` branch did not sit inert — but nor did it override the platform: the app’s bundles arrive in `AppPlugin`’s `start()` (Phase 2) and the platform’s at `kernel:ready` (Phase 3), and `deepMerge` gives the later source the leaf, so what an application had was a GAP FILLER on a namespace it does not own — rendering only where the platform bundle carried no string for that key and locale. The registered `translation` ITEM follows the file door (maintainer ruling 2026-09-22: one app metadata type, two authoring doors, one accepted shape) and no longer declares `settings` either; there the group had been STRONGER, because the runtime-authored layer is read over the shipped bundles, so a stored item overrode the platform’s own copy. The D2 conversion strips the group from per-app bundle entries and from bare items alike — the runtime translation sync replays it over every stored row before merging — and the paired semantic entry says what the strip means at each door, because a notice reading "(removed)" says neither that an item’s overrides give way to the platform’s string nor that a gap falls back to the manifest's own English literal. Finally it retires object `tenancy.organizationField` (ADR-0049 enforce-or-remove). The key named the column a PLATFORM ROW is stamped from, as opposed to the column the object is WALLED by (`tenantField`); on an ordinary object those are the same column, and the entire protocol declared it exactly once — on `sys_api_key`, a better-auth-managed credential table this platform ships and no application authors. Its three readers were all platform-row writers, scope-pinned by name, so an application declaration was inert by construction while still forcing every future piece of organization logic to ask "what if somebody set this?". The divergence is NOT retired, only its authorability: it moves to `PLATFORM_STAMP_ORGANIZATION_COLUMNS` in `@objectstack/metadata-core`, keyed by object name and read by the stamp face alone, so audit stamping, the approval-row writer and the automation-run recorder keep their behaviour with no authorable input. The conversion is a lossless delete, and a lossless delete still leaves the author a judgment, which the family's D3 entry `object-tenancy-organization-field-retired` carries — an application whose tenant column genuinely is not `organization_id` declares `tenancy.tenantField`, which both walls the object and stamps its platform rows. It also retires `connector.connectionTimeoutMs` (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-22, letter A — the narrower SECOND decision the key was owed after the ruling that made its nine ledger siblings live deliberately left this one dead). Bounded, defaulted, `.describe()`d and served back by `/meta/connector`, so an author had every signal it worked — and no site ever applied it as a deadline. This retirement is NOT the zero-mention shape: five sites outside `packages/spec` read the key (the materialization fingerprint and the provider-context build in the automation service, `ctx.connectionTimeoutMs` in the `rest` and `openapi` provider factories, and the `?? 30000` fallbacks that put it back on the reported def), but every one is a pass-through whose only termini are the def `GET /connectors` echoes and the fingerprint that decides whether to re-materialize. The one mapping from authored policy onto the platform's outbound `fetch` was handed `retryConfig` and `requestTimeoutMs` only, so the key was carried and never honoured — the same parsed-unmarked-unenforced state ADR-0049 forbids, wearing a longer route. Nor was the `实现` arm available: a WHATWG `fetch` exposes one `AbortSignal` over the whole operation and never the connect phase, so bounding time-to-response with it would kill a slow-but-connected upstream the author meant to allow with a large `requestTimeoutMs`. `requestTimeoutMs` is the replacement and the bound the platform can keep. The carrier key is a retiredKey tombstone on the non-strict `ConnectorSchema` (a bare deletion would be a silent strip), registered under both def keys because `DeclarativeConnectorEntrySchema` carries it too, both carriers wrapping the same private `ConnectorBaseSchema`; the D2 conversion strips it from `connectors[]` as a pure lossless delete — it never had an effect to lose — because a stored connector row CAN carry it (the `PUT /meta/connector/:name` door persists the authored value and the stored-row rehydration seam is live for this type, both measured); and the withdrawn `ConnectorProviderContext` member, which is code and has no authored source to rewrite, leaves via the paired semantic entry instead. Finally it gives the one-filter-orthography convergence (ruled 2026-08-25: one filter spelling platform-wide, the rule array) its mechanical half at rest (ruled 2026-09-12): the D2 conversion `page-component-filter-record-to-rule-array` rewrites a record-form or single-level AST `filter` at the converged rule-array doors — `dataSource.filter`, the `object-*` / `element:number` / `element:record_picker` `filter` props and `object-grid.defaultFilters` — to the rule array wherever the mapping is lossless, and leaves a filter carrying `$and` / `$or` / `$not` (or any part with no lossless rule spelling) exactly as stored, because flattening a combinator changes which rows a page selects. It is retired from the load path, so authors are still refused at the door and taught the array; the stored-row seams and this chain replay it. It also retires the view item's `owner` and `hidden` (ADR-0049 enforce-or-remove). Both sat on the view-item identity layer, were accepted by the strict authoring door and by the wire member the `view` write door validates, and were stored verbatim — and nothing read either: both switcher read paths filter on `viewKind` + `object` and sort on `order`, so `hidden: true` hid nothing, and no per-user scope ever read `owner`, so a view marked as one user's was listed for everyone who can read the object. Per-user view scoping is a parked direction (ADR-0017, amended 2026-09-04), not a shipped mechanism. Both keys are `retiredKey()` tombstones on the SHARED shape, because that shape also feeds the `.strip()` wire member, where a bare deletion would be a silent strip. The D2 conversion `view-item-owner-hidden-removed` strips them from the view item RECORD spelling only, as a lossless delete, in both collections a record travels in — `views` (stack sources and stored rows) and the assembled-manifest `viewItems` channel (package export, environment artifacts), whose registration parse would otherwise refuse an artifact assembled before this release. It also retires a `joined` report's `chart` at both coordinates (ADR-0049 enforce-or-remove): the joined renderer draws each block as a table and returns before the one container `chart` read, and no renderer reads a block's `chart` at all, so a chart on a joined report parsed, passed the chart-bindings lint, and plotted nothing. The key leaves `JoinedReportBlockSchema`'s closed shape (its `guidance` table carries the prescription) and the joined arm of `ReportSchema`'s refinement refuses a container `chart`; `chart` stays live on every non-joined report. The D2 conversion `report-joined-chart-removed` strips both as a pure lossless delete — neither ever had an effect to lose — because a stored report row CAN carry them (the Studio report form offered a block `chart` input until this change); it is retired from the load path, so authors are refused at parse rather than rewritten. It retires the view item's `owner` / `hidden` pair on the flattened overlay door too (ADR-0049; the view item's disposition for the same key pair, followed here as triage directed): the lean personalization PUT with no `config` declared its own `owner` / `hidden`, accepted and stored them, and nothing read either. Both are `retiredKey()` tombstones on the two overlay members with the view item's own prescription texts, and the D2 conversion `view-overlay-owner-hidden-removed` strips them from the flattened spelling (no `config`, no container slot) in `views` and `viewItems`, so a stored overlay row is served without them. A row that held other view keys is then valid again and re-saves; a row that held nothing but its identity and the two keys is left identity-only, which the door refuses, so it is badged invalid, refused on a whole-row re-save and reported `failed` by `os migrate meta --stored --apply` until it is deleted or given the setting its author meant. Its D3 record is the semantic entry `view-overlay-owner-hidden-retired`. It also narrows form `layout` to `vertical` | `horizontal` on both surfaces that declared the four-arm enum — the `object-form` page component and the form view (ADR-0049 enforce-or-remove). No renderer ever gave `inline` or `grid` a behaviour of its own: every form presentation folded both to `vertical`, multi-column is `columns` (honoured under either layout), and `inline` is a toolbar / filter-row pattern rather than a record-form layout — redundant vocabulary under the maintainer's family criterion (a capability mainstream platforms have is served once, here by `columns`), retired with no alias window. Both enums refuse the two values with a per-value prescription naming `columns`; the D2 conversion `form-layout-inline-grid-to-vertical` rewrites them to `vertical` (behaviour-preserving, `columns` untouched) on `object-form` page components, on every form payload a view carries, and on the assembled-manifest `viewItems` channel. It also removes `currencyConfig.precision` (ADR-0049 enforce-or-remove): declared and validated against ISO 4217, read by no renderer or runtime — a currency amount's decimal places are its currency's ISO 4217 minor unit, derived from the currency itself. The D2 conversion `currency-config-precision-removed` strips it from every field's `currencyConfig` as a pure lossless delete, which matters most at rest: the schema used to bake `precision: 2` into parse output, so stored object rows and built artifacts carry it without anyone having written it. Retired from the load path; an authored key is refused with the prescription. It also retires the RLS policy's `tags` (ADR-0049 enforce-or-remove; graded RETIRE by the maintainer's criterion — no mainstream platform tags a row-level policy): the key promised categorization and reporting for governance and compliance, and nothing ever read it — the RLS compiler never consulted it and no preview rendered it. It is a `retiredKey()` tombstone on `RowLevelSecurityPolicySchema` (the `priority` posture one key over), and the D2 conversion `permission-rls-tags-removed` strips it from every policy in `permissions[].rowLevelSecurity` as a lossless delete, so a stored permission row that still carries it replays clean. It is retired from the load path, so authors are refused at parse rather than rewritten. Its D3 record is the semantic entry `permission-rls-tags-retired`. Finally, it removes `aria` from the action (ADR-0049 enforce-or-remove), the fourth member of the `aria` family after `dashboard.aria`, `dashboard.widgets[].aria` and the chart config's, and retired for the same measured reason: an ARIA block an author can declare and nothing lowers to the DOM. The liveness ledger had graded it `live` on an uncited "partial" note with no reader behind it; at the pinned renderer, none of the surfaces that render an action — button, icon, menu, group and bar, the row and bulk action menus, the record quick-actions toolbar — reads it. Remove rather than enforce, because every one of them already takes the accessible name from the action's required `label` (visible text, or `aria-label` on an icon-only action), and the node that places the actions carries the node-level `aria` block — a per-action block would be a second spelling of both. The D2 conversion `action-aria-removed` STRIPS the key from stack actions and object-nested actions as a pure lossless delete, retired from the load path so authors are refused at parse; its D3 record is the semantic entry `action-aria-retired`. It also retires the connector resilience family (ADR-0049 enforce-or-remove, one batch): `connector.health` — the `healthCheck` probe (eight keys) and the `circuitBreaker` (six) — `connector.status` and the connector-nested `webhooks`, sixteen authorable keys with no reader outside the spec package. No loop ever polled a connector endpoint or tripped a breaker; nothing read an authored `status` (the runtime publishes a computed `state`, and participation is `enabled`); and a webhook nested in a connector was never registered as a `webhook` item, so it was never materialized or delivered — the top-level `webhooks:` collection is the delivered one. The three carrier keys are retiredKey tombstones on `ConnectorBaseSchema`, registered under both carrier defs; `status`, defaulted `'inactive'`, joins `connectionTimeoutMs` in the retired-default residue stage, because every 17.x parse emitted it into every connector. Seven defs leave whole — `ConnectorHealth`, `HealthCheckConfig`, `CircuitBreakerConfig`, `ConnectorStatus`, `WebhookConfig`, `WebhookEvent`, `WebhookSignatureAlgorithm` — and the D2 conversion `connector-resilience-keys-removed` strips the three keys from `connectors[]` and stored rows as a pure lossless delete (the nested webhooks are stripped, never moved: moving them would start deliveries that never happened). It ABSORBS the breaker half of the duration rename above: `health.circuitBreaker.monitoringWindow` → `monitoringWindowMs` is no longer converted, because the whole block it lived in is now removed. Finally it makes edge-branched `decision` nodes EXCLUSIVE (maintainer ruling 2026-09-23, 「跟主流对齐」): the first conditioned out-edge that holds, in declaration order, is the branch, and taking every true branch is the declared `mode: 'inclusive'`. The D2 conversion `flow-decision-mode-inclusive-explicit` writes that key onto every decision with two or more conditioned out-edges and no `conditions` list, so a flow written while every true branch ran keeps its behaviour; it is a default flip, so it is retired from the load path AND refused by the flow rehydration seam and the artifact-ingestion door, and replays only here — the paired semantic entry carries the judgment the diff then asks for. BREAKING for flows stored in `sys_metadata`, by maintainer ruling: such a decision with no `mode` takes the first-match meaning on upgrade and nothing rewrites it; `os migrate meta --stored` lists each one for review, and `mode: 'inclusive'` is the one-line fix where a node meant every branch. It also retires the list view's own `tabs` (ADR-0049 enforce-or-remove). The key parsed and was stored at every list-view door and drew nothing: a list view's own `tabs` has no reader, the one component that would draw it has no production mount, and the tab strip above an object's records is the saved-view switcher, which renders one tab per `listViews` entry and reads no `tabs` key (`userFilters.tabs`, a different key of the same element type, is read and rendered, and stays). The key is a `retiredKey()` tombstone on the list-view shape (its prescription says how to move each tab to a named `listViews` entry); `ViewTabSchema` itself stays, because the page-only `userFilters.tabs` preset bar reuses it and renders. The D2 conversion `view-list-tabs-removed` strips the key from every list payload in `stack.views[]` as a lossless delete, and is retired from the load path, so authors are refused at parse rather than rewritten. It retires the inner `name` on cube members — `measures..name` and `dimensions..name` (ADR-0049 enforce-or-remove) — by the mainstream criterion: Cube.dev and LookML key a member by its declared name, with no second inner name that can disagree. Both member bags are records, and every consumer already resolved a member by its record KEY, publishing and querying it as `.`; the REQUIRED inner copy was read by nothing, and one that disagreed with its key was silently ignored. The keys are retiredKey tombstones on `MetricSchema` and `DimensionSchema`, and because the key was required, every stored or built cube carries it: the D2 conversion `cube-member-inner-name-removed` strips it from every member of every cube, retired from the load path, and its notice prints a disagreeing value beside the key that stays. Its D3 record is the semantic entry `cube-member-inner-name-retired`, which asks the author of a disagreeing name which spelling they meant. It also retires the connector `triggers` array (ADR-0049 enforce-or-remove; ADR-0041 keeps connector-event triggers in its third tier, as their own trigger package): the `ConnectorTrigger` shape — `key`, `label`, `description`, `type` (`polling` / `webhook`) and `intervalSeconds` — was read by nothing. The automation engine registered a connector's actions only, its trigger registry holds FLOW trigger kinds that no connector trigger ever entered, no polling loop read an interval and no receiver was driven by a `webhook` trigger, so a declared trigger never started a flow. `triggers` is a retiredKey tombstone on `ConnectorBaseSchema`, registered under both carrier defs; the provider-bound refusal of the key, whose reason (the provider derives triggers) was untrue, is gone with it, since the tombstone refuses every value on every carrier. `ConnectorTrigger` leaves whole, and the D2 conversion `connector-triggers-removed` strips the array from `connectors[]` and stored rows as a pure lossless delete — never turning a trigger into a flow, which is the author's decision (an `api` flow for an external event, a `schedule` flow for a scheduled pull, each calling the connector's action). It ABSORBS the trigger half of the connector duration rename (its breaker half went with `health` above), so `connector-health-and-trigger-durations-unit-in-key`, with neither half left, is no longer in this step. It also retires a cube's `refreshKey` whole — the refresh cadence `every` and the data-change probe `sql` (ADR-0049 enforce-or-remove). Nothing read either key, and no analytics result is cached, so a declared cadence refreshed nothing and every query was computed when it was asked, as it still is. The key is a retiredKey tombstone on `CubeSchema`, and the D2 conversion `cube-refresh-key-removed` strips the whole block from every cube as a pure lossless delete, retired from the load path. Its D3 record is the semantic entry `cube-refresh-key-retired`. A refresh cadence is declared again when a result cache exists. It also narrows the `time` stored form to the zone-less wall clock the record validator already enforces (ADR-0053 D-C1), so a field default or an action param default or value with a `Z` or a UTC offset is refused when it is authored or submitted rather than on every insert that falls back to it. The D2 conversion `time-default-utc-suffix-dropped` drops a `Z` or a zero offset, which names the same wall clock, and leaves a non-zero offset as stored for its author to rewrite; its D3 record is the semantic entry `time-default-zone-refused`. It also retires the page header's `breadcrumb` switch (ADR-0049 enforce-or-remove): no renderer ever drew a trail for it — objectui drew an empty slot that nothing filled — and the navigation trail is drawn once, by the app shell's header. The key is a retiredKey tombstone on `PageHeaderProps`, beside the `icon` that row lost at 17, and the D2 conversion `page-header-breadcrumb-removed` strips it from every `page:header`, `true` and `false` alike, retired from the load path. Its D3 record is the semantic entry `page-header-breadcrumb-retired`. The `nav:breadcrumb` component type is not part of it: the Studio page palette still offers it. It also retires connector-attached sync from the connector (ADR-0049, the ENFORCE route by ruling): `connector.syncConfig` — `strategy`, `direction`, `realtimeSync`, `timestampField`, `conflictResolution`, `batchSize`, `deleteMode`, `filters` — and `connector.fieldMappings` — `source`, `target`, `defaultValue`, `dataType`, `required`, `syncMode` — fourteen keys no engine ever executed, whose `latest_wins` and `soft_delete` defaults read as configured policy and did nothing. The capability is mainstream, so the definition moves rather than lapses: every mainstream platform binds a sync to its TARGET, so a `mapping` gains `connectorSource`, the `rest` / `openapi` connector it pulls from, the read action and an optional timestamp `watermark`, and a `job` sets the cadence (no schedule key returns to the connector). That binding is declared in this step and executed in a later one. Both connector keys are retiredKey tombstones on `ConnectorBaseSchema`, registered under both carrier defs; `DataSyncConfig`, `SyncStrategy`, `ConnectorConflictResolution` and `ConnectorFieldMapping` leave whole; and the D2 conversion `connector-sync-keys-removed` strips both keys from `connectors[]` and stored rows as a pure lossless delete — never writing a `mapping`, which would start writes that never happened. It also narrows an analytics cube member's `sql` — `measures..sql` and `dimensions..sql` — to a column reference: a field of the cube's object, a relationship path ending in one, or `'*'` (maintainer ruling D, ADR-0021 "zero raw SQL / zero raw expressions" carried from the dataset layer to the cube members it compiles to; ADR-0049 enforce-or-remove). A SQL expression there names no single field, so no platform check could judge which fields it reads, and the two analytics strategies never agreed on it: the raw-SQL path ran it verbatim, the ObjectQL path refused it. It is now refused at parse with a prescription naming the ADR-0021 dataset form — a measure with its own structured `filter` for a conditional count or sum, and `derived: { op, of: [...] }` over named measures for a ratio, sum, difference or product. No D2 conversion: an expression has no mechanical rewrite into a dataset, so the semantic entry `cube-member-sql-expression-retired` carries the move, including the scale change a ratio makes (a `derived` ratio is a 0–1 fraction). It also closes the form view's inline grid columns: `subforms[].columns`, on `view.form` and on `formViews` entries, was `z.array(z.any())` while a relationship field's `inlineColumns` was already the strict `InlineGridColumnSchema`, so a mis-keyed column published clean and drew a blank grid column, and `scale` on a currency column, which the other carrier refuses under the maintainer's rulings of 2026-09-23 (option B) and 2026-09-24 (option 乙), published green. The carrier now references that schema, so both carriers are judged by it, with its own prescriptions. The D2 conversion `form-view-subform-columns-canonicalized` respells a `{ field }` column as `{ name }`, the respelling `field-column-lists-canonicalized` makes on `inlineColumns`: it rewrites stored rows and assembled artifacts and lists the edit under `os migrate meta`, and it is retired from the load path, so an author writing `field` meets the refusal. A view saved with a failing column is refused with the column schema's prescription, and a stored row carrying one is diagnosed at rehydration; neither is stripped, because which column an unknown key or a mixed `field`/`name` entry meant is the author's call, and a conversion that dropped the key would accept at load what the parse now refuses. Its D3 record is the semantic entry `form-view-subform-columns-closed`. On both carriers, the reach of `inline-grid-column-currency-scale-refused` extends to a column that declares no `type`: such a column takes its type from the child field, which the column schema cannot see, when the console hydrates it, so `defineStack`'s cross-reference check re-parses a column whose `name` is a `currency` field of the child object as the type it renders as, and the refusal of its `scale` is the column schema's own. Reach: the child object must be declared in the same stack; a column naming no field of it, or a subform whose child object comes from another package, is not judged there. It has no D2 conversion, for the declared-type entry's reason: deleting the key is the migration, and a conversion that dropped it would accept it at load, the grace window ruling B refused. Its D3 record is the semantic entry `inline-grid-column-identity-only-currency-scale-refused`. It also gives the executor target of an action one spelling on the page blocks that run one. `ActionSchema` has always refused `endpoint` with the rename to `target`, while the `action:button` and `action:icon` component rows declared `endpoint` as a key of their own, and the console's `api` handler reads `target` only — so an `api` button authored with `endpoint` was accepted by the props gate and called nothing. The rows now refuse it with the same rename, read from the one alias table both share. The D2 conversion `action-block-endpoint-to-target` renames the key on an `api` action, where the rename is lossless, retired from the load path so authors are refused at the door while stored rows and `os migrate meta` replay it; an `endpoint` on a block with no `actionType` or another one is left as stored and reported as a TODO. Its D3 record is the semantic entry `action-block-endpoint-spelling-retired`. Finally, it retires the form field's `publicPicker` block (ADR-0087 D2, immediate — the maintainer's ruling E, which reverses the earlier ruling that had declared it): an anonymous public form no longer offers record search. The block opted a lookup, `master_detail` or `user` field on a public form into a picker served by an unauthenticated route; that route is deleted, and the public-form resolve route now leaves those three field types off the anonymous rendering unconditionally. The schema refuses the key with the prescription; the mechanical conversion `form-field-public-picker-removed` strips it from old sources and stored rows (lossless in effect — its only reader was the deleted route), and the semantic entry asks the author how a visitor should now choose: a `select` field with static `options`, or a form behind sign-in. It also closes the third carrier of the inline grid column: an `object-master-detail-form` page block's `details` was `z.array(z.unknown())`, so a key its renderer does not read and `scale` on a currency column, which the other two carriers refuse under the maintainer's rulings of 2026-09-23 (option B) and 2026-09-24 (option 乙), went through `objectstack validate` green. Each detail entry is now a strict shape of the twelve keys the renderer reads, and its `columns` references `InlineGridColumnSchema`. Page-component `properties` is read by the component-props gate, which reports a failing entry or column as an advisory finding, and is not parsed on the metadata save or load path, so a stored page still saves and loads and no conversion is registered; the authored census found nothing to respell. `defineStack`'s identity-only check reaches the block wherever a page carries it, with the reach `inline-grid-column-identity-only-currency-scale-refused` records for the other two carriers. Its D3 record is the semantic entry `ui-object-master-detail-form-details-closed`. It closes the fourth carrier the same way: `record:line_items` had no `ComponentPropsMap` row — it was the one entry on the string-arm registration ledger — so the component-props gate skipped its props, and the showcase project page's five `field`-keyed columns published green over a grid of empty cells. The row declares the fifteen keys the renderer reads, requires `relationshipField` and at least one column, and its `columns` references `InlineGridColumnSchema`; the showcase columns are respelled `name` in the same change. The panel draws its columns as authored, with no hydration from the child object's field, so `defineStack`'s identity-only check does not reach it. Its D3 record is the semantic entry `ui-record-line-items-props-closed`. It also holds an ADR-0021 dataset's `field` — `dimensions[].field` and `measures[].field` — to the accept set the cube members it compiles to already hold, from one shared declaration: a field of the dataset's object, a relationship path ending in one, and on a measure also `'*'` (ADR-0021 "zero raw SQL / zero raw expressions"; ADR-0049 enforce-or-remove). The slot was a bare string that parsed any expression, while the analytics dataset door already refused one on every query, so an expression could be saved and never answered. It is now refused at parse with a prescription naming the ADR-0021 form — a measure with its own structured `filter`, or `derived: { op, of: [...] }` over named measures — and so are an empty string (a count omits `field` instead) and `'*'` on a dimension, which names no axis. The one lossless repair is D2: `dataset-count-measure-empty-field-removed` drops a `count` measure's empty `field`, which still counts rows. An expression has no mechanical rewrite into a column, so the semantic entry `dataset-member-field-expression-refused` carries the rest. It also closes the export options of an `object-grid` page block. `exportOptions` was `z.unknown()`, so a bare format array — the list view's legacy spelling, which the list view lifts to `{ formats }` — was accepted on the grid, whose renderer reads `exportOptions.formats` and lifts nothing: the export menu offered its csv/json default and the author's list was dropped. The row now takes the list view's five-member export options object by identity, not the list view's union, and refuses a bare array with the object form named, a format outside the enum and an undeclared key. Page-component `properties` is read by the component-props gate, which reports these as advisory findings, and is not parsed on the metadata save or load path, so a stored page still saves and loads and no conversion is registered: the bare array never worked here, and lifting it would change the menu a deployed grid shows. The authored census found nothing to respell. Its D3 record is the semantic entry `ui-object-grid-export-options-closed`. It also makes an agent's structured output JSON-only (ADR-0049 enforce-or-remove). The cloud AI runtime, which executes agents, enforces `structuredOutput` on every final answer and refused four of its members before an agent's first turn: the `regex`, `grammar` and `xml` formats — no key ever carried a pattern or grammar to check against, and an answer is checked only as JSON — and the `coerce_types` step, for which no coercion engine exists. All four are refused at parse with a prescription, and the D2 conversion `agent-structured-output-refused-members-removed` deletes a block whose `format` was retired, deletes a retired `fallbackFormat` and drops `coerce_types` from the pipeline, retired from the load path. It also retires the metric sub-caption at both ends (maintainer ruling 2026-10-01, which reverses the 2026-08-06 ruling that gave it a translation key of its own; ADR-0049). The widget translation key `dashboards..widgets..subCaption` overlaid a widget's `options.description`, a key the dashboard schema never declared and no authored widget wrote, so the overlay in `translateDashboard` was its only writer. The overlay is removed, `subCaption` is a `retiredKey()` tombstone on the widget translation node, and its former `subtitle` alias now carries the retirement instead of a rename onto a key that accepts nothing. A widget keeps one authored description, `widget.description`, which renders as the card-header subtitle and is translated by the widget's `description` key. The D2 conversion `translation-widget-sub-caption-removed` strips the key from bundle entries and stored translation items as a lossless delete of what is served, retired from the load path so authors are refused at parse; its D3 record is the semantic entry `translation-widget-sub-caption-retired`. It also makes an agent's memory contract state exactly what the runtime honours (ADR-0049 enforce-or-remove). The cloud AI runtime, which executes agents, recalls the newest `maxEntries` long-term notes before the first round, writes one every `reflectionInterval` delivered interactions, and keeps them in its own database store; before an agent's first turn it refused the `vector` store (the old default) and `redis`, an enabled `longTerm` missing either number, and a `reflectionInterval` without one. So `longTerm.store` is retired as a whole key — the memory store is platform infrastructure, not agent metadata — and the D2 conversion `agent-memory-long-term-store-removed` deletes it, losslessly, retired from the load path; and with long-term memory enabled both numbers are required at authoring, with no default declared, so an upgrading author chooses them. It also retires an agent's conversation state machine, `agent.lifecycle` (ADR-0049 enforce-or-remove). It was parsed and never read: no runtime moved an agent through a declared state or refused an undeclared transition, and enforcing it would have meant a statechart interpreter beside Flow, the two-engine shape ADR-0020 rejected. What it reached for is served elsewhere — a conversation phase is a skill selected by its `triggerConditions`, a multi-step process is a Flow, a record's status transitions are the `state_machine` validation rule — so authoring refuses the key with that prescription, and the D2 conversion `agent-lifecycle-removed` deletes it, losslessly, retired from the load path. The XState `StateMachineSchema` family, kept by ADR-0020 only for this door, left the package with it. It also retires a cube measure's custom-SQL-expression types — `number`, `string` and `boolean` from `AggregationMetricType`, so from `measures..type` (ADR-0049 enforce-or-remove). They marked a measure whose `sql` was the whole computation, and with that `sql` now a column reference they had nothing left to compute: the raw-SQL path returned the column unaggregated and the ObjectQL path refused the measure. Each is refused at parse with a prescription naming the six aggregates. No D2 conversion: the column alone does not say which aggregate the author meant, so the semantic entry `cube-metric-expression-types-retired` carries the choice, and a stored cube that still carries one is refused rather than rewritten. It also retires `object-grid`'s `resizableColumns` (ADR-0049 enforce-or-remove; objectui's ruling that `resizable` is canonical, under the startup rule of immediate retirement): the legacy second spelling of `resizable`, read only as `schema.resizable ?? schema.resizableColumns` (measured at the `.objectui-sha` pin `89cad75d55`, `plugin-grid/src/ObjectGrid.tsx:5361`). One switch, two spellings, and zero writers in either repository, so there is no window. A retiredKey tombstone on `ObjectGridPropsSchema` with one D2 conversion that follows the renderer's precedence: the value moves to `resizable` when that is absent, and strips as a lossless delete when it is present (it was never read then). Its D3 record is the semantic entry `object-grid-resizable-columns-retired`. It also types seven members of an `object-grid` page block: `rowHeight`, `rowColor`, `navigation`, `conditionalFormatting`, `bulkActionDefs`, `aggregations` and `operations` were `z.unknown()` (an array of it for `bulkActionDefs`), although the grid reads each with one shape, so `rowHeight: 42` passed every door and rendered as `compact`. The five a list view also declares take the list view's own schemas by reference; `aggregations` takes the measured `[{ field, type }]` with the query AST's aggregation functions, and `operations` the four booleans a grid read point names (`create`, `update`, `delete`, `export`), refusing `read` and `import`, which nothing reads. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-grid-row-members-typed`. It also types `navigation` on the `object-map`, `object-gantt` and `object-tree` page blocks (the first stage of the `ComponentPropsMap` `z.unknown()` close-out): each renderer hands it to the shared navigation hook, which reads `navigation.mode` and falls back to `page`, so `navigation: 42` and a bare mode string passed every door and opened the record page. The three rows now take the list view's `NavigationConfigSchema` by reference, the carrier the grid, kanban, calendar and timeline blocks already take. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-map-gantt-tree-navigation-typed`. It also retires the `ai:chat_window` page element (ADR-0049 enforce-or-remove), the `user:profile` shape one namespace over: no renderer for it ever shipped, and none is wanted — the console leaves it unregistered on purpose, because the floating chat overlay it mounts on every page is the supported AI chat entry point — so a page that placed one validated clean and drew "Unknown component type", and its four props configured nothing. The name leaves `PageComponentType` and is refused by name at the node, its `ComponentPropsMap` row stays as a whole-bag refusal carrying the same prescription, and the props def `AIChatWindowProps` is unpublished. No conversion is registered: the only edit is deleting the node, a layout decision that is the author's. Its D3 record is the semantic entry `ui-ai-chat-window-retired`; `ai:suggestion` is unchanged. It also narrows page `requires` to the kinds whose source is compiled at save (ADR-0080 §5; maintainer ruling 2026-10-03, letter A): the plugin-namespace list is derived from an html page's source when the page is saved, while on `react`, `full` and `slotted` pages nothing derived it, the Studio page editor dropped it on every save, and a load-time warning was its one reader. `PageSchema` now accepts the key only when `kind` is `html` or its deprecated alias `jsx`, and refuses it at `requires` on every other kind, a page that omits `kind` included, naming the key, the page's kind and the compiled kinds. The key stays live on html pages, so there is no tombstone. The D2 conversion `page-requires-non-compiled-kind-removed` deletes the key from those pages, retired from the load path, so stored rows and artifacts replay clean while authored sources are refused until edited; the delete is lossless. Its D3 record is the semantic entry `page-requires-non-compiled-kind-refused`. It also types eight list members of the `object-grid`, `object-kanban` and `object-calendar` page blocks (the second stage of the `ComponentPropsMap` `z.unknown()` close-out): the grid's `fields`, `selection`, `selectable`, `rowActions`, `bulkActions` and `batchActions`, the kanban's `columns` and the calendar's `calendar` were `z.unknown()` (an array of it for the lists), although each renderer reads them with one shape, so a `{ name }` entry in `bulkActions` passed every door and was skipped. The members a list view declares take the list view's own by reference (`batchActions`, the spelling the grid reads first, takes `bulkActions`'s); the grid's `fields` and `selectable` and the kanban lane take the measured shape. The grid's `columns` stays open: its group headers draw an authored column's `options`, which the list view's column entry does not declare. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-grid-kanban-calendar-list-members-typed`. It also refuses, at parse, a hook whose `body` targets a table of stored metadata, `sys_metadata` or `sys_metadata_history` (maintainer ruling 2026-10-03, letter A: an app-authored body may not touch those tables, whose only writer for a body is the metadata protocol). The runtime already refused such a hook where a body becomes a handler, so it never ran, while the metadata save door answered 200 for it. `HookSchema` now refuses the same set at `object`, or at the list member, with the runtime's prescription to change metadata through the metadata API, judged by the one predicate the runtime uses: a hook with a `body` in any form whose target names either table. A code `handler` and the wildcard `'*'` stay outside it, as they are at registration. No key is removed, so there is no tombstone, and no D2 conversion exists: a refused hook carries no intent a rewrite could keep. Its D3 record is the semantic entry `hook-body-stored-metadata-target-refused`. It also types four members of the `object-form` page block (the third stage of the `ComponentPropsMap` `z.unknown()` close-out): `contentLayout`, `submitBehavior`, `navigateOnSuccess` and `mobile` were `z.unknown()`, although the form reads each with one shape, so a `submitBehavior` `kind` the form does not know passed every door and fell through to the thank-you panel. `submitBehavior` takes the form view's own block by reference; the other three take the measured shape. The form's `fields` and `sections` and the master-detail form's two stay open — the form draws a `{ name }` field entry and an inline runtime field inside a section, which the typed shapes would refuse — and `customFields` stays open until the spec declares the runtime form field its entries are. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-form-members-typed`. It also completes the `element:text` `variant` convergence (the second release of the ruled two-release split): the enum is the nine values `ui:text` publishes — `h1`-`h6`, `body`, `caption`, `overline` — and the pre-convergence spellings `heading` and `subheading`, which every release since the nine were added still accepted, are refused by name with a prescription naming the level to write. The D2 conversion `element-text-variant-heading-levels` rewrites `heading` to `h2` and `subheading` to `h3` on every `element:text` page component — the heading element each one always rendered, so the outline is unchanged and the heading takes that level's style. The `body` default for an absent `variant` is unchanged. It also types two members of the `object-metric` page block (the fourth stage of the `ComponentPropsMap` `z.unknown()` close-out): `aggregate` and `trend` were `z.unknown()`, although the tile reads each with one shape, so `aggregate: 'count'` and a trend with no `value` passed every door, and the tile asked the server for a measure it does not have, or painted a lone `%`. `aggregate` takes the query AST's aggregation functions and the chart aggregate's `groupBy` union by reference, with `groupBy` optional because a metric is one number; `trend` takes the badge's measured shape. `drillDown` and `compareTo` stay open: each by-reference candidate declares a key the tile never reads (the chart drill-down's `filter`, the dashboard comparison's `dimension`), and the chart drill-down refuses the `report` the tile draws, so each waits on a ruling. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-metric-aggregate-trend-typed`. It also retires an `object-master-detail-form` detail entry's `sortField` (ADR-0049 enforce-or-remove; the spec half of objectui's own retirement of the override). The console stopped reading the authored override: the field its line grid stamps with each line's position on drag-reorder is derived from the child object — its first field named `position`, `sort_order`, `sequence`, `line_no`, `line_number` or `sort` — and the pinned console had crossed that change while the spec still declared the key, so an authored value published green and was dropped. A retiredKey tombstone on the strict detail entry with one D2 conversion that is a pure lossless DELETE scoped by component `type` and by position (`properties.details[]`); its D3 entry `object-master-detail-form-detail-sort-field-retired` carries the one judgment left, whether the child object declares the field the line order is kept in. It also types the `object-metric` page block's `compareTo` (the fifth stage of the `ComponentPropsMap` `z.unknown()` close-out) to the tile's read, per the ruling between the reference and the read: `{ kind }`, with `kind` the dashboard widget comparison's own vocabulary by reference, and `dimension` refused by name, because this inline tile shifts the date macros in its own `filter` and never reads a dataset time dimension. A bare kind string, a kind outside the two and a `dimension` passed every door and compared the wrong window. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-metric-compare-to-typed`. It also types the `object-metric` page block's `drillDown` to the tile's read (the same stage and ruling): its five list members — `enabled`, `title`, `target`, `columns`, `maxRows` — are the chart drill-down's own by reference, and `filter` and `mode` are refused by name, because a metric tile has no click event for a drill filter to resolve against and no row for `mode` to open; both passed every door and were ignored. The drill `report` stays open: the tile draws a dataset-bound report, but the spec declares no drill report yet, and declares that contract first. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-metric-drill-down-typed`. It also types the `object-grid` page block's `columns` (the fifth stage of the `ComponentPropsMap` `z.unknown()` close-out), the list member the second stage held: the grid's group headers drew a column's `options`, which the list view's column entry does not declare, and objectui has since retired that read and takes the labels from the object field only. So the member takes the list view's own `columns` by reference — all field names or all column entries — and a column keyed `accessorKey` / `header` / `name`, a mixed list or an undeclared column key (`editable`, `options`, `reference`), which passed every door and drew no column or was ignored, is refused. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-grid-columns-typed`. It also refuses, at parse, a flow `create_record`, `update_record` or `delete_record` node whose `objectName` is the string `sys_metadata` or `sys_metadata_history` (the maintainer ruling of 2026-10-03, letter A, applied to flows: app-authored work may not write those tables, whose only writer is the metadata protocol). The runtime already refused such a node before any write, at its first run, while every authoring door accepted the flow. `FlowSchema` now refuses the same set at `nodes.N.config.objectName`, through the one judge `registerFlow` and `objectstack validate` share, with the runtime's prescription to change metadata through the metadata API: one of those three write nodes whose `objectName` names either table by exact name. A `get_record` node and a dynamic target stay outside it: the run judges the name it hands the data engine. No key is removed, so there is no tombstone, and no D2 conversion exists: a refused node carries no intent a rewrite could keep. Its D3 record is the semantic entry `flow-write-node-stored-metadata-target-refused`. It also types the top-level `fields` of the `object-form` and `object-master-detail-form` page blocks (the last stage of the `ComponentPropsMap` `z.unknown()` close-out), the two members the third stage held: the form drew a `{ name }` field entry its own page-builder guide taught, with a `label`, `type` and `required` it silently dropped, and objectui has since retired that entry from every authoring face, drawing only a stored one by its name. So both rows take field names, objectui's own declaration of the member, and refuse an object entry with what to write instead — a `{ name }` entry is its bare name, and a `{ field }` entry belongs in a section. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-form-fields-names-typed`. It also types the `object-gantt` page block's `markers` (the same stage): its entries were `z.unknown()` because the marker contract lived only in objectui, so a marker with no `date`, a numeric `date` or a misspelled member passed every door and the chart drew no line, or drew it unlabelled. The spec now declares objectui's own authoring declaration of a marker, `{ date, label?, color? }` with `date` a string, and the row takes it. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-gantt-markers-typed`. It also types the `object-timeline` page block's `mapping` (the same stage): the binding record — four optional field names for an entry's title, date, description and marker colour — was `z.unknown()` because its contract lived only in objectui, so a bare field name or a misspelled member passed every door and the rail drew the default field. The spec now declares objectui's own declaration of it, and the row takes it. The stage's other members — the metric drill-down's `report`, the form's `customFields` and both forms' `sections`, the timeline's `items` and the action containers' members — stay open: each contract has more than one viable shape that no ruling decides yet. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-timeline-mapping-typed`. It also types the `object-kanban` page block's `conditionalFormatting`, the one member the `ComponentPropsMap` `z.unknown()` close-out held for a ruling: it was `z.unknown()` while objectui's kanban also authored a native rule dialect the list view refuses, so `42` or a rule with no `style` passed every door and the board painted no card for it. objectui has since made the list view's `{ condition, style }` rule the member's only authoring dialect, and the board evaluates it with the grid's evaluator, so the row takes the list view's own member by reference, as `object-grid` does. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-kanban-conditional-formatting-typed`. It also requires every block of a `joined` report to bind a `dataset` (ADR-0021 single-form, enforced under ADR-0049 enforce-or-remove): the schema comment and the reports guide both said each block is dataset-bound, but the joined arm of `ReportSchema`'s refinement required only a non-empty `blocks`, so a block with no `dataset` parsed, passed `objectstack validate` and every save door, and drew nothing: the joined renderer issues no query for it, and a report whose blocks all lack one falls through to the pre-9.0 presentation bridge, which issues none either. The arm now refuses each such block at `blocks[i].dataset`, naming the block, with the prescription to bind it to a dataset; `dataset` stays optional on the block shape, which is read only on a `joined` report. No key is removed, so there is no tombstone, and no D2 conversion exists: only the author knows which dataset a block was meant to show. Its D3 record is the semantic entry `ui-report-joined-block-dataset-required`. It also types the `object-form` page block's `customFields`, one of the two contracts the `ComponentPropsMap` `z.unknown()` close-out held as forks and the maintainer has since ruled: each member is the runtime form field the form draws, which the spec did not declare, so a member with no `name` or a misspelled member passed every door and the form drew the field without it. The spec now declares a closed runtime form field of the members the form draws, in camelCase, keyed by `name` — the `grid` widget's snake_case keys stay out until the widget reads a camelCase spelling — and the row takes a list of it. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-form-custom-fields-typed`. It also types the `sections` of the `object-form` and `object-master-detail-form` page blocks, the other ruled fork: a section's `fields` draws an inline runtime form field beside a name and the form view's `{ field }` entry, which the stored form view's section refuses, so the sections stayed `z.unknown()` and a misspelled key passed every door. Both rows now take one page-block section shape of their own — the form view's section keys plus those three entry arms, the inline arm the runtime form field — in canonical spellings only: a page block's `properties` is never parsed on the way to the form, so a deprecated section `visibleOn` or a string `columns`, which a form view folds at parse, was dropped, and is refused with the canonical spelling. The stored form view is unchanged. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-form-sections-typed`. It then types the three members the stages above held open, as the maintainer ruled them on the decision card for those forks. The `object-metric` drill-down's `report` is `ReportSchema`, by reference (fork 1, letter B): it waited until a joined report refused a block that binds no dataset, and since then every report the member admits is one the drill drawer draws — a report with no `dataset`, a bare report name or a `{ name }` reference, which the drawer answered by listing the records, is refused. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-metric-drill-down-report-typed`. It types the `object-timeline` page block's `items` (fork 4, letter B): each entry is one of objectui's two ruled kinds, closed — a feed entry `{ time, title, description, variant, icon, content, className }` or a gantt row `{ label, items }` of bars `{ title, startDate, endDate, variant }`, each date a string or epoch milliseconds — and a row refinement pairs each entry with the kind the block's `variant` selects, so a feed entry with no `title`, or a gantt row on a feed timeline, is refused instead of drawn empty. A feed entry's `content` (child components) is held unjudged until a writer appears. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-timeline-items-typed`. And it types the members of the `action:group` and `action:menu` page blocks, the last of those forks (the same card, fork 5, letter A): each member was an open record the container draws and runs itself, so a misspelled key, a node-style `actionType` or an `endpoint` no `api` handler reads passed every door. A member now takes `action:button`'s keys with its executor spelled `type`, measured from the containers' reads — an `action:menu` item reads no `size` and declares none — with the rows' prescriptions; `outcomeMessages`, a member `className` and a member `properties.params` are refused, and `outcomeMessages` stays undeclared on all four action blocks as one decision. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-action-group-menu-members-typed`. It then closes the one static-values spelling those members still accepted and the containers drop: an `action:group` or `action:menu` member's `params` takes the input list, an `ActionParam[]` array, only, unless the member's `type` is `api`, whose object `params` keeps its request-payload window. `params` carries one shape and no second value-bag key is declared, so an object `params` on any other member, which parsed and then reached no action, is refused at `actions.N.params` with the prescription to author an action with static parameter values as its own `action:button` node. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-action-group-menu-member-params-array-only`. It also judges an `approval` flow node's `config` at parse against the contract the spec declares for it, `ApprovalNodeConfigSchema`, WHOLE. The approval executor fails the node on any issue of that contract, while `objectstack validate` and `objectstack compile` exited 0 on an undeclared `escalation.bogusKey` or a `timeoutHours: 0.5` and compile copied it into the artifact. The approval node now joins a declared contract map beside the builtin executor contracts, read by the one judge `registerFlow` and `objectstack validate` share, with no plugin loaded: an undeclared key or a refused value is refused at `nodes.N.config.` in the contract's own words, its did-you-mean included, and a key left out as before. The builtin arm stays presence-only. No key is removed, so there is no tombstone, and no D2 conversion exists: the platform cannot know what the author meant. Its D3 record is the semantic entry `flow-approval-node-config-contract-refused`. Then the builtin arm stops being presence-only: a present value a builtin node's executor contract refuses is refused at parse, at `nodes.N.config.`, with the same `node-config-refused-by-contract` code. Every builtin executor parses its config against that contract before it acts, so a `create_record` `outputVariable: 42` or a screen field `min: '1'` used to pass `objectstack validate` and `objectstack compile`, register, and fail every run that reached the node. The arm judges only what the build can know the run will parse: never a value carrying a `{token}`, whatever its slot's type (held back by ruling, not admitted: outside `http` such a token in a number or boolean slot still fails at its first run, so those slots take a literal); on `http`, which parses after interpolating, only token-free values and never the credential-held `signingSecret`; on a `loop`, only one with a `body`; on the region containers, never the region slots. Key membership is untouched. No key is removed, so there is no tombstone, and no D2 conversion exists: the platform cannot know the value the author meant. Its D3 record is the semantic entry `flow-builtin-node-config-values-refused`. It also retires the flat-list form of a package manifest's `permissions` (ADR-0049 enforce-or-remove): `ManifestPermissionsSchema` was a union of a list of permission strings and the structured ADR-0025 block `{ services, hooks, network, fs }`, and nothing ever acted on the list — the loader registers the consented grant set, never the manifest's request — so the block is now the only form. A list is refused at parse with its prescription, and the D2 conversion `manifest-permissions-string-list-removed` strips it from the stack's manifest and every `packages[].manifest` as a lossless delete, retired from the load path; translating what each dropped string meant into the four lists is the author's judgement, not a rewrite. It also makes a declared index state its uniqueness scope (ADR-0120 D1, staged to this protocol by D7). On `indexes[].unique`, bare `true` was the one spelling whose scope was positional: it built the index over exactly `fields`, one holder across the whole installation, while reading like "unique per organization" to an author who knew the field-level meaning. The parse now refuses it with a prescription naming both words — `'global'` (installation-wide, the index bare `true` built) and `'organization'` (one holder per organization). Field-level `unique: true` is untouched. The D2 conversion `declared-index-unique-scope` rewrites a declared index's bare `true` to `'global'`, which is lossless and drift-free by construction, retired from the load path so authors are refused at the door while stored rows, built artifacts and `os migrate meta` replay it. Its D3 record is the semantic entry `declared-index-bare-unique-true-retired`: whether each respelled index was really meant installation-wide is the author's call. It also takes the injected organization column off seven deployment-level platform tables — `sys_job`, `sys_job_run`, `sys_job_queue`, `sys_flow_dispatch`, `sys_migration`, `sys_migration_journal` and `sys_presence` (ADR-0131 D7). A writer census found no writer that attributes a row of any of them to an organization, so the column only ever held NULL, and under a walled posture the tenant wall hid every row from every reader. Each now declares `systemFields: { tenant: false }` and the object-level capability gate `requiredPermissions: ['manage_platform_settings']`: with no column there is no wall, so reads are governed by object permission, and the gate keeps one organization's administrator off another organization's rows. Nothing in stack metadata is rewritten; an existing database keeps the column as an orphan the boot drift report names, and `os migrate apply --allow-destructive` drops it. The D3 records are the seven `sys-*-organization-column-retired` semantic entries. It also refuses, at parse, a flow edge that does not resolve in its own graph or that repeats an earlier one. An edge's `source` and `target` must name nodes of the graph that declares it — the flow's own nodes, or the region body's for an edge inside a region — because the engine resolves them there alone, and a dangling edge carried the run nowhere, silently; and an edge with the same `source`, `target`, `type`, `condition` and branch `label` as an earlier edge of that graph is refused, because the engine runs a target once per out-edge it selects and a copy ran it again. Both are judged in the region walk the node-id rule uses, so `objectstack validate`, `registerFlow` and the metadata save door agree. No key is removed, so there is no tombstone, and no D2 conversion exists: a dangling endpoint carries no intent a rewrite could recover, and dropping a copy changes how often its target runs. Its D3 record is the semantic entry `flow-edge-unresolved-or-repeated-refused`. And the builtin arm judges key membership where no other door does: a key a `script` or `subflow` node's executor contract does not declare is refused at parse, at `nodes.N.config.`, with the same `node-config-refused-by-contract` code. Those two descriptors publish no `configSchema`, so `registerFlow`'s undeclared-key check skipped them, while their executors parse the strict contract and refuse the node on an undeclared key: a `script` `bogusKey` used to pass `objectstack validate`, `objectstack compile` and registration and fail every run that reached the node. Every other builtin keeps its undeclared keys at registration, against its descriptor; a retired `script` key keeps its tombstone. No key is removed, so there is no tombstone, and no D2 conversion exists: the platform cannot know what an undeclared key was meant to be. Its D3 record is the semantic entry `flow-script-subflow-config-undeclared-keys-refused`. It also moves the settings cascade's global rung out of the tenant-scoped `sys_setting` into the new tenant-less `sys_platform_setting` (ADR-0131 D7): one row per namespace and key for the deployment, no organization column, reads governed by the `manage_platform_settings` capability. The settings service writes a global-scope key there and reads the rung from there alone, and the `global` option of `sys_setting.scope` retires because no write reaches it. The cascade order and the `global` resolution source are unchanged. Nothing moves automatically: the v18 upgrade ceremony moves existing global rows, `sys_secret` handles included, and they open unchanged because the ADR-0128 AAD binds no holder object and no organization. The D3 record is the `sys-setting-global-rung-moved` semantic entry. It also retires the document family WHOLE (ADR-0049 enforce-or-remove; the ruling of record on PDF and print documents, letter B′, 2026-10-08: "A document is a page with a print declaration; no new template type"): the four defs of `data/document.zod.ts` — `data/DocumentTemplate` (a docx template with placeholders), `data/Document`, `data/ESignatureConfig` and the orphaned `data/DocumentVersion` — exported from `@objectstack/spec/data`, mounted by no stack key, registered as no metadata type and read by nothing in this repository, objectui or hotcrm, leave via RETIRED_DEFS_BY_MAJOR with one D3 semantic entry, so that "template" means one thing: a printable document is a page that declares `print`. The `ESignatureConfig` deadline-key tombstones leave with their def's source and their RETIRED_KEYS_BY_MAJOR[18] entries stay as history. It retires the single-brace `{…}` template dialect from the flow VALUE slots (the C half of the maintainer's ruling D on the flow expression dialects): the `assignment` node's values, in all three shapes, and the `fields` map of `create_record` and `update_record`, where a CEL value envelope is already the expression form. A string there is now the literal text it spells, and one carrying a `{…}` token is refused — by `FlowValueSlotSchema`, `registerFlow`, `objectstack validate` and the executor alike — with the CEL spelling of each token. No D2 conversion exists: every authored spelling was measured lossy (an absent key writes nothing under the template and fails under CEL; CEL divides two integers as integers), so which value an absent key should write is the author's judgment. The date macros and the `$User` paths keep their meaning until CEL can spell them. Its D3 record is the semantic entry `flow-value-slot-template-dialect-refused`. It also takes the injected organization column off the compliance ledger, `sys_audit_log` (ADR-0131 D7): some of its rows are about deployment-level actions no organization owns, so the organization a row is about stays in the attribution field `tenant_id`, which every writer already stamps, and never becomes the tenancy anchor. With no column there is no wall, so a platform administrator now reads the rows about no organization too; an organization reader is scoped to the rows about its active organization by the platform row policy `sys_audit_log_org`, stripped when no wall is enforced, and `organization_admin` names the ledger without the superuser bits so its wildcard bypass cannot skip that policy. Per-tenant retention partitions on `tenant_id`. Nothing moves automatically: an existing database keeps the column as an orphan the boot drift report names, for the v18 ceremony to drop once its values are confirmed in `tenant_id`. The D3 record is the `sys-audit-log-organization-column-retired` semantic entry. Then the builtin key arm covers every builtin whose contract registration could judge: a key the executor contract of a `get_record`, `create_record`, `update_record`, `delete_record`, `notify`, `http`, `screen`, `map`, `loop` or `parallel` node does not declare is refused at parse, at `nodes.N.config.`, with the same `node-config-refused-by-contract` code, closed with the rename-or-remove remedy. Registration's descriptor walk refused those keys already, after `objectstack validate` and `objectstack compile` had passed them, and it now stands aside for those types, so each has one judge; the declared key sets were measured equal first, so registration refuses what it refused before. `try_catch` waits for its contract's `retry` to close (below): it stripped an unknown key where its descriptor closes it. No key is removed, so there is no tombstone, and no D2 conversion exists. Its D3 record is the semantic entry `flow-builtin-node-config-undeclared-keys-refused`. It executes ADR-0032 Decision 3 in the flow TEXT slots — a `notify` node's `title` and `message`, a `screen` node's `title` and `description`, a refusing `end` node's `message`: they render through the formula template engine, so their placeholders are `{{ }}` holes, a variable path with an optional formatter (the engine's hole grammar now admits a `$`-named variable, so `{{ $error.message }}` is a hole). A single-brace `{…}` token there is refused — by the node contract, `registerFlow` and `objectstack validate` alike — with the hole spelling of each path token, or, for arithmetic, a function, a date macro or a run-user path, the `assignment` that computes it into a variable. No D2 conversion exists: the 17.x interpolator and the engine render a `Date` differently (JSON-quoted against ISO text), and a whole-slot object differently in a screen or `end` text, so the rewrite is the author's to check. Every other flow string keeps the single-brace dialect. Its D3 record is the semantic entry `flow-text-slot-single-brace-refused`. It also makes the deployment's platform-global declaration total (ADR-0131 D7): an object a deployment declares platform-global in its `org-scoping` service's `platformGlobalObjects` gets no organization column on that deployment, because the injected-columns plan reads the declaration, so the organization wall and the driver agree by having nothing to scope. The engine reads it at its plugin start, before the first schema sync, once every plugin init has run, and re-plans the objects registered before it; the security layer's stand-down for such an object retires with it. An absent declaration changes nothing, and a malformed one is refused and declares nothing. Nothing moves automatically: a declaring deployment's existing table keeps the column as an orphan the boot drift report names. The D3 record is the `platform-global-object-organization-column-retired` semantic entry. It also retires the `sys_view_definition` platform object as inert (ADR-0131 D13): no framework code wrote or read its rows, and runtime-authored views are `view` items in `sys_metadata`. The object, its two registrations, its `kernel:ready` active-row index migration and that migration's exports leave, and its name leaves the platform-object registry. Nothing in stack metadata is rewritten; an existing database keeps the table, which no platform path drops. The D3 record is the `sys-view-definition-retired` semantic entry. Then the retry policy closes, and `try_catch` joins the builtin key arm: `RetryPolicySchema`, the one declaration behind `job.retryPolicy` and a `try_catch` node's `retry`, refuses a key it does not declare, naming it with a did-you-mean, where it used to strip it — and with opt-in defaults a stripped `maxRetries` meant no retry at all. No writer relied on the strip. With `retry` closed to the five keys the descriptor declares, a key a `try_catch` node's contract does not declare is refused at parse, at `nodes.N.config.`, with the same `node-config-refused-by-contract` code, and the descriptor walk keeps plugin node types only. A `retryDelayMs` the conversion leaves beside a different `backoffMs` meets its tombstone there, as it met the walk. No key is removed, so there is no new tombstone, and no D2 conversion exists. Its D3 record is the semantic entry `try-catch-and-retry-policy-undeclared-keys-refused`. In those same text slots a `{{ }}` hole may root at a `$`-named variable only when the flow engine binds it (`$record`, `$runId`, `$flowName`, `$flowLabel`, `$error`, and a flat-graph `loop`'s `$loopItems` / `$loopIndex`): a hole such as `{{ $User.Id }}`, which the 17.x contracts accepted as a plain string and which renders nothing under the template engine, is refused by the node contract, `registerFlow` and `objectstack validate` with the remedy its single-brace spelling gets — compute the value with an `assignment` node, then write the variable as a hole. The template engine binds no new variable, and no D2 conversion exists: what the hole was meant to read is not in the flow. Its D3 record is the semantic entry `flow-text-slot-unbound-dollar-root-refused`. +Protocol 18 extends the publish-time refusal of unresolved placeholders, which protocol 17 applied to datasource connection config, to the memory driver's config-material persistence keys: `persistence.path` (file persistence and the `auto` override) and `persistence.key` (localStorage and the `auto` override) refuse `${…}` placeholder syntax at publish. Nothing resolves a placeholder there — the driver would create a literal `./${DATA_DIR}/…` path or write under the literal localStorage key — the same authored-under-a-false-belief shape, one surface over. The memory driver's `initialData` stays deliberately unjudged: it carries arbitrary record values, where a literal `${…}` may be legitimate data. It also retires `MetadataPluginConfig.additionalTypes` (ADR-0049 enforce-or-remove): the key was documented as THE plugin kind-declaration channel and read by nothing — the manager's type registry is seeded once from `DEFAULT_METADATA_TYPE_REGISTRY` and never merged with it, so authoring it configured nothing. A kind enters the live set as a side effect of registering an item of that kind. It also refuses malformed field `scale`/`precision` declarations: both are digit counts, so a non-integer or negative value (`scale: 2.5`, `precision: -1`) has no defined meaning — the write-time `scale` check, which refuses an over-scale value rather than rounding it, deliberately left it unenforced rather than invent floor/round semantics, which made the declaration silently inert. The schema now refuses both at parse (`z.number().int().min(0)`); the mechanical conversion deletes a malformed value from old sources and stored rows (behaviour-preserving), and the semantic entry tells the author to re-declare the count they meant. Finally, it removes the `objects["*"].allowExport` grant from the shipped admin permission sets — `admin_full_access`, `organization_admin` and the derived `organization_admin_no_bypass`. Measured on 17.0.0 GA, that wildcard made the export axis undeniable for an org admin: an application could declare an object exportable by nobody and the platform exported it anyway, with no supported opt-out, because a code-package set cannot be edited (`403 [not_overridable]`) and the admin held no app-authored set in which to write the per-object `false` that would have won. It is the earlier removal of `member_default`'s CRUD wildcard applied to the export axis, which had kept its wildcard by omission rather than by decision. From 18 an admin exports exactly what an app-authored set grants — a posture the same run measured to be already precise. Unlike everything else in this step it changes no schema, so nothing refuses at publish: the upgrade signal is behavioural and belongs here. Finally, it converges `record:chatter` / `record:discussion` `position` on the renderer's vocabulary (maintainer ruling 2026-08-15): the schema declared `sidebar`/`inline`/`drawer` — values no renderer branch ever compared, so the schema's own `sidebar` default silently rendered in flow while the value that actually docks the panel (`right`) was refused at publish. The row now speaks `bottom`/`right`/`left`; the mechanical conversion rewrites the old spellings (`sidebar` → `right`, `inline` → `bottom`, `drawer` → `right`), and the three schema defaults (`position`, `collapsible`, `defaultCollapsed`) are dropped per the `maxVisible` principle — renderer fallbacks stay the renderer's facts. It also retires `targetVariable` on `element:text_input` and `element:record_picker` (ADR-0049 enforce-or-remove): a declarative hint with zero readers in any repo — the live binding runs the other direction, resolved from the page variable whose `source` names the component's `id` (PageVariableSchema) — so an author who wrote only `targetVariable` got an input that wrote nothing, with a success receipt. The mechanical conversion strips the key from old sources (pure lossless delete — it never had an effect to lose); the tombstone's prescription says how to declare the binding that works. Finally, it retires the whole `element:filter` element (ADR-0049 enforce-or-remove at ELEMENT grain — the wider finding that the `targetVariable` retirement recorded and left for its own card): no renderer for the element ever shipped in any repo — objectui registers none, Studio's designer palette lists it as a no-renderer exclusion, and the 2026-06 page-liveness audit recorded it rendering "Unknown component type" — so every one of its six authorable keys was a capability claim nothing kept. All six are retiredKey tombstones; the mechanical conversion strips them from old sources (pure lossless deletes) and leaves the bare node, which the parse then refuses by name — delete the component. List surfaces own their filtering: a view's `userFilters` quick-filter bar / the list toolbar's filter builder. It also retires the whole `element:form` element (ADR-0049 enforce-or-remove at ELEMENT grain — the `element:filter` shape one element over, recorded by that retirement's own verdict sweep): no renderer for the element ever shipped in any repo — objectui registers none, Studio's designer palette lists it as a no-renderer exclusion naming the live replacement, and the 2026-06 page-liveness audit recorded it rendering "Unknown component type" — so every one of its six authorable keys was a capability claim nothing kept. All six are retiredKey tombstones; the mechanical conversion strips them from old sources (pure lossless deletes) and leaves the bare node, which the parse then refuses by name — delete the component. Use the object-bound `object-form` block instead — rendered, designer-publishable, its props declared for the component-props gate, and carrying the same intent (`objectName`, `fields`, `mode`, `submitText`). It also closes the two explicit column lists on relationship fields: `field.inlineColumns` entries are now the strict, name-keyed InlineGridColumnSchema (mirroring the objectui grid renderer's measured reads — objectui aligned the widget to `name` and retired the `field` spelling with no tolerant alias), and `field.relatedListColumns` entries are child field-name strings (the only form the related-list renderer hydrates fully). Both were z.array(z.any()) — a mis-keyed column published clean and rendered as blank cells with the right row count. The mechanical conversion respells inline `{ field }` entries as `{ name }` and folds related-list column objects to their identity string; unknown keys are named rejections at publish from this major. It also retires `measures..filters` on analytics cubes (ADR-0049 enforce-or-remove): a declared per-metric raw-SQL filter with zero consumers — both SQL strategies aggregate the metric's `sql` and never read `filters`, so a hand-authored `filters: [{ sql: "stage = 'closed_won'" }]` parsed, registered, and silently returned the UNFILTERED aggregate under the author's metric name (the same defect the dataset path had, on a hand-authored cube; the dataset half was repaired through its own structured channel when the analytics strategy began compiling each dataset measure's `filter`). The raw-SQL fragment also ran against the platform's structured-FilterCondition direction — it cannot be parameterized, re-targeted per driver dialect, or walked by the lint filter rules. The mechanical conversion strips the key from old sources (pure lossless delete — it never had an effect to lose); filter at query time with `where`, or use an ADR-0021 dataset measure's structured `filter` (a metric's own `sql` is a column reference, see `cube-member-sql-expression-retired`). Finally, it retires the stack `themes` carrier and `ThemeSchema` whole (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-21, disposition B: 退役授权面): the pipeline was live from the authoring gate through artifact ingest and stopped there — zero non-test readers of stored `theme` items, `theme` never a registered metadata type, no first-party app mounting the spec-aware provider, nothing selecting an active theme — so an authored theme shipped through every green gate and changed nothing on screen. `app.branding` stays the one colour surface; objectui's ThemeEngine/ThemeContext and their unit tests are retained. Semantic rather than mechanical: an authored palette has no lossless target (N themes vs M apps is a judgment), so the entry prescribes the hand move instead of deleting authored content silently. It also retires the `record:highlights` highlight-field `icon` (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-21, executing the 2026-08-20 census verdict): a declared key with zero read points in any direction — objectui's renderer normalized the authored object and carried `icon` into a highlight chip with no icon slot, `useRegisterHighlightFields` registers field NAMES only (structurally unable to carry it), and the Studio designer publishes the field list as plain strings — while six author-facing surfaces advertised the key (the shape that got the reference-rail `icon` refused, on the highlight chip). The mechanical conversion strips the key from the object entries of every `record:highlights` `fields[]` (pure lossless delete — the chip renders label and value only, so it never had an effect to lose); there is no replacement, and the live neighbour `readonly`, declared because the chip's read-only gate reads it, is untouched. It also retires the import mapping `lookup` transform's steering params (ADR-0049 enforce-or-remove — the sub-walk half of the 17.0.0 mapping cleanup that retired `extractQuery` / `errorPolicy` / `batchSize`): `fieldMapping[].params.object` / `.fromField` / `.toField` / `.autoCreate` declared a per-entry reference-resolution dialect the import path never implemented — `lookup` copies the cell through and resolution runs off the target field's own metadata — and `autoCreate` read as create-if-missing while an unresolved reference actually fails the row (`import_reference_not_found`), with or without the key. The eleven alias spellings convert to guidance so every spelling lands on the prescription; the mechanical conversion strips the four keys from stored sources (pure lossless deletes — none ever had an effect to lose). Finally, it retires the component-translation copy key `pages..components..submitLabel` and its `submit` alias (ADR-0049; maintainer ruling 2026-08-22): the face is measured, not mirrored — each copy key exists because some component in `ComponentPropsMap` declares it — and `submitLabel`'s only declarer was `element:form`, retired whole above, so the key had no declared component left to translate and the resolver overlay was its only reader. Retire won over re-anchor because the live form surface (`object-form`) speaks `submitText` (`I18nLabelSchema`), localizable at its own authoring site; re-anchoring would have widened the face for one word. The mechanical conversion strips the key from stored bundles and items (pure lossless delete — nothing read it once `element:form` was retired), at the acknowledged cost of dropping the bespoke-component route for that one word. Finally, it retires `page.components[].responsive` and the whole `ResponsiveConfig` layout vocabulary it carried (ADR-0049 D2; maintainer ruling 2026-08-22): the key was the destination the `dashboard.widgets[].responsive` tombstone prescribed as the live alternative, and a two-repo measurement (tsc-probe methodology with positive and negative controls) found the claim false — objectui's two implementations of the contract (`useResponsiveConfig`, `ResponsiveProtocol`) had zero callers and nothing read `.responsive` off a page component, so the prescribed migration moved an inert key to an inert key while the platform's own error message vouched for it. The same change repairs every shipped text that carried that redirect. `ResponsiveConfigSchema`, its two breakpoint maps and the `BreakpointName` enum had no other authorable carrier and leave with the key (RETIRED_DEFS_BY_MAJOR[18]); the live per-breakpoint channel on a page component is `responsiveStyles` (ADR-0065), which objectui really compiles. The mechanical conversion strips the key from stored pages (pure lossless delete — it never had an effect to lose). Finally, it retires nine of the eleven members of the plugin manifest's `contributes` block (ADR-0049 enforce-or-remove; triage graded 2026-08-21, cloud census leg discharged clean 2026-08-24): `events`, `menus`, `themes`, `translations`, `actions`, `drivers`, `fieldTypes`, `functions` and `commands`. A census of all three repos, with controls, measured that the whole monorepo contains exactly one non-test read of `manifest.contributes`, and it reads `kinds`; the other nine members parsed, entered the manifest, and changed nothing, while published docs and the schema's own JSDoc kept teaching them (`commands` documented Commander.js resolution the CLI dropped for oclif; `fieldTypes` advertised a registration seam that never existed). All nine are retiredKey tombstones mirroring `loading`; `kinds` survives (live reader), and `routes` was left to a ruling of its own, which retired it as well (the `plugin-manifest-contributes-routes-retired` entry). D3 semantic, no D2 conversion: a manifest is not a stack collection member, so a conversion would be a transform with no seam that ever runs. On the surviving `kinds` bucket it also retires the `globs` sub-field (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-24): the schema promised that declaring `globs` enables file-type discovery, but discovery globs `filePatterns` off the metadata type registry — which `contributes.kinds` does not extend, as `metadata-plugin.zod.ts` records outright — so an authored `globs` was accepted, stored, served back through `GET /metadata/kind`, and never consulted (zero value reads; the only non-test occurrences were the schema declaration and two type positions). The `kind` bucket itself and its `id` are untouched; file-type discovery stays single-channel on `filePatterns`. D3 semantic `plugin-manifest-kind-globs-retired`, same no-seam reasoning. Finally, it retires `object-grid`'s `defaultSort` (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-25, decision-inbox batch 4 — the producer half of objectui's `table.defaultSort` retirement, which the maintainer's 2026-08-22 「接受所有」 ruling on objectui's sort sink ordered): the legacy second spelling of `sort`, a single `{ field, order }` pair the renderer read only when `sort` was absent (measured at the `.objectui-sha` pin `190fbd01d`, `plugin-grid/src/ObjectGrid.tsx:1244-1246` and `:2847`, which wraps it `[schema.defaultSort]` — the exact array shape `sort` carries). One intent, two spellings; objectui's mirror schema is parity-test-only and parses nothing at runtime, so only the spec strictObject can refuse the key. The mechanical conversion carries the pair over — renamed to `sort` and wrapped in the array shape — when `sort` is absent, and strips it as a pure lossless delete when `sort` is present (the renderer's own precedence made it unread then). Finally, it retires the object-permission lifecycle bits `allowRestore` and `allowPurge` (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-26, decision-inbox batch 5, which chose retiring the two bits over gating operations that do not exist): the `restore` / `purge` ObjectQL operations the bits claimed to gate have never existed — no destructive lifecycle verb is in the engine's dispatch vocabulary, which a test pins — so granting the bits delivered nothing, and an author who declared `allowPurge: false` believed a lock on GDPR hard-deletion existed when the operation itself did not. Both keys are retiredKey tombstones; the evaluator's pre-mapping rows retired in the same batch (a dispatched `restore`/`purge` stays denied fail-closed via the DESTRUCTIVE_OPERATIONS backstop, so there is no ungated window), and the mechanical conversion strips the keys from every object grant in `permissions[].objects` (pure lossless delete — they never had an effect to lose). `allowTransfer` is ENFORCED — the server guards who may rewrite a record's owner — and stays. The keys return with the M2 lifecycle initiative (feature + RBAC in one batch), which stays open as their anchor. Finally, it narrows the per-option `default` key OUT of the form-view options vocabulary (ADR-0049 declared-but-unenforced; maintainer ruling 2026-08-28 on the console form renderer's analysis, disposition 甲): `SelectOptionSchema` serves two surfaces and only the OBJECT-field face reads `default` (enforced there by a maintainer ruling of 2026-08-10 — `applyFieldDefaults` falls back to the option marked `default: true`; that face, its alias rows and its precedence pin are untouched). On a form-view field's option list the key parsed clean and nothing read it — the insert-path fallback consults the object definition's options, never a form view's, and no form renderer seeds a value from it (measured against the console's form controls, none of which reads the key; the ruled census found ZERO authored occurrences across the tree, the example apps and the published *.form.ts corpus). The FormView vocabulary's own option shape (`FormSelectOptionSchema`, ui/view.zod.ts) now refuses the key with the prescription; the mechanical conversion strips it from stored sources (pure lossless delete — it never had an effect on this surface to lose). It also retires the paper metadata-customization protocol whole (ADR-0049 enforce-or-remove, maintainer ruling 2026-08-29): `kernel/metadata-customization.zod.ts` — the three-layer platform/user patch-overlay model with field-level change tracking and a 3-way-merge story — was exported, documented as the customization architecture, and implemented ONLY by an unreachable `packages/metadata` limb (no route served the paper `…/overlay`/`…/effective` endpoints; the four optional service members were called only by their own unit tests). ADR-0126 §6 wall 4 supersedes it on the record ("nothing may build against it"). The module's seven defs and the three section-5 API contracts leave via RETIRED_DEFS_BY_MAJOR; the authorable carriers `MetadataPluginConfig.customizationPolicies` / `.mergeStrategy` and `MetadataManagerConfig.persistence.overlayWritable` are retiredKey tombstones (no D2 conversion — plugin/manager configs are not stack collection members, the additionalTypes reasoning). The customization that actually ships: ADR-0005's org overlay and ADR-0126's packaged-metadata model. Finally, it canonicalizes the legacy objectql field-key dialect `reference_to` → `reference` on lookup/master_detail fields (the server half of the maintainer's 2026-08-31 ruling that the server normalizes the protocol and the renderer only executes it). `FieldSchema` has always refused `reference_to` by name, but stored `sys_metadata` rows written by seams that bypass the parse still carry it, held up today only by objectui's `reference ?? reference_to` fallback arms — which the ruling's objectui half deletes. The mechanical conversion renames the key (the house precedence for a shadowed alias: a canonical `reference` wins, a disagreeing pair is kept for the author), replays on every stored-row rehydration so the serve face only ever emits the canonical spelling, and `os migrate meta` rewrites old sources; the authoring-surface rejection with its rename prescription is unchanged. It also retires `connector.errorMapping` (ADR-0049 enforce-or-remove; triage ruling 2026-09-02): `ErrorMappingConfig` (4 keys) and its `ErrorMappingRule[]` (7 keys) were authorable through `ConnectorSchema` — and, via `DeclarativeConnectorEntrySchema`, through `stack.connectors[]` and the `/meta/connector` door — and read by nothing: no provider, dispatcher or materializer ever mapped an external error through the rules, so `unmappedBehavior` configured nothing and a rule's `userMessage` was never shown to anyone. That spelling is the live API-error channel's (`ApiError.userMessage`), so an author who wrote a rule here reasonably believed they were marking a refusal for an end user; the failure was silent in both directions. The carrier key is a retiredKey tombstone on the non-strict `ConnectorSchema` (a bare deletion would be a silent strip), the three defs — `integration/ErrorMappingConfig`, `integration/ErrorMappingRule` and the orphaned `integration/ConnectorErrorCategory` enum — leave via RETIRED_DEFS_BY_MAJOR, and the mechanical conversion strips the block from `connectors[]` (pure lossless delete; it never had an effect to lose). It also retires the fourteen hour/minute/day-shaped deadline keys of the incident-response, training and change-management families (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-02): six on the incident-response schemas, five on the training schemas and three nested in the change-management schemas, every one on the published surface and read by nothing — the schemas are mounted by no stack key and registered as no metadata type — so a compliance author who wrote `triageDeadlineHours: 4` held a deadline the platform never kept. All fourteen are retiredKey tombstones (the schemas are not strict; a bare deletion would be a silent strip) with no D2 conversion, for the additionalTypes reason: none of these schemas is a stack collection member, so the chain has no seam. It then retires those three compliance-shaped families WHOLE (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-05, ruled A, not roadmapped): the nineteen defs of `system/incident-response.zod.ts`, `system/training.zod.ts` and `system/change-management.zod.ts` — roughly a hundred declared keys, exported from `@objectstack/spec/system`, mounted by no stack key, registered as no metadata type, absent from the liveness ledgers, read by nothing repo-wide (examples, skills and objectui at the pinned sha included) — leave via RETIRED_DEFS_BY_MAJOR with one D3 semantic entry per family; the fourteen deadline-key tombstones leave with their defs' source and their RETIRED_KEYS_BY_MAJOR[18] entries stay as history. Boolean capability claims such as `notifyRegulators`, `requirePostIncidentReview`, `trackCompletion` and `approval.required` were the sharpest declared-≠-enforced shape left: an author writing `notifyRegulators: true` held a compliance promise the platform never kept. And it resolves the branch the deadline-key ruling held open — no roadmapped e-signature consumer — so `ESignatureConfig.expirationDays` / `reminderDays` (`data/document.zod.ts`, defaults 30 / 7 days, read by nothing) are retiredKey tombstones with no D2 conversion (`document` is no stack collection member), registered in RETIRED_KEYS_BY_MAJOR[18] with one D3 semantic entry. Finally, it moves the unit of every duration-shaped `z.number()` key whose unit lived only in its description into the key name (maintainer ruling 2026-09-02, no grandfathered baseline): `hook.timeout` and `job.timeout` become `timeoutMs` (mechanical rename, retired from the load path), and the five keys with no stack seam — `MetadataManagerConfig.cache.ttl` / `cache.databaseLoader.ttl` (seconds and milliseconds fourteen lines apart under one name), `DriverOptions.timeout`, and the tenant `connectionPool.idleTimeout` / `accessControl.sessionTimeout` whose unit the reference pages never published — are retiredKey tombstones with a semantic entry each, naming the suffixed key. The `data`, `ui`, `ai` and `integration` remainder closes the same sweep: `dashboard.refreshInterval` → `refreshIntervalSeconds`, the connector pair `health.circuitBreaker.monitoringWindow` → `monitoringWindowMs` and `triggers[].interval` → `intervalSeconds` (both halves later absorbed by the removal of the block each key lived in — see the connector retirements below), and the two datasource config keys `memory config.persistence.autoSaveInterval` → `autoSaveIntervalMs` (BOTH union arms — the `auto` arm forwards the same value to the same file adapter, so splitting them would have left one value with two spellings) and `turso config.timeout` → `timeoutMs` all convert, because a dashboard, a connector and a datasource are stack collection members stored as rows; the two with no seam — `ConversationAnalytics.duration`, computed at runtime and never authored, and `NoSQLQueryOptions.timeout`, a per-call driver argument — are retiredKey tombstones with a semantic entry each. That remainder is what takes `check:duration-unit-keys` to zero offenders over `packages/spec/src/**`; the gate goes red again by design when its declared population widens beyond that subtree. It also retires the three outer keys of `MetadataManagerConfig.cache` — `enabled`, `ttlSeconds` (the duration rename's respelling of `ttl`, never shipped) and `maxSize` — that the rename above surfaced (ADR-0049 enforce-or-remove): declared, defaulted and published, read by nothing — `MetadataManager` hands only `cache.databaseLoader` to the loader — so `cache: { enabled: false }` switched nothing off. All three are retiredKey tombstones registered in RETIRED_KEYS_BY_MAJOR[18] with one D3 semantic entry and no D2 conversion (a manager config is no stack collection member); the rename is folded into the removal, so `cache.ttl` now prescribes deletion rather than a hop to a retired key. It also retires the seven cron-typed positions nothing evaluated (ADR-0049; the 2026-09-06 ruling retired each family rather than marking it experimental): the two export-schedule crons, `ScheduleState.cronExpression`, `DataSyncConfig.schedule`, `CacheWarmup.schedule` and the two disaster-recovery crons were parsed into the cron envelope and read by nothing (the D7 ledger row `cron-declared-unwired`). All seven are DELETED OUTRIGHT — no retiredKey tombstone, no RETIRED_KEYS_BY_MAJOR[18] entry, no D2 conversion and no D3 semantic entry — so this step replays nothing for them and `migrate meta` lists no edit: the keys simply stop existing. That the chain is silent does NOT make the deletion silent to an author: the PARSE strips (no schema here is `.strict()`), but above it `lintUnknownAuthoringKeys` names the dropped key for the one position a stack manifest reaches — `os validate` and `os build` both print `connectors..syncConfig.schedule: 'schedule' is not a declared connector key, so its value is dropped at load.`, and `os validate --strict` EXITS 1 on that warning. The other six positions are unreachable from a manifest, so for those the parse-level strip is the whole of it. That is the maintainer ruling of 2026-09-10 on the retirement PR, taken over the seat recommendation to keep the connector D2, on the reading that customers do not upgrade major by major in order. It also retires the `type: 'page'` LIST-VIEW mount and its `pageName` binding (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-09 「撤」). The member was added so a view could render nothing of its own and delegate to an already-published page, but only the spec half landed: no renderer ever routed it — objectui's list-view switch shares its default arm with `grid` — so a page view drew an empty table where the page belonged, and the three parse refusals policing the binding policed a mount that never mounted anything. The enum VALUE carries its prescription on the `type` enum's own error map (an enum-value narrowing has no tombstone to hang one on, the `exportOptions` 'pdf' precedent); `pageName` is a retiredKey tombstone on both list-view doors. The D2 conversion STRIPS both keys rather than rewriting `type` to `'grid'`: `type` defaults to `grid` in the schema, so deleting it lands the row on exactly what it already rendered without this registry guessing a view type. The surviving page mount is the app navigation item (`PageNavItem.pageName`), untouched. It also retires `object-kanban`'s `quickAdd` (ADR-0049 enforce-or-remove; the spec half of the director-seat ruling of 2026-09-08 that the board grows no inline record-creation path and retires the key). The board FORWARDED the key into the shared renderer but the affordance is gated on both `quickAdd` and `onQuickAdd`, and `onQuickAdd` is a host-supplied FUNCTION JSON cannot carry and no producer puts on an `object-kanban` node — so the gate was permanently false. The drop was NOT silent, and that is what made it worse than silence: objectui's html tier reported the published key as `unknown-prop`, the same diagnostic a typo gets, so an author following the contract met a tool contradicting it with no way to tell which side was wrong. A retiredKey tombstone on `ObjectKanbanPropsSchema` with one D2 conversion that is a pure lossless DELETE (the key never had an effect to preserve) scoped by component `type`. Delete the key; `object-kanban` offers no quick-add control. It also retires the bare STRING `sort` clause on the list-view doors (ruled 2026-09-07: the legacy string clause is retired, one spelling, the array). This is the PRODUCER half of the seam whose consumer half shipped in objectui first: `convertSortToQueryParams` now refuses a runtime string, so `ListViewSchema.sort` was minting documents its own consumer rejects — a document that validated upstream failed downstream, and the author was told off by the wrong layer. Like the `type` value above it is a VALUE narrowing with no tombstone to hang a prescription on, so the surviving array member's own error map carries it, keyed on `issue.input` being a string. The D2 conversion REWRITES rather than strips, because the clause is losslessly mechanical: `'created_at desc'` is the tuple `{ field, order }`, a bare field name meant ascending and is written out as `order: 'asc'`, and the comma-separated multi-key form becomes one entry per key in the same order. A string that does not parse as that grammar — the `'-field'` dialect above all — is left alone and meets the door instead: that dialect belongs to `RecordRelatedListProps.sort`, never reaches `convertSortToQueryParams`, and retiring it was NOT ruled. It also removes `page.assignedProfiles` (ADR-0090 D2 / ADR-0049 enforce-or-remove; maintainer ruling 2026-09-12 「同意」). The key was authorable on the published `PageSchema` and named for the Profile concept ADR-0090 D2 deleted, while the schema's own alias table CORRECTED an authored `profiles:` into it — two files from `security/permission.zod.ts` answering the same word with "no Profile concept". Measured across this repository and objectui it had zero readers, so a page that "assigned profiles" was open to every caller who could reach it. It is a retiredKey tombstone on `PageSchema` — the def is still parsed from the `page` root, so there is an author to teach — and the two alias entries became refusals naming the permission-set route. The D2 conversion STRIPS the key — there is no lossless target, because which permission set a given profile name corresponds to is a judgement no walker can make, which is what the paired D3 semantic entry is for. Finally, it removes `aria` from the chart config (ADR-0049 enforce-or-remove; maintainer decision of 2026-09-12 — judge the protocol wrong for this one key). It is the last member of the `aria` family retired for the same measured reason as `dashboard.aria` and `dashboard.widgets[].aria` before it: an ARIA block an author can declare and nothing lowers to the DOM. It survived those two sweeps by depth — it sits inside the widget’s `chartConfig` bag, which no drill had reached until the per-key pass recorded in `liveness/dashboard.json`. That pass found `aria` to be the one `ChartConfigSchema` key with no reader on EITHER face: the chart implementation declares no `aria` prop, the presentation lowering names it nowhere, and the react block omits it from ``’s `dataProps`. Remove rather than enforce, because the same chart config already carries a WORKING accessible-name channel in `description` (lowered as `role="img"` + `aria-label`), and giving `aria` a reader would put two accessible-name sources on one element behind a precedence rule nobody has written — one node, one accessibility vocabulary. The tombstone rides `ChartConfigSchema` and therefore copies into `ReportChartSchema`, so the key is registered twice; the D2 conversion STRIPS it from all three authored sites (`dashboards[].widgets[].chartConfig`, `reports[].chart`, `reports[].blocks[].chart`) as a pure lossless delete — it never had an effect to lose. The two alias spellings that pointed at it, `accessibility` and `ariaProps`, became refusals carrying the same prescription rather than renames onto a tombstone. It also states, and enforces, who owns a dataset-bound chart's STRUCTURE (ADR-0021; maintainer ruling 2026-09-12): the dataset decides which series exist and which column each one reads, `chartConfig` carries appearance, and `dashboard.widgets[].chartConfig`'s `type`, `xAxis`, `yAxis` and `series` are refused by name on that carrier — the widget's own `type` is the chart family and `dimensions`/`values` are the selection. An authored `yAxis[].field` was a live membership channel: the renderer synthesised a series from it when the chart declared none, so one authored axis could silently re-point a dataset-bound series at another column and the chart still drew. The D2 conversion strips the four keys from dashboard widgets only — `ReportChartSchema` and the inline-data react `` tier keep their own axes — and the paired semantic entry carries what the stripped keys were saying, because an authored axis field may name a column the widget never selected and no walker can move that intent into the dataset. Finally, it splits the translation bundle type in two (maintainer ruling 2026-09-13: settings copy belongs to the platform): the platform bundle keeps all eleven groups and the per-app bundle (`stack.translations`, `defineTranslationBundle`) no longer declares `settings`, which is keyed by `SettingsManifest.namespace` and only platform code declares a manifest. Both bundles load into ONE served tree, so an app-authored `settings` branch did not sit inert — but nor did it override the platform: the app’s bundles arrive in `AppPlugin`’s `start()` (Phase 2) and the platform’s at `kernel:ready` (Phase 3), and `deepMerge` gives the later source the leaf, so what an application had was a GAP FILLER on a namespace it does not own — rendering only where the platform bundle carried no string for that key and locale. The registered `translation` ITEM follows the file door (maintainer ruling 2026-09-22: one app metadata type, two authoring doors, one accepted shape) and no longer declares `settings` either; there the group had been STRONGER, because the runtime-authored layer is read over the shipped bundles, so a stored item overrode the platform’s own copy. The D2 conversion strips the group from per-app bundle entries and from bare items alike — the runtime translation sync replays it over every stored row before merging — and the paired semantic entry says what the strip means at each door, because a notice reading "(removed)" says neither that an item’s overrides give way to the platform’s string nor that a gap falls back to the manifest's own English literal. Finally it retires object `tenancy.organizationField` (ADR-0049 enforce-or-remove). The key named the column a PLATFORM ROW is stamped from, as opposed to the column the object is WALLED by (`tenantField`); on an ordinary object those are the same column, and the entire protocol declared it exactly once — on `sys_api_key`, a better-auth-managed credential table this platform ships and no application authors. Its three readers were all platform-row writers, scope-pinned by name, so an application declaration was inert by construction while still forcing every future piece of organization logic to ask "what if somebody set this?". The divergence is NOT retired, only its authorability: it moves to `PLATFORM_STAMP_ORGANIZATION_COLUMNS` in `@objectstack/metadata-core`, keyed by object name and read by the stamp face alone, so audit stamping, the approval-row writer and the automation-run recorder keep their behaviour with no authorable input. The conversion is a lossless delete, and a lossless delete still leaves the author a judgment, which the family's D3 entry `object-tenancy-organization-field-retired` carries — an application whose tenant column genuinely is not `organization_id` declares `tenancy.tenantField`, which both walls the object and stamps its platform rows. It also retires `connector.connectionTimeoutMs` (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-22, letter A — the narrower SECOND decision the key was owed after the ruling that made its nine ledger siblings live deliberately left this one dead). Bounded, defaulted, `.describe()`d and served back by `/meta/connector`, so an author had every signal it worked — and no site ever applied it as a deadline. This retirement is NOT the zero-mention shape: five sites outside `packages/spec` read the key (the materialization fingerprint and the provider-context build in the automation service, `ctx.connectionTimeoutMs` in the `rest` and `openapi` provider factories, and the `?? 30000` fallbacks that put it back on the reported def), but every one is a pass-through whose only termini are the def `GET /connectors` echoes and the fingerprint that decides whether to re-materialize. The one mapping from authored policy onto the platform's outbound `fetch` was handed `retryConfig` and `requestTimeoutMs` only, so the key was carried and never honoured — the same parsed-unmarked-unenforced state ADR-0049 forbids, wearing a longer route. Nor was the `实现` arm available: a WHATWG `fetch` exposes one `AbortSignal` over the whole operation and never the connect phase, so bounding time-to-response with it would kill a slow-but-connected upstream the author meant to allow with a large `requestTimeoutMs`. `requestTimeoutMs` is the replacement and the bound the platform can keep. The carrier key is a retiredKey tombstone on the non-strict `ConnectorSchema` (a bare deletion would be a silent strip), registered under both def keys because `DeclarativeConnectorEntrySchema` carries it too, both carriers wrapping the same private `ConnectorBaseSchema`; the D2 conversion strips it from `connectors[]` as a pure lossless delete — it never had an effect to lose — because a stored connector row CAN carry it (the `PUT /meta/connector/:name` door persists the authored value and the stored-row rehydration seam is live for this type, both measured); and the withdrawn `ConnectorProviderContext` member, which is code and has no authored source to rewrite, leaves via the paired semantic entry instead. Finally it gives the one-filter-orthography convergence (ruled 2026-08-25: one filter spelling platform-wide, the rule array) its mechanical half at rest (ruled 2026-09-12): the D2 conversion `page-component-filter-record-to-rule-array` rewrites a record-form or single-level AST `filter` at the converged rule-array doors — `dataSource.filter`, the `object-*` / `element:number` / `element:record_picker` `filter` props and `object-grid.defaultFilters` — to the rule array wherever the mapping is lossless, and leaves a filter carrying `$and` / `$or` / `$not` (or any part with no lossless rule spelling) exactly as stored, because flattening a combinator changes which rows a page selects. It is retired from the load path, so authors are still refused at the door and taught the array; the stored-row seams and this chain replay it. It also retires the view item's `owner` and `hidden` (ADR-0049 enforce-or-remove). Both sat on the view-item identity layer, were accepted by the strict authoring door and by the wire member the `view` write door validates, and were stored verbatim — and nothing read either: both switcher read paths filter on `viewKind` + `object` and sort on `order`, so `hidden: true` hid nothing, and no per-user scope ever read `owner`, so a view marked as one user's was listed for everyone who can read the object. Per-user view scoping is a parked direction (ADR-0017, amended 2026-09-04), not a shipped mechanism. Both keys are `retiredKey()` tombstones on the SHARED shape, because that shape also feeds the `.strip()` wire member, where a bare deletion would be a silent strip. The D2 conversion `view-item-owner-hidden-removed` strips them from the view item RECORD spelling only, as a lossless delete, in both collections a record travels in — `views` (stack sources and stored rows) and the assembled-manifest `viewItems` channel (package export, environment artifacts), whose registration parse would otherwise refuse an artifact assembled before this release. It also retires a `joined` report's `chart` at both coordinates (ADR-0049 enforce-or-remove): the joined renderer draws each block as a table and returns before the one container `chart` read, and no renderer reads a block's `chart` at all, so a chart on a joined report parsed, passed the chart-bindings lint, and plotted nothing. The key leaves `JoinedReportBlockSchema`'s closed shape (its `guidance` table carries the prescription) and the joined arm of `ReportSchema`'s refinement refuses a container `chart`; `chart` stays live on every non-joined report. The D2 conversion `report-joined-chart-removed` strips both as a pure lossless delete — neither ever had an effect to lose — because a stored report row CAN carry them (the Studio report form offered a block `chart` input until this change); it is retired from the load path, so authors are refused at parse rather than rewritten. It retires the view item's `owner` / `hidden` pair on the flattened overlay door too (ADR-0049; the view item's disposition for the same key pair, followed here as triage directed): the lean personalization PUT with no `config` declared its own `owner` / `hidden`, accepted and stored them, and nothing read either. Both are `retiredKey()` tombstones on the two overlay members with the view item's own prescription texts, and the D2 conversion `view-overlay-owner-hidden-removed` strips them from the flattened spelling (no `config`, no container slot) in `views` and `viewItems`, so a stored overlay row is served without them. A row that held other view keys is then valid again and re-saves; a row that held nothing but its identity and the two keys is left identity-only, which the door refuses, so it is badged invalid, refused on a whole-row re-save and reported `failed` by `os migrate meta --stored --apply` until it is deleted or given the setting its author meant. Its D3 record is the semantic entry `view-overlay-owner-hidden-retired`. It also narrows form `layout` to `vertical` | `horizontal` on both surfaces that declared the four-arm enum — the `object-form` page component and the form view (ADR-0049 enforce-or-remove). No renderer ever gave `inline` or `grid` a behaviour of its own: every form presentation folded both to `vertical`, multi-column is `columns` (honoured under either layout), and `inline` is a toolbar / filter-row pattern rather than a record-form layout — redundant vocabulary under the maintainer's family criterion (a capability mainstream platforms have is served once, here by `columns`), retired with no alias window. Both enums refuse the two values with a per-value prescription naming `columns`; the D2 conversion `form-layout-inline-grid-to-vertical` rewrites them to `vertical` (behaviour-preserving, `columns` untouched) on `object-form` page components, on every form payload a view carries, and on the assembled-manifest `viewItems` channel. It also removes `currencyConfig.precision` (ADR-0049 enforce-or-remove): declared and validated against ISO 4217, read by no renderer or runtime — a currency amount's decimal places are its currency's ISO 4217 minor unit, derived from the currency itself. The D2 conversion `currency-config-precision-removed` strips it from every field's `currencyConfig` as a pure lossless delete, which matters most at rest: the schema used to bake `precision: 2` into parse output, so stored object rows and built artifacts carry it without anyone having written it. Retired from the load path; an authored key is refused with the prescription. It also retires the RLS policy's `tags` (ADR-0049 enforce-or-remove; graded RETIRE by the maintainer's criterion — no mainstream platform tags a row-level policy): the key promised categorization and reporting for governance and compliance, and nothing ever read it — the RLS compiler never consulted it and no preview rendered it. It is a `retiredKey()` tombstone on `RowLevelSecurityPolicySchema` (the `priority` posture one key over), and the D2 conversion `permission-rls-tags-removed` strips it from every policy in `permissions[].rowLevelSecurity` as a lossless delete, so a stored permission row that still carries it replays clean. It is retired from the load path, so authors are refused at parse rather than rewritten. Its D3 record is the semantic entry `permission-rls-tags-retired`. Finally, it removes `aria` from the action (ADR-0049 enforce-or-remove), the fourth member of the `aria` family after `dashboard.aria`, `dashboard.widgets[].aria` and the chart config's, and retired for the same measured reason: an ARIA block an author can declare and nothing lowers to the DOM. The liveness ledger had graded it `live` on an uncited "partial" note with no reader behind it; at the pinned renderer, none of the surfaces that render an action — button, icon, menu, group and bar, the row and bulk action menus, the record quick-actions toolbar — reads it. Remove rather than enforce, because every one of them already takes the accessible name from the action's required `label` (visible text, or `aria-label` on an icon-only action), and the node that places the actions carries the node-level `aria` block — a per-action block would be a second spelling of both. The D2 conversion `action-aria-removed` STRIPS the key from stack actions and object-nested actions as a pure lossless delete, retired from the load path so authors are refused at parse; its D3 record is the semantic entry `action-aria-retired`. It also retires the connector resilience family (ADR-0049 enforce-or-remove, one batch): `connector.health` — the `healthCheck` probe (eight keys) and the `circuitBreaker` (six) — `connector.status` and the connector-nested `webhooks`, sixteen authorable keys with no reader outside the spec package. No loop ever polled a connector endpoint or tripped a breaker; nothing read an authored `status` (the runtime publishes a computed `state`, and participation is `enabled`); and a webhook nested in a connector was never registered as a `webhook` item, so it was never materialized or delivered — the top-level `webhooks:` collection is the delivered one. The three carrier keys are retiredKey tombstones on `ConnectorBaseSchema`, registered under both carrier defs; `status`, defaulted `'inactive'`, joins `connectionTimeoutMs` in the retired-default residue stage, because every 17.x parse emitted it into every connector. Seven defs leave whole — `ConnectorHealth`, `HealthCheckConfig`, `CircuitBreakerConfig`, `ConnectorStatus`, `WebhookConfig`, `WebhookEvent`, `WebhookSignatureAlgorithm` — and the D2 conversion `connector-resilience-keys-removed` strips the three keys from `connectors[]` and stored rows as a pure lossless delete (the nested webhooks are stripped, never moved: moving them would start deliveries that never happened). It ABSORBS the breaker half of the duration rename above: `health.circuitBreaker.monitoringWindow` → `monitoringWindowMs` is no longer converted, because the whole block it lived in is now removed. Finally it makes edge-branched `decision` nodes EXCLUSIVE (maintainer ruling 2026-09-23, 「跟主流对齐」): the first conditioned out-edge that holds, in declaration order, is the branch, and taking every true branch is the declared `mode: 'inclusive'`. The D2 conversion `flow-decision-mode-inclusive-explicit` writes that key onto every decision with two or more conditioned out-edges and no `conditions` list, so a flow written while every true branch ran keeps its behaviour; it is a default flip, so it is retired from the load path AND refused by the flow rehydration seam and the artifact-ingestion door, and replays only here — the paired semantic entry carries the judgment the diff then asks for. BREAKING for flows stored in `sys_metadata`, by maintainer ruling: such a decision with no `mode` takes the first-match meaning on upgrade and nothing rewrites it; `os migrate meta --stored` lists each one for review, and `mode: 'inclusive'` is the one-line fix where a node meant every branch. It also retires the list view's own `tabs` (ADR-0049 enforce-or-remove). The key parsed and was stored at every list-view door and drew nothing: a list view's own `tabs` has no reader, the one component that would draw it has no production mount, and the tab strip above an object's records is the saved-view switcher, which renders one tab per `listViews` entry and reads no `tabs` key (`userFilters.tabs`, a different key of the same element type, is read and rendered, and stays). The key is a `retiredKey()` tombstone on the list-view shape (its prescription says how to move each tab to a named `listViews` entry); `ViewTabSchema` itself stays, because the page-only `userFilters.tabs` preset bar reuses it and renders. The D2 conversion `view-list-tabs-removed` strips the key from every list payload in `stack.views[]` as a lossless delete, and is retired from the load path, so authors are refused at parse rather than rewritten. It retires the inner `name` on cube members — `measures..name` and `dimensions..name` (ADR-0049 enforce-or-remove) — by the mainstream criterion: Cube.dev and LookML key a member by its declared name, with no second inner name that can disagree. Both member bags are records, and every consumer already resolved a member by its record KEY, publishing and querying it as `.`; the REQUIRED inner copy was read by nothing, and one that disagreed with its key was silently ignored. The keys are retiredKey tombstones on `MetricSchema` and `DimensionSchema`, and because the key was required, every stored or built cube carries it: the D2 conversion `cube-member-inner-name-removed` strips it from every member of every cube, retired from the load path, and its notice prints a disagreeing value beside the key that stays. Its D3 record is the semantic entry `cube-member-inner-name-retired`, which asks the author of a disagreeing name which spelling they meant. It also retires the connector `triggers` array (ADR-0049 enforce-or-remove; ADR-0041 keeps connector-event triggers in its third tier, as their own trigger package): the `ConnectorTrigger` shape — `key`, `label`, `description`, `type` (`polling` / `webhook`) and `intervalSeconds` — was read by nothing. The automation engine registered a connector's actions only, its trigger registry holds FLOW trigger kinds that no connector trigger ever entered, no polling loop read an interval and no receiver was driven by a `webhook` trigger, so a declared trigger never started a flow. `triggers` is a retiredKey tombstone on `ConnectorBaseSchema`, registered under both carrier defs; the provider-bound refusal of the key, whose reason (the provider derives triggers) was untrue, is gone with it, since the tombstone refuses every value on every carrier. `ConnectorTrigger` leaves whole, and the D2 conversion `connector-triggers-removed` strips the array from `connectors[]` and stored rows as a pure lossless delete — never turning a trigger into a flow, which is the author's decision (an `api` flow for an external event, a `schedule` flow for a scheduled pull, each calling the connector's action). It ABSORBS the trigger half of the connector duration rename (its breaker half went with `health` above), so `connector-health-and-trigger-durations-unit-in-key`, with neither half left, is no longer in this step. It also retires a cube's `refreshKey` whole — the refresh cadence `every` and the data-change probe `sql` (ADR-0049 enforce-or-remove). Nothing read either key, and no analytics result is cached, so a declared cadence refreshed nothing and every query was computed when it was asked, as it still is. The key is a retiredKey tombstone on `CubeSchema`, and the D2 conversion `cube-refresh-key-removed` strips the whole block from every cube as a pure lossless delete, retired from the load path. Its D3 record is the semantic entry `cube-refresh-key-retired`. A refresh cadence is declared again when a result cache exists. It also narrows the `time` stored form to the zone-less wall clock the record validator already enforces (ADR-0053 D-C1), so a field default or an action param default or value with a `Z` or a UTC offset is refused when it is authored or submitted rather than on every insert that falls back to it. The D2 conversion `time-default-utc-suffix-dropped` drops a `Z` or a zero offset, which names the same wall clock, and leaves a non-zero offset as stored for its author to rewrite; its D3 record is the semantic entry `time-default-zone-refused`. It also retires the page header's `breadcrumb` switch (ADR-0049 enforce-or-remove): no renderer ever drew a trail for it — objectui drew an empty slot that nothing filled — and the navigation trail is drawn once, by the app shell's header. The key is a retiredKey tombstone on `PageHeaderProps`, beside the `icon` that row lost at 17, and the D2 conversion `page-header-breadcrumb-removed` strips it from every `page:header`, `true` and `false` alike, retired from the load path. Its D3 record is the semantic entry `page-header-breadcrumb-retired`. The `nav:breadcrumb` component type is not part of it: the Studio page palette still offers it. It also retires connector-attached sync from the connector (ADR-0049, the ENFORCE route by ruling): `connector.syncConfig` — `strategy`, `direction`, `realtimeSync`, `timestampField`, `conflictResolution`, `batchSize`, `deleteMode`, `filters` — and `connector.fieldMappings` — `source`, `target`, `defaultValue`, `dataType`, `required`, `syncMode` — fourteen keys no engine ever executed, whose `latest_wins` and `soft_delete` defaults read as configured policy and did nothing. The capability is mainstream, so the definition moves rather than lapses: every mainstream platform binds a sync to its TARGET, so a `mapping` gains `connectorSource`, the `rest` / `openapi` connector it pulls from, the read action and an optional timestamp `watermark`, and a `job` sets the cadence (no schedule key returns to the connector). That binding is declared in this step and executed in a later one. Both connector keys are retiredKey tombstones on `ConnectorBaseSchema`, registered under both carrier defs; `DataSyncConfig`, `SyncStrategy`, `ConnectorConflictResolution` and `ConnectorFieldMapping` leave whole; and the D2 conversion `connector-sync-keys-removed` strips both keys from `connectors[]` and stored rows as a pure lossless delete — never writing a `mapping`, which would start writes that never happened. It also narrows an analytics cube member's `sql` — `measures..sql` and `dimensions..sql` — to a column reference: a field of the cube's object, a relationship path ending in one, or `'*'` (maintainer ruling D, ADR-0021 "zero raw SQL / zero raw expressions" carried from the dataset layer to the cube members it compiles to; ADR-0049 enforce-or-remove). A SQL expression there names no single field, so no platform check could judge which fields it reads, and the two analytics strategies never agreed on it: the raw-SQL path ran it verbatim, the ObjectQL path refused it. It is now refused at parse with a prescription naming the ADR-0021 dataset form — a measure with its own structured `filter` for a conditional count or sum, and `derived: { op, of: [...] }` over named measures for a ratio, sum, difference or product. No D2 conversion: an expression has no mechanical rewrite into a dataset, so the semantic entry `cube-member-sql-expression-retired` carries the move, including the scale change a ratio makes (a `derived` ratio is a 0–1 fraction). It also closes the form view's inline grid columns: `subforms[].columns`, on `view.form` and on `formViews` entries, was `z.array(z.any())` while a relationship field's `inlineColumns` was already the strict `InlineGridColumnSchema`, so a mis-keyed column published clean and drew a blank grid column, and `scale` on a currency column, which the other carrier refuses under the maintainer's rulings of 2026-09-23 (option B) and 2026-09-24 (option 乙), published green. The carrier now references that schema, so both carriers are judged by it, with its own prescriptions. The D2 conversion `form-view-subform-columns-canonicalized` respells a `{ field }` column as `{ name }`, the respelling `field-column-lists-canonicalized` makes on `inlineColumns`: it rewrites stored rows and assembled artifacts and lists the edit under `os migrate meta`, and it is retired from the load path, so an author writing `field` meets the refusal. A view saved with a failing column is refused with the column schema's prescription, and a stored row carrying one is diagnosed at rehydration; neither is stripped, because which column an unknown key or a mixed `field`/`name` entry meant is the author's call, and a conversion that dropped the key would accept at load what the parse now refuses. Its D3 record is the semantic entry `form-view-subform-columns-closed`. On both carriers, the reach of `inline-grid-column-currency-scale-refused` extends to a column that declares no `type`: such a column takes its type from the child field, which the column schema cannot see, when the console hydrates it, so `defineStack`'s cross-reference check re-parses a column whose `name` is a `currency` field of the child object as the type it renders as, and the refusal of its `scale` is the column schema's own. Reach: the child object must be declared in the same stack; a column naming no field of it, or a subform whose child object comes from another package, is not judged there. It has no D2 conversion, for the declared-type entry's reason: deleting the key is the migration, and a conversion that dropped it would accept it at load, the grace window ruling B refused. Its D3 record is the semantic entry `inline-grid-column-identity-only-currency-scale-refused`. It also gives the executor target of an action one spelling on the page blocks that run one. `ActionSchema` has always refused `endpoint` with the rename to `target`, while the `action:button` and `action:icon` component rows declared `endpoint` as a key of their own, and the console's `api` handler reads `target` only — so an `api` button authored with `endpoint` was accepted by the props gate and called nothing. The rows now refuse it with the same rename, read from the one alias table both share. The D2 conversion `action-block-endpoint-to-target` renames the key on an `api` action, where the rename is lossless, retired from the load path so authors are refused at the door while stored rows and `os migrate meta` replay it; an `endpoint` on a block with no `actionType` or another one is left as stored and reported as a TODO. Its D3 record is the semantic entry `action-block-endpoint-spelling-retired`. Finally, it retires the form field's `publicPicker` block (ADR-0087 D2, immediate — the maintainer's ruling E, which reverses the earlier ruling that had declared it): an anonymous public form no longer offers record search. The block opted a lookup, `master_detail` or `user` field on a public form into a picker served by an unauthenticated route; that route is deleted, and the public-form resolve route now leaves those three field types off the anonymous rendering unconditionally. The schema refuses the key with the prescription; the mechanical conversion `form-field-public-picker-removed` strips it from old sources and stored rows (lossless in effect — its only reader was the deleted route), and the semantic entry asks the author how a visitor should now choose: a `select` field with static `options`, or a form behind sign-in. It also closes the third carrier of the inline grid column: an `object-master-detail-form` page block's `details` was `z.array(z.unknown())`, so a key its renderer does not read and `scale` on a currency column, which the other two carriers refuse under the maintainer's rulings of 2026-09-23 (option B) and 2026-09-24 (option 乙), went through `objectstack validate` green. Each detail entry is now a strict shape of the twelve keys the renderer reads, and its `columns` references `InlineGridColumnSchema`. Page-component `properties` is read by the component-props gate, which reports a failing entry or column as an advisory finding, and is not parsed on the metadata save or load path, so a stored page still saves and loads and no conversion is registered; the authored census found nothing to respell. `defineStack`'s identity-only check reaches the block wherever a page carries it, with the reach `inline-grid-column-identity-only-currency-scale-refused` records for the other two carriers. Its D3 record is the semantic entry `ui-object-master-detail-form-details-closed`. It closes the fourth carrier the same way: `record:line_items` had no `ComponentPropsMap` row — it was the one entry on the string-arm registration ledger — so the component-props gate skipped its props, and the showcase project page's five `field`-keyed columns published green over a grid of empty cells. The row declares the fifteen keys the renderer reads, requires `relationshipField` and at least one column, and its `columns` references `InlineGridColumnSchema`; the showcase columns are respelled `name` in the same change. The panel draws its columns as authored, with no hydration from the child object's field, so `defineStack`'s identity-only check does not reach it. Its D3 record is the semantic entry `ui-record-line-items-props-closed`. It also holds an ADR-0021 dataset's `field` — `dimensions[].field` and `measures[].field` — to the accept set the cube members it compiles to already hold, from one shared declaration: a field of the dataset's object, a relationship path ending in one, and on a measure also `'*'` (ADR-0021 "zero raw SQL / zero raw expressions"; ADR-0049 enforce-or-remove). The slot was a bare string that parsed any expression, while the analytics dataset door already refused one on every query, so an expression could be saved and never answered. It is now refused at parse with a prescription naming the ADR-0021 form — a measure with its own structured `filter`, or `derived: { op, of: [...] }` over named measures — and so are an empty string (a count omits `field` instead) and `'*'` on a dimension, which names no axis. The one lossless repair is D2: `dataset-count-measure-empty-field-removed` drops a `count` measure's empty `field`, which still counts rows. An expression has no mechanical rewrite into a column, so the semantic entry `dataset-member-field-expression-refused` carries the rest. It also closes the export options of an `object-grid` page block. `exportOptions` was `z.unknown()`, so a bare format array — the list view's legacy spelling, which the list view lifts to `{ formats }` — was accepted on the grid, whose renderer reads `exportOptions.formats` and lifts nothing: the export menu offered its csv/json default and the author's list was dropped. The row now takes the list view's five-member export options object by identity, not the list view's union, and refuses a bare array with the object form named, a format outside the enum and an undeclared key. Page-component `properties` is read by the component-props gate, which reports these as advisory findings, and is not parsed on the metadata save or load path, so a stored page still saves and loads and no conversion is registered: the bare array never worked here, and lifting it would change the menu a deployed grid shows. The authored census found nothing to respell. Its D3 record is the semantic entry `ui-object-grid-export-options-closed`. It also makes an agent's structured output JSON-only (ADR-0049 enforce-or-remove). The cloud AI runtime, which executes agents, enforces `structuredOutput` on every final answer and refused four of its members before an agent's first turn: the `regex`, `grammar` and `xml` formats — no key ever carried a pattern or grammar to check against, and an answer is checked only as JSON — and the `coerce_types` step, for which no coercion engine exists. All four are refused at parse with a prescription, and the D2 conversion `agent-structured-output-refused-members-removed` deletes a block whose `format` was retired, deletes a retired `fallbackFormat` and drops `coerce_types` from the pipeline, retired from the load path. It also retires the metric sub-caption at both ends (maintainer ruling 2026-10-01, which reverses the 2026-08-06 ruling that gave it a translation key of its own; ADR-0049). The widget translation key `dashboards..widgets..subCaption` overlaid a widget's `options.description`, a key the dashboard schema never declared and no authored widget wrote, so the overlay in `translateDashboard` was its only writer. The overlay is removed, `subCaption` is a `retiredKey()` tombstone on the widget translation node, and its former `subtitle` alias now carries the retirement instead of a rename onto a key that accepts nothing. A widget keeps one authored description, `widget.description`, which renders as the card-header subtitle and is translated by the widget's `description` key. The D2 conversion `translation-widget-sub-caption-removed` strips the key from bundle entries and stored translation items as a lossless delete of what is served, retired from the load path so authors are refused at parse; its D3 record is the semantic entry `translation-widget-sub-caption-retired`. It also makes an agent's memory contract state exactly what the runtime honours (ADR-0049 enforce-or-remove). The cloud AI runtime, which executes agents, recalls the newest `maxEntries` long-term notes before the first round, writes one every `reflectionInterval` delivered interactions, and keeps them in its own database store; before an agent's first turn it refused the `vector` store (the old default) and `redis`, an enabled `longTerm` missing either number, and a `reflectionInterval` without one. So `longTerm.store` is retired as a whole key — the memory store is platform infrastructure, not agent metadata — and the D2 conversion `agent-memory-long-term-store-removed` deletes it, losslessly, retired from the load path; and with long-term memory enabled both numbers are required at authoring, with no default declared, so an upgrading author chooses them. It also retires an agent's conversation state machine, `agent.lifecycle` (ADR-0049 enforce-or-remove). It was parsed and never read: no runtime moved an agent through a declared state or refused an undeclared transition, and enforcing it would have meant a statechart interpreter beside Flow, the two-engine shape ADR-0020 rejected. What it reached for is served elsewhere — a conversation phase is a skill selected by its `triggerConditions`, a multi-step process is a Flow, a record's status transitions are the `state_machine` validation rule — so authoring refuses the key with that prescription, and the D2 conversion `agent-lifecycle-removed` deletes it, losslessly, retired from the load path. The XState `StateMachineSchema` family, kept by ADR-0020 only for this door, left the package with it. It also retires a cube measure's custom-SQL-expression types — `number`, `string` and `boolean` from `AggregationMetricType`, so from `measures..type` (ADR-0049 enforce-or-remove). They marked a measure whose `sql` was the whole computation, and with that `sql` now a column reference they had nothing left to compute: the raw-SQL path returned the column unaggregated and the ObjectQL path refused the measure. Each is refused at parse with a prescription naming the six aggregates. No D2 conversion: the column alone does not say which aggregate the author meant, so the semantic entry `cube-metric-expression-types-retired` carries the choice, and a stored cube that still carries one is refused rather than rewritten. It also retires `object-grid`'s `resizableColumns` (ADR-0049 enforce-or-remove; objectui's ruling that `resizable` is canonical, under the startup rule of immediate retirement): the legacy second spelling of `resizable`, read only as `schema.resizable ?? schema.resizableColumns` (measured at the `.objectui-sha` pin `89cad75d55`, `plugin-grid/src/ObjectGrid.tsx:5361`). One switch, two spellings, and zero writers in either repository, so there is no window. A retiredKey tombstone on `ObjectGridPropsSchema` with one D2 conversion that follows the renderer's precedence: the value moves to `resizable` when that is absent, and strips as a lossless delete when it is present (it was never read then). Its D3 record is the semantic entry `object-grid-resizable-columns-retired`. It also types seven members of an `object-grid` page block: `rowHeight`, `rowColor`, `navigation`, `conditionalFormatting`, `bulkActionDefs`, `aggregations` and `operations` were `z.unknown()` (an array of it for `bulkActionDefs`), although the grid reads each with one shape, so `rowHeight: 42` passed every door and rendered as `compact`. The five a list view also declares take the list view's own schemas by reference; `aggregations` takes the measured `[{ field, type }]` with the query AST's aggregation functions, and `operations` the four booleans a grid read point names (`create`, `update`, `delete`, `export`), refusing `read` and `import`, which nothing reads. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-grid-row-members-typed`. It also types `navigation` on the `object-map`, `object-gantt` and `object-tree` page blocks (the first stage of the `ComponentPropsMap` `z.unknown()` close-out): each renderer hands it to the shared navigation hook, which reads `navigation.mode` and falls back to `page`, so `navigation: 42` and a bare mode string passed every door and opened the record page. The three rows now take the list view's `NavigationConfigSchema` by reference, the carrier the grid, kanban, calendar and timeline blocks already take. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-map-gantt-tree-navigation-typed`. It also retires the `ai:chat_window` page element (ADR-0049 enforce-or-remove), the `user:profile` shape one namespace over: no renderer for it ever shipped, and none is wanted — the console leaves it unregistered on purpose, because the floating chat overlay it mounts on every page is the supported AI chat entry point — so a page that placed one validated clean and drew "Unknown component type", and its four props configured nothing. The name leaves `PageComponentType` and is refused by name at the node, its `ComponentPropsMap` row stays as a whole-bag refusal carrying the same prescription, and the props def `AIChatWindowProps` is unpublished. No conversion is registered: the only edit is deleting the node, a layout decision that is the author's. Its D3 record is the semantic entry `ui-ai-chat-window-retired`; `ai:suggestion` is unchanged. It also narrows page `requires` to the kinds whose source is compiled at save (ADR-0080 §5; maintainer ruling 2026-10-03, letter A): the plugin-namespace list is derived from an html page's source when the page is saved, while on `react`, `full` and `slotted` pages nothing derived it, the Studio page editor dropped it on every save, and a load-time warning was its one reader. `PageSchema` now accepts the key only when `kind` is `html` or its deprecated alias `jsx`, and refuses it at `requires` on every other kind, a page that omits `kind` included, naming the key, the page's kind and the compiled kinds. The key stays live on html pages, so there is no tombstone. The D2 conversion `page-requires-non-compiled-kind-removed` deletes the key from those pages, retired from the load path, so stored rows and artifacts replay clean while authored sources are refused until edited; the delete is lossless. Its D3 record is the semantic entry `page-requires-non-compiled-kind-refused`. It also types eight list members of the `object-grid`, `object-kanban` and `object-calendar` page blocks (the second stage of the `ComponentPropsMap` `z.unknown()` close-out): the grid's `fields`, `selection`, `selectable`, `rowActions`, `bulkActions` and `batchActions`, the kanban's `columns` and the calendar's `calendar` were `z.unknown()` (an array of it for the lists), although each renderer reads them with one shape, so a `{ name }` entry in `bulkActions` passed every door and was skipped. The members a list view declares take the list view's own by reference (`batchActions`, the spelling the grid reads first, takes `bulkActions`'s); the grid's `fields` and `selectable` and the kanban lane take the measured shape. The grid's `columns` stays open: its group headers draw an authored column's `options`, which the list view's column entry does not declare. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-grid-kanban-calendar-list-members-typed`. It also refuses, at parse, a hook whose `body` targets a table of stored metadata, `sys_metadata` or `sys_metadata_history` (maintainer ruling 2026-10-03, letter A: an app-authored body may not touch those tables, whose only writer for a body is the metadata protocol). The runtime already refused such a hook where a body becomes a handler, so it never ran, while the metadata save door answered 200 for it. `HookSchema` now refuses the same set at `object`, or at the list member, with the runtime's prescription to change metadata through the metadata API, judged by the one predicate the runtime uses: a hook with a `body` in any form whose target names either table. A code `handler` and the wildcard `'*'` stay outside it, as they are at registration. No key is removed, so there is no tombstone, and no D2 conversion exists: a refused hook carries no intent a rewrite could keep. Its D3 record is the semantic entry `hook-body-stored-metadata-target-refused`. It also types four members of the `object-form` page block (the third stage of the `ComponentPropsMap` `z.unknown()` close-out): `contentLayout`, `submitBehavior`, `navigateOnSuccess` and `mobile` were `z.unknown()`, although the form reads each with one shape, so a `submitBehavior` `kind` the form does not know passed every door and fell through to the thank-you panel. `submitBehavior` takes the form view's own block by reference; the other three take the measured shape. The form's `fields` and `sections` and the master-detail form's two stay open — the form draws a `{ name }` field entry and an inline runtime field inside a section, which the typed shapes would refuse — and `customFields` stays open until the spec declares the runtime form field its entries are. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-form-members-typed`. It also completes the `element:text` `variant` convergence (the second release of the ruled two-release split): the enum is the nine values `ui:text` publishes — `h1`-`h6`, `body`, `caption`, `overline` — and the pre-convergence spellings `heading` and `subheading`, which every release since the nine were added still accepted, are refused by name with a prescription naming the level to write. The D2 conversion `element-text-variant-heading-levels` rewrites `heading` to `h2` and `subheading` to `h3` on every `element:text` page component — the heading element each one always rendered, so the outline is unchanged and the heading takes that level's style. The `body` default for an absent `variant` is unchanged. It also types two members of the `object-metric` page block (the fourth stage of the `ComponentPropsMap` `z.unknown()` close-out): `aggregate` and `trend` were `z.unknown()`, although the tile reads each with one shape, so `aggregate: 'count'` and a trend with no `value` passed every door, and the tile asked the server for a measure it does not have, or painted a lone `%`. `aggregate` takes the query AST's aggregation functions and the chart aggregate's `groupBy` union by reference, with `groupBy` optional because a metric is one number; `trend` takes the badge's measured shape. `drillDown` and `compareTo` stay open: each by-reference candidate declares a key the tile never reads (the chart drill-down's `filter`, the dashboard comparison's `dimension`), and the chart drill-down refuses the `report` the tile draws, so each waits on a ruling. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-metric-aggregate-trend-typed`. It also retires an `object-master-detail-form` detail entry's `sortField` (ADR-0049 enforce-or-remove; the spec half of objectui's own retirement of the override). The console stopped reading the authored override: the field its line grid stamps with each line's position on drag-reorder is derived from the child object — its first field named `position`, `sort_order`, `sequence`, `line_no`, `line_number` or `sort` — and the pinned console had crossed that change while the spec still declared the key, so an authored value published green and was dropped. A retiredKey tombstone on the strict detail entry with one D2 conversion that is a pure lossless DELETE scoped by component `type` and by position (`properties.details[]`); its D3 entry `object-master-detail-form-detail-sort-field-retired` carries the one judgment left, whether the child object declares the field the line order is kept in. It also types the `object-metric` page block's `compareTo` (the fifth stage of the `ComponentPropsMap` `z.unknown()` close-out) to the tile's read, per the ruling between the reference and the read: `{ kind }`, with `kind` the dashboard widget comparison's own vocabulary by reference, and `dimension` refused by name, because this inline tile shifts the date macros in its own `filter` and never reads a dataset time dimension. A bare kind string, a kind outside the two and a `dimension` passed every door and compared the wrong window. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-metric-compare-to-typed`. It also types the `object-metric` page block's `drillDown` to the tile's read (the same stage and ruling): its five list members — `enabled`, `title`, `target`, `columns`, `maxRows` — are the chart drill-down's own by reference, and `filter` and `mode` are refused by name, because a metric tile has no click event for a drill filter to resolve against and no row for `mode` to open; both passed every door and were ignored. The drill `report` stays open: the tile draws a dataset-bound report, but the spec declares no drill report yet, and declares that contract first. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-metric-drill-down-typed`. It also types the `object-grid` page block's `columns` (the fifth stage of the `ComponentPropsMap` `z.unknown()` close-out), the list member the second stage held: the grid's group headers drew a column's `options`, which the list view's column entry does not declare, and objectui has since retired that read and takes the labels from the object field only. So the member takes the list view's own `columns` by reference — all field names or all column entries — and a column keyed `accessorKey` / `header` / `name`, a mixed list or an undeclared column key (`editable`, `options`, `reference`), which passed every door and drew no column or was ignored, is refused. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-grid-columns-typed`. It also refuses, at parse, a flow `create_record`, `update_record` or `delete_record` node whose `objectName` is the string `sys_metadata` or `sys_metadata_history` (the maintainer ruling of 2026-10-03, letter A, applied to flows: app-authored work may not write those tables, whose only writer is the metadata protocol). The runtime already refused such a node before any write, at its first run, while every authoring door accepted the flow. `FlowSchema` now refuses the same set at `nodes.N.config.objectName`, through the one judge `registerFlow` and `objectstack validate` share, with the runtime's prescription to change metadata through the metadata API: one of those three write nodes whose `objectName` names either table by exact name. A `get_record` node and a dynamic target stay outside it: the run judges the name it hands the data engine. No key is removed, so there is no tombstone, and no D2 conversion exists: a refused node carries no intent a rewrite could keep. Its D3 record is the semantic entry `flow-write-node-stored-metadata-target-refused`. It also types the top-level `fields` of the `object-form` and `object-master-detail-form` page blocks (the last stage of the `ComponentPropsMap` `z.unknown()` close-out), the two members the third stage held: the form drew a `{ name }` field entry its own page-builder guide taught, with a `label`, `type` and `required` it silently dropped, and objectui has since retired that entry from every authoring face, drawing only a stored one by its name. So both rows take field names, objectui's own declaration of the member, and refuse an object entry with what to write instead — a `{ name }` entry is its bare name, and a `{ field }` entry belongs in a section. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-form-fields-names-typed`. It also types the `object-gantt` page block's `markers` (the same stage): its entries were `z.unknown()` because the marker contract lived only in objectui, so a marker with no `date`, a numeric `date` or a misspelled member passed every door and the chart drew no line, or drew it unlabelled. The spec now declares objectui's own authoring declaration of a marker, `{ date, label?, color? }` with `date` a string, and the row takes it. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-gantt-markers-typed`. It also types the `object-timeline` page block's `mapping` (the same stage): the binding record — four optional field names for an entry's title, date, description and marker colour — was `z.unknown()` because its contract lived only in objectui, so a bare field name or a misspelled member passed every door and the rail drew the default field. The spec now declares objectui's own declaration of it, and the row takes it. The stage's other members — the metric drill-down's `report`, the form's `customFields` and both forms' `sections`, the timeline's `items` and the action containers' members — stay open: each contract has more than one viable shape that no ruling decides yet. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-timeline-mapping-typed`. It also types the `object-kanban` page block's `conditionalFormatting`, the one member the `ComponentPropsMap` `z.unknown()` close-out held for a ruling: it was `z.unknown()` while objectui's kanban also authored a native rule dialect the list view refuses, so `42` or a rule with no `style` passed every door and the board painted no card for it. objectui has since made the list view's `{ condition, style }` rule the member's only authoring dialect, and the board evaluates it with the grid's evaluator, so the row takes the list view's own member by reference, as `object-grid` does. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-kanban-conditional-formatting-typed`. It also requires every block of a `joined` report to bind a `dataset` (ADR-0021 single-form, enforced under ADR-0049 enforce-or-remove): the schema comment and the reports guide both said each block is dataset-bound, but the joined arm of `ReportSchema`'s refinement required only a non-empty `blocks`, so a block with no `dataset` parsed, passed `objectstack validate` and every save door, and drew nothing: the joined renderer issues no query for it, and a report whose blocks all lack one falls through to the pre-9.0 presentation bridge, which issues none either. The arm now refuses each such block at `blocks[i].dataset`, naming the block, with the prescription to bind it to a dataset; `dataset` stays optional on the block shape, which is read only on a `joined` report. No key is removed, so there is no tombstone, and no D2 conversion exists: only the author knows which dataset a block was meant to show. Its D3 record is the semantic entry `ui-report-joined-block-dataset-required`. It also types the `object-form` page block's `customFields`, one of the two contracts the `ComponentPropsMap` `z.unknown()` close-out held as forks and the maintainer has since ruled: each member is the runtime form field the form draws, which the spec did not declare, so a member with no `name` or a misspelled member passed every door and the form drew the field without it. The spec now declares a closed runtime form field of the members the form draws, in camelCase, keyed by `name` — the `grid` widget's snake_case keys stay out until the widget reads a camelCase spelling — and the row takes a list of it. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-form-custom-fields-typed`. It also types the `sections` of the `object-form` and `object-master-detail-form` page blocks, the other ruled fork: a section's `fields` draws an inline runtime form field beside a name and the form view's `{ field }` entry, which the stored form view's section refuses, so the sections stayed `z.unknown()` and a misspelled key passed every door. Both rows now take one page-block section shape of their own — the form view's section keys plus those three entry arms, the inline arm the runtime form field — in canonical spellings only: a page block's `properties` is never parsed on the way to the form, so a deprecated section `visibleOn` or a string `columns`, which a form view folds at parse, was dropped, and is refused with the canonical spelling. The stored form view is unchanged. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-form-sections-typed`. It then types the three members the stages above held open, as the maintainer ruled them on the decision card for those forks. The `object-metric` drill-down's `report` is `ReportSchema`, by reference (fork 1, letter B): it waited until a joined report refused a block that binds no dataset, and since then every report the member admits is one the drill drawer draws — a report with no `dataset`, a bare report name or a `{ name }` reference, which the drawer answered by listing the records, is refused. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-metric-drill-down-report-typed`. It types the `object-timeline` page block's `items` (fork 4, letter B): each entry is one of objectui's two ruled kinds, closed — a feed entry `{ time, title, description, variant, icon, content, className }` or a gantt row `{ label, items }` of bars `{ title, startDate, endDate, variant }`, each date a string or epoch milliseconds — and a row refinement pairs each entry with the kind the block's `variant` selects, so a feed entry with no `title`, or a gantt row on a feed timeline, is refused instead of drawn empty. A feed entry's `content` (child components) is held unjudged until a writer appears. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-timeline-items-typed`. And it types the members of the `action:group` and `action:menu` page blocks, the last of those forks (the same card, fork 5, letter A): each member was an open record the container draws and runs itself, so a misspelled key, a node-style `actionType` or an `endpoint` no `api` handler reads passed every door. A member now takes `action:button`'s keys with its executor spelled `type`, measured from the containers' reads — an `action:menu` item reads no `size` and declares none — with the rows' prescriptions; `outcomeMessages`, a member `className` and a member `properties.params` are refused, and `outcomeMessages` stays undeclared on all four action blocks as one decision. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-action-group-menu-members-typed`. It then closes the one static-values spelling those members still accepted and the containers drop: an `action:group` or `action:menu` member's `params` takes the input list, an `ActionParam[]` array, only, unless the member's `type` is `api`, whose object `params` keeps its request-payload window. `params` carries one shape and no second value-bag key is declared, so an object `params` on any other member, which parsed and then reached no action, is refused at `actions.N.params` with the prescription to author an action with static parameter values as its own `action:button` node. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-action-group-menu-member-params-array-only`. It also judges an `approval` flow node's `config` at parse against the contract the spec declares for it, `ApprovalNodeConfigSchema`, WHOLE. The approval executor fails the node on any issue of that contract, while `objectstack validate` and `objectstack compile` exited 0 on an undeclared `escalation.bogusKey` or a `timeoutHours: 0.5` and compile copied it into the artifact. The approval node now joins a declared contract map beside the builtin executor contracts, read by the one judge `registerFlow` and `objectstack validate` share, with no plugin loaded: an undeclared key or a refused value is refused at `nodes.N.config.` in the contract's own words, its did-you-mean included, and a key left out as before. The builtin arm stays presence-only. No key is removed, so there is no tombstone, and no D2 conversion exists: the platform cannot know what the author meant. Its D3 record is the semantic entry `flow-approval-node-config-contract-refused`. Then the builtin arm stops being presence-only: a present value a builtin node's executor contract refuses is refused at parse, at `nodes.N.config.`, with the same `node-config-refused-by-contract` code. Every builtin executor parses its config against that contract before it acts, so a `create_record` `outputVariable: 42` or a screen field `min: '1'` used to pass `objectstack validate` and `objectstack compile`, register, and fail every run that reached the node. The arm judges only what the build can know the run will parse: never a value carrying a `{token}`, whatever its slot's type (held back by ruling, not admitted: outside `http` such a token in a number or boolean slot still fails at its first run, so those slots take a literal); on `http`, which parses after interpolating, only token-free values and never the credential-held `signingSecret`; on a `loop`, only one with a `body`; on the region containers, never the region slots. Key membership is untouched. No key is removed, so there is no tombstone, and no D2 conversion exists: the platform cannot know the value the author meant. Its D3 record is the semantic entry `flow-builtin-node-config-values-refused`. It also retires the flat-list form of a package manifest's `permissions` (ADR-0049 enforce-or-remove): `ManifestPermissionsSchema` was a union of a list of permission strings and the structured ADR-0025 block `{ services, hooks, network, fs }`, and nothing ever acted on the list — the loader registers the consented grant set, never the manifest's request — so the block is now the only form. A list is refused at parse with its prescription, and the D2 conversion `manifest-permissions-string-list-removed` strips it from the stack's manifest and every `packages[].manifest` as a lossless delete, retired from the load path; translating what each dropped string meant into the four lists is the author's judgement, not a rewrite. It also makes a declared index state its uniqueness scope (ADR-0120 D1, staged to this protocol by D7). On `indexes[].unique`, bare `true` was the one spelling whose scope was positional: it built the index over exactly `fields`, one holder across the whole installation, while reading like "unique per organization" to an author who knew the field-level meaning. The parse now refuses it with a prescription naming both words — `'global'` (installation-wide, the index bare `true` built) and `'organization'` (one holder per organization). Field-level `unique: true` is untouched. The D2 conversion `declared-index-unique-scope` rewrites a declared index's bare `true` to `'global'`, which is lossless and drift-free by construction, retired from the load path so authors are refused at the door while stored rows, built artifacts and `os migrate meta` replay it. Its D3 record is the semantic entry `declared-index-bare-unique-true-retired`: whether each respelled index was really meant installation-wide is the author's call. It also takes the injected organization column off seven deployment-level platform tables — `sys_job`, `sys_job_run`, `sys_job_queue`, `sys_flow_dispatch`, `sys_migration`, `sys_migration_journal` and `sys_presence` (ADR-0131 D7). A writer census found no writer that attributes a row of any of them to an organization, so the column only ever held NULL, and under a walled posture the tenant wall hid every row from every reader. Each now declares `systemFields: { tenant: false }` and the object-level capability gate `requiredPermissions: ['manage_platform_settings']`: with no column there is no wall, so reads are governed by object permission, and the gate keeps one organization's administrator off another organization's rows. Nothing in stack metadata is rewritten; an existing database keeps the column as an orphan the boot drift report names, and `os migrate apply --allow-destructive` drops it. The D3 records are the seven `sys-*-organization-column-retired` semantic entries. It also refuses, at parse, a flow edge that does not resolve in its own graph or that repeats an earlier one. An edge's `source` and `target` must name nodes of the graph that declares it — the flow's own nodes, or the region body's for an edge inside a region — because the engine resolves them there alone, and a dangling edge carried the run nowhere, silently; and an edge with the same `source`, `target`, `type`, `condition` and branch `label` as an earlier edge of that graph is refused, because the engine runs a target once per out-edge it selects and a copy ran it again. Both are judged in the region walk the node-id rule uses, so `objectstack validate`, `registerFlow` and the metadata save door agree. No key is removed, so there is no tombstone, and no D2 conversion exists: a dangling endpoint carries no intent a rewrite could recover, and dropping a copy changes how often its target runs. Its D3 record is the semantic entry `flow-edge-unresolved-or-repeated-refused`. And the builtin arm judges key membership where no other door does: a key a `script` or `subflow` node's executor contract does not declare is refused at parse, at `nodes.N.config.`, with the same `node-config-refused-by-contract` code. Those two descriptors publish no `configSchema`, so `registerFlow`'s undeclared-key check skipped them, while their executors parse the strict contract and refuse the node on an undeclared key: a `script` `bogusKey` used to pass `objectstack validate`, `objectstack compile` and registration and fail every run that reached the node. Every other builtin keeps its undeclared keys at registration, against its descriptor; a retired `script` key keeps its tombstone. No key is removed, so there is no tombstone, and no D2 conversion exists: the platform cannot know what an undeclared key was meant to be. Its D3 record is the semantic entry `flow-script-subflow-config-undeclared-keys-refused`. It also moves the settings cascade's global rung out of the tenant-scoped `sys_setting` into the new tenant-less `sys_platform_setting` (ADR-0131 D7): one row per namespace and key for the deployment, no organization column, reads governed by the `manage_platform_settings` capability. The settings service writes a global-scope key there and reads the rung from there alone, and the `global` option of `sys_setting.scope` retires because no write reaches it. The cascade order and the `global` resolution source are unchanged. Nothing moves automatically: the v18 upgrade ceremony moves existing global rows, `sys_secret` handles included, and they open unchanged because the ADR-0128 AAD binds no holder object and no organization. The D3 record is the `sys-setting-global-rung-moved` semantic entry. It also retires the document family WHOLE (ADR-0049 enforce-or-remove; the ruling of record on PDF and print documents, letter B′, 2026-10-08: "A document is a page with a print declaration; no new template type"): the four defs of `data/document.zod.ts` — `data/DocumentTemplate` (a docx template with placeholders), `data/Document`, `data/ESignatureConfig` and the orphaned `data/DocumentVersion` — exported from `@objectstack/spec/data`, mounted by no stack key, registered as no metadata type and read by nothing in this repository, objectui or hotcrm, leave via RETIRED_DEFS_BY_MAJOR with one D3 semantic entry, so that "template" means one thing: a printable document is a page that declares `print`. The `ESignatureConfig` deadline-key tombstones leave with their def's source and their RETIRED_KEYS_BY_MAJOR[18] entries stay as history. It retires the single-brace `{…}` template dialect from the flow VALUE slots (the C half of the maintainer's ruling D on the flow expression dialects): the `assignment` node's values, in all three shapes, and the `fields` map of `create_record` and `update_record`, where a CEL value envelope is already the expression form. A string there is now the literal text it spells, and one carrying a `{…}` token is refused — by `FlowValueSlotSchema`, `registerFlow`, `objectstack validate` and the executor alike — with the CEL spelling of each token. No D2 conversion exists: every authored spelling was measured lossy (an absent key writes nothing under the template and fails under CEL; CEL divides two integers as integers), so which value an absent key should write is the author's judgment. The `$User` paths are refused too: the flow CEL scope binds `current_user`, the run's user or `null` in a run with none, so `{$User.Id}` is `current_user.id`, guarded where a flow can run without a user, and the other `$User` paths, which never resolved, name a read of the user record. The date macros keep their meaning until CEL can spell them. Its D3 record is the semantic entry `flow-value-slot-template-dialect-refused`. It also takes the injected organization column off the compliance ledger, `sys_audit_log` (ADR-0131 D7): some of its rows are about deployment-level actions no organization owns, so the organization a row is about stays in the attribution field `tenant_id`, which every writer already stamps, and never becomes the tenancy anchor. With no column there is no wall, so a platform administrator now reads the rows about no organization too; an organization reader is scoped to the rows about its active organization by the platform row policy `sys_audit_log_org`, stripped when no wall is enforced, and `organization_admin` names the ledger without the superuser bits so its wildcard bypass cannot skip that policy. Per-tenant retention partitions on `tenant_id`. Nothing moves automatically: an existing database keeps the column as an orphan the boot drift report names, for the v18 ceremony to drop once its values are confirmed in `tenant_id`. The D3 record is the `sys-audit-log-organization-column-retired` semantic entry. Then the builtin key arm covers every builtin whose contract registration could judge: a key the executor contract of a `get_record`, `create_record`, `update_record`, `delete_record`, `notify`, `http`, `screen`, `map`, `loop` or `parallel` node does not declare is refused at parse, at `nodes.N.config.`, with the same `node-config-refused-by-contract` code, closed with the rename-or-remove remedy. Registration's descriptor walk refused those keys already, after `objectstack validate` and `objectstack compile` had passed them, and it now stands aside for those types, so each has one judge; the declared key sets were measured equal first, so registration refuses what it refused before. `try_catch` waits for its contract's `retry` to close (below): it stripped an unknown key where its descriptor closes it. No key is removed, so there is no tombstone, and no D2 conversion exists. Its D3 record is the semantic entry `flow-builtin-node-config-undeclared-keys-refused`. It executes ADR-0032 Decision 3 in the flow TEXT slots — a `notify` node's `title` and `message`, a `screen` node's `title` and `description`, a refusing `end` node's `message`: they render through the formula template engine, so their placeholders are `{{ }}` holes, a variable path with an optional formatter (the engine's hole grammar now admits a `$`-named variable, so `{{ $error.message }}` is a hole). A single-brace `{…}` token there is refused — by the node contract, `registerFlow` and `objectstack validate` alike — with the hole spelling of each path token, or, for arithmetic, a function, a date macro or a run-user path, the `assignment` that computes it into a variable. No D2 conversion exists: the 17.x interpolator and the engine render a `Date` differently (JSON-quoted against ISO text), and a whole-slot object differently in a screen or `end` text, so the rewrite is the author's to check. Every other flow string keeps the single-brace dialect. Its D3 record is the semantic entry `flow-text-slot-single-brace-refused`. It also makes the deployment's platform-global declaration total (ADR-0131 D7): an object a deployment declares platform-global in its `org-scoping` service's `platformGlobalObjects` gets no organization column on that deployment, because the injected-columns plan reads the declaration, so the organization wall and the driver agree by having nothing to scope. The engine reads it at its plugin start, before the first schema sync, once every plugin init has run, and re-plans the objects registered before it; the security layer's stand-down for such an object retires with it. An absent declaration changes nothing, and a malformed one is refused and declares nothing. Nothing moves automatically: a declaring deployment's existing table keeps the column as an orphan the boot drift report names. The D3 record is the `platform-global-object-organization-column-retired` semantic entry. It also retires the `sys_view_definition` platform object as inert (ADR-0131 D13): no framework code wrote or read its rows, and runtime-authored views are `view` items in `sys_metadata`. The object, its two registrations, its `kernel:ready` active-row index migration and that migration's exports leave, and its name leaves the platform-object registry. Nothing in stack metadata is rewritten; an existing database keeps the table, which no platform path drops. The D3 record is the `sys-view-definition-retired` semantic entry. Then the retry policy closes, and `try_catch` joins the builtin key arm: `RetryPolicySchema`, the one declaration behind `job.retryPolicy` and a `try_catch` node's `retry`, refuses a key it does not declare, naming it with a did-you-mean, where it used to strip it — and with opt-in defaults a stripped `maxRetries` meant no retry at all. No writer relied on the strip. With `retry` closed to the five keys the descriptor declares, a key a `try_catch` node's contract does not declare is refused at parse, at `nodes.N.config.`, with the same `node-config-refused-by-contract` code, and the descriptor walk keeps plugin node types only. A `retryDelayMs` the conversion leaves beside a different `backoffMs` meets its tombstone there, as it met the walk. No key is removed, so there is no new tombstone, and no D2 conversion exists. Its D3 record is the semantic entry `try-catch-and-retry-policy-undeclared-keys-refused`. In those same text slots a `{{ }}` hole may root at a `$`-named variable only when the flow engine binds it (`$record`, `$runId`, `$flowName`, `$flowLabel`, `$error`, and a flat-graph `loop`'s `$loopItems` / `$loopIndex`): a hole such as `{{ $User.Id }}`, which the 17.x contracts accepted as a plain string and which renders nothing under the template engine, is refused by the node contract, `registerFlow` and `objectstack validate` with the remedy its single-brace spelling gets — compute the value with an `assignment` node, then write the variable as a hole. The template engine binds no new variable, and no D2 conversion exists: what the hole was meant to read is not in the flow. Its D3 record is the semantic entry `flow-text-slot-unbound-dollar-root-refused`. It also refuses, at `objectstack validate` and the runtime publish gate, a flow CEL root the flow does not bind — in a trigger gate, an edge, a `decision` branch, a screen field `visibleWhen` or a CEL value envelope. Such a root fails every run that reaches it with an unknown-variable fault, and the build used to pass it. The run-user spellings formulas, row-level security and the client accept (`user`, `ctx.user`, `os.user`) are the reachable case: flow CEL binds the run's user as `current_user` only, and the refusal names it. A flow whose run can bind a name the reader cannot see is left unjudged, and no D2 conversion exists: none of these roots ever evaluated in a flow. Its D3 record is the semantic entry `flow-cel-unbound-root-refused`. ### Mechanical (applied for you) @@ -949,6 +949,9 @@ AUTHOR-REACHABLE SURFACES: a saved report's `query.filter` (`sys_saved_report`) - **`flow-builtin-node-config-values-refused`** — `a builtin flow node (get_record, create_record, update_record, delete_record, notify, http, screen, script, subflow, map, loop, parallel, try_catch) whose config carries a value its executor contract refuses — a value of the wrong type (create_record outputVariable 42, a screen field min written as the string 1, get_record limit as a string, update_record multi as a string), a value outside the declared set or range (notify severity loud, screen mode view, loop maxIterations 0, try_catch retry.maxRetries above 10), an empty script function or subflow flowName, or a rule finding on present keys (a notify template beside an inline title). Never a value carrying a token in braces, an undeclared or retired key, a region slot, or an http signingSecret. Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, defineFlow(), an exported stack passed to objectstack validate or objectstack compile, a flow saved from the Studio flow designer, and a flow row already sitting in sys_metadata` → the value the contract declares, written at the key the refusal names: a string where it wants a string (`outputVariable: 'taskId'`), a number where it wants a number (`min: 1`, `limit: 10`, `maxIterations: 5`, `timeoutMs: 5000`), a boolean where it wants a boolean (`multi: true`, `durable: true`), one of the declared values (`severity: 'warning'`, `mode: 'edit'`), or a value inside the declared range. Outside `http`, a number or boolean slot takes a LITERAL only: those executors parse the config as authored, so a `{token}` template there (`limit: '{page.size}'`, `maxIterations: '{cap}'`) passes the build doors and still fails every run. Only `http` interpolates its config before it parses, so only an `http` slot may also take a sole-token template that resolves to the declared type (`timeoutMs: '{timeout}'`, `durable: '{durable}'`). For a rule finding, follow the rule's own sentence (keep `template` or the inline `title` / `message`, not both) - Why not automatic: Every builtin executor (`service-automation` `builtin/`) parses its node's `config` against the contract `getBuiltinNodeConfigContracts()` names before it acts, and refuses the node on any finding. The build doors judged only the keys that contract requires, left out, so a present value it refuses — `create_record` `outputVariable: 42`, a screen field `min: '1'` (the shape the Studio designer used to store for a field's Min / Max) — passed `FlowSchema.parse`, `objectstack validate` and `objectstack compile`, registered, and then failed every run that reached the node: the config is metadata, and no rerun could succeed. The one judge `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first) and `objectstack validate` share (`flowNodeConfigRefusals`) now refuses such a value as `node-config-refused-by-contract`, anchored at the key, in the contract's own words — the code the approval contract already uses. It judges only what the build can know the run will parse, and holds one more class back by ruling: a value carrying a `{token}` is never refused at the build doors for its pre-interpolation type — which is no promise it runs, since every builtin but `http` parses its config as authored and so still refuses a token in a number or boolean slot at its first run; `http` parses after interpolating its whole config, so only token-free values are judged there and never `signingSecret`, which the credential channel may supply; a `loop` with no `body` is not parsed by its executor and is judged for nothing; the region slots of `loop`, `parallel` and `try_catch` are judged as graphs of their own and by `validateControlFlow`. An undeclared or retired key, a `predicate` ledger slot (a screen field `visibleWhen`) and a `value` ledger slot (a CRUD `fields` value) keep the judges they had. ⚠️ No D2 conversion: the platform cannot know the value the author meant. ⚠️ Where such a node already sits the whole flow is refused: registered from the metadata registry or `sys_metadata` at boot it is skipped with a `warn` naming it, its trigger not armed, while the flows beside it register; a `defineStack({ flows })` source throws `StackSchemaInvalidError` for the whole stack; an artifact file is refused whole at load. ADR-0087, ADR-0031. - Done when: Run `objectstack validate` over every stack authored in config files, and boot every deployed stack. Each refusal names the node and the key: `FlowSchema.parse` anchors a `custom` issue at `nodes.N.config.` (`nodes.N.config.outputVariable`, `nodes.N.config.fields.0.min`, or the region path `nodes.N.config.body.nodes.M.config…`), `objectstack validate` prints the same path, and `validateStackExpressions` phrases it as `node 'mk' (create_record) config.outputVariable`. For each hit write the value the contract declares, per the replacement. Two proofs. (1) For a stack authored in config files, `objectstack validate` is clean. (2) Boot the stack and confirm each flow REGISTERS: no `failed to register flow` warn for it — that warn line is the locator for a row that exists only in `sys_metadata`. A node whose values its contract accepts parses and registers byte-identically to before. A `{token}` template parses and registers as before too, and runs only where the run parses it after interpolation (`http`) or where the slot takes a string; in a number or boolean slot of any other builtin it fails at its first run exactly as it did, so write a literal there. +- **`flow-cel-unbound-root-refused`** — `flows[].nodes[].config.condition, flows[].edges[].condition, a decision node's conditions[].expression, a screen node's fields[].visibleWhen, and the CEL value envelopes of an assignment node's assignments and of a create_record or update_record node's fields — a CEL source whose root identifier the flow does not bind, such as user.id, ctx.user.id or os.user.id` → current_user for the run's user: user.id, ctx.user.id and os.user.id become current_user.id, and a flow that can run without a user guards it as current_user != null ? current_user.id : null. Any other root becomes a name the flow binds — a declared variable, an outputVariable, an iterator, index or error variable, an assignment target, a node id or a screen field — or a field of the trigger record, read bare or as record.FIELD + - Why not automatic: A flow CEL expression evaluates in one scope per run: the flow's variables spread to top level, the trigger record's fields flattened beside them, and record, previous, vars and current_user bound by the engine. A root outside that scope fails the expression with an unknown-variable fault on every run that reaches it, and the build door, which judged a flow expression's syntax only, passed it. The run-user spellings are the reachable case: formulas, row-level security and the client bind the run's user under user, ctx.user and os.user as well, and flow CEL does not, because a top-level user would collide with a variable or a lookup field of that name. objectstack validate and the runtime publish gate now refuse such a root, naming current_user for the user spellings and the in-scope names for any other. A flow whose run can bind a name the reader cannot see is not judged: one with a script or connector_action node, a wait, subflow or map node a resume can fold a bag into, a screen with no field list, a plugin node type, or a trigger object the stack does not declare. No D2 conversion exists: none of these roots ever evaluated in a flow, so there is no behaviour to carry over, and a run without a user needs a guard only the author can choose. + - Done when: Run objectstack validate: it reports each refused root as expression-invalid at the flow, the node or edge and the slot, naming the root and its remedy. Replace each run-user spelling with current_user, guarded where the flow can run without a user, and bind or correct every other root. Re-run the flow paths that evaluate those expressions and confirm each one evaluates instead of failing with an unknown-variable fault. - **`flow-decision-branch-expression-absent-refused`** — `a decision node branch — an element of config.conditions[] — written without its expression key, or with expression: null, at any depth including an ADR-0031 region body. That includes a branch whose predicate sits under another key (condition is the edge spelling). Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, defineFlow(), an exported stack passed to objectstack validate, a flow saved from the Studio flow designer with a branch row whose expression cell is empty, and a flow row already sitting in sys_metadata` → the predicate the branch was meant to test, as non-blank bare CEL text under `expression` (`{ label: 'high', expression: 'record.amount > 10000' }`); a predicate written under `condition` moves to `expression`. To keep the branch and its label but never take it, write `expression: 'false'` — that is a CHANGE of behaviour, not a preserved one: a run that reached the branch used to fail there (`condition evaluation error`), and now routes on to the next branch or the declared fallback. ⚠️ Not by dropping a decision's only branch: with no `conditions` the node routes by its out-edges alone, so the out-edge that branch labelled is no longer held back - Why not automatic: `DecisionConditionSchema` declares a branch `{ label, expression }` with `expression` a required `z.string()`, but nothing parses a decision node's open config against it, and the expression-ledger resolver skipped an absent value as "not authored" — so a branch with no predicate passed `FlowSchema.parse`, `AutomationEngine.registerFlow` and `objectstack validate`, and the decision executor then handed `evaluateCondition` an envelope with no `source`, which it refuses: the build accepted what the run refused. The ledger now marks the slot `required` (reconciled against that schema's own `required` list), the resolver emits the absent value there, and all three doors refuse it through `predicateSlotRefusal`, leading with `PREDICATE_SLOT_STRING_REFUSAL` — the walk, function and sentence that already refuse the blank string. ⚠️ No D2 conversion: the platform cannot know the rule the author left out, and `'false'` would change what the flow does rather than keep it. ⚠️ Where such a branch already sits the whole flow is refused: registered from the metadata registry or `sys_metadata` at boot it is skipped with a `warn` naming it, its trigger not armed, while the flows beside it register; a `defineStack({ flows })` source throws `StackSchemaInvalidError` for the whole stack; an artifact file is refused whole at load. ADR-0087, ADR-0032. - Done when: Grep every flow node in `defineStack({ flows })` sources, exported stacks and every flow row in `sys_metadata` — including nodes inside a `loop` / `parallel` / `try_catch` region body — for a `decision` node whose `config.conditions[i]` has no `expression` key, or `expression: null`. Each refusal names the node and the branch: `FlowSchema.parse` anchors a `custom` issue at `nodes.N.config.conditions.I.expression` (or the region path `nodes.N.config.body.nodes.M.config…`), and `objectstack validate` prints the same path; `validateStackExpressions` phrases it as `node 'check' (decision) decision branch expression at config.conditions[0].expression`. For each hit write the predicate the branch was meant to test, or `expression: 'false'` where the branch should keep its label and never be taken. Two proofs. (1) For a stack authored in config files, `objectstack validate` is clean. (2) Boot the stack and confirm each flow REGISTERS: no `failed to register flow` warn for it (the three boot paths spell it `[Automation] failed to register flow`, `[Automation] flow re-sync: failed to register flow` and `[Automation] cold-boot flow bind: failed to register flow`) — that warn line is the locator for a row that exists only in `sys_metadata`. A branch carrying a non-blank predicate parses and registers byte-identically to before, a decision with no `conditions` still routes by its out-edges, and an absent screen field `visibleWhen` is still legal. @@ -970,18 +973,18 @@ AUTHOR-REACHABLE SURFACES: a saved report's `query.filter` (`sys_saved_report`) - **`flow-script-subflow-config-undeclared-keys-refused`** — `a script or subflow flow node whose config carries a key its executor contract does not declare — a typo (funtion), a key copied from another node type (a subflow timeoutMs written inside config, an approvers list on a script), or a key nothing reads (bogusKey). script declares function, inputs and outputVariable; subflow declares flowName, input and outputVariable. Never a retired script key (actionType, template, recipients, variables, script), which keeps its own path, and never a key on any other builtin node type, whose undeclared keys registration already judges against the node type descriptor. Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, defineFlow(), an exported stack passed to objectstack validate or objectstack compile, a flow saved from the Studio flow designer, and a flow row already sitting in sys_metadata` → the key the contract declares, or no key: rename a typo to the declared key it meant (`function`, `inputs`, `outputVariable` on a `script`; `flowName`, `input`, `outputVariable` on a `subflow`), move a value the function or the child flow should receive into `inputs` (script) or `input` (subflow), move a `subflow` timeout to the node itself (`{ id, type: 'subflow', timeoutMs: 30000, config: { … } }`), and delete a key nothing reads. The refusal carries the contract's own sentence, with its did-you-mean for a near miss - Why not automatic: The `script` and `subflow` executors (`service-automation` `builtin/screen-nodes.ts`, `builtin/subflow-node.ts`) parse the node's `config` against a strict contract (`ScriptConfigSchema`, `SubflowConfigSchema`) before they act, and refuse the node on an undeclared key. No door before the run judged one: `registerFlow`'s undeclared-key check derives the declared set from the node type descriptor's `configSchema`, and these two descriptors publish none (the schemaless class, `SCHEMALESS_NODE_CONFIG_SCHEMAS`), while the build doors' executor-contract arm judged required keys and present values but held key membership back on the premise that registration judges it. So a `script` node carrying `bogusKey` passed `FlowSchema.parse`, `objectstack validate` and `objectstack compile` (which copied the key into the artifact), registered, and then failed every run that reached the node: the config is metadata, and no rerun could succeed. The one judge `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first) and `objectstack validate` share (`flowNodeConfigRefusals`) now refuses such a key on these two types as `node-config-refused-by-contract`, anchored at the key, one refusal per key, in the contract's own words — the code the value half and the approval contract already use. Every other builtin keeps its undeclared keys where they were judged: at registration, against its descriptor, with that check's own prescriptions. `decision` is schemaless too, but its executor parses no contract, so an undeclared key there fails no run and stays unjudged. A retired `script` key keeps its tombstone path. ⚠️ A spelling the ADR-0087 D2 conversion `flow-node-script-config-aliases` or `flow-node-subflow-flow-alias` still rewrites at load (`functionName`, `input` on a `script`; `flow` on a `subflow`) is converted before the judge at every door that converts first; met by a direct `FlowSchema.parse` or `defineFlow()` it is refused like any other undeclared key, as the missing canonical key already was. ⚠️ No D2 conversion: the platform cannot know what an undeclared key was meant to be. ⚠️ Where such a node already sits the whole flow is refused: registered from the metadata registry or `sys_metadata` at boot it is skipped with a `warn` naming it, its trigger not armed, while the flows beside it register; a `defineStack({ flows })` source throws `StackSchemaInvalidError` for the whole stack; an artifact file is refused whole at load. ADR-0087, ADR-0031. - Done when: Run `objectstack validate` over every stack authored in config files, and boot every deployed stack. Each refusal names the node and the key: `FlowSchema.parse` anchors a `custom` issue at `nodes.N.config.` (`nodes.N.config.bogusKey`, or the region path `nodes.N.config.body.nodes.M.config…`), `objectstack validate` prints the same path, and `validateStackExpressions` phrases it as `node 'summarize' (script) config.bogusKey`. For each hit rename, move or delete the key per the replacement. Two proofs. (1) For a stack authored in config files, `objectstack validate` is clean. (2) Boot the stack and confirm each flow REGISTERS: no `failed to register flow` warn for it — that warn line is the locator for a row that exists only in `sys_metadata`. A `script` or `subflow` node whose keys its contract declares parses and registers byte-identically to before. -- **`flow-text-slot-single-brace-refused`** — `flows[].nodes[].config of a notify node (title, message), a screen node (title, description) and an end node (message) — a string, or the source of a template envelope, carrying a single-brace template token` → a double-brace template hole, rendered by the formula template engine over the flow's variables: a variable path with an optional formatter, {{ record.name }}, {{ $error.message }}, {{ rows.0.subject }}, {{ record.amount | currency }}. A token no hole can spell is computed into a variable first, with an assignment node — arithmetic and functions as a CEL value envelope, the date macros and the run-user paths as the value-slot spelling that still reads them — and written as {{ variable }} +- **`flow-text-slot-single-brace-refused`** — `flows[].nodes[].config of a notify node (title, message), a screen node (title, description) and an end node (message) — a string, or the source of a template envelope, carrying a single-brace template token` → a double-brace template hole, rendered by the formula template engine over the flow's variables: a variable path with an optional formatter, {{ record.name }}, {{ $error.message }}, {{ rows.0.subject }}, {{ record.amount | currency }}. A token no hole can spell is computed into a variable first, with an assignment node — arithmetic and functions as a CEL value envelope, the date macros as the value-slot spelling that still reads them, the run user's id as the CEL value envelope current_user.id — and written as {{ variable }} - Why not automatic: ADR-0032 Decision 3 fixes one template delimiter, double braces, and deletes the single brace: it collides with CEL map literals, and an author who meets both dialects in one flow mixes them. The 17.x interpolator and the template engine render the same text for a path holding a string, a number, a boolean, null, an absent key or variable, an ISO date string, an object or an array, but not for every value — a Date rendered JSON-quoted under the interpolator and as its ISO text under the engine, and a screen title, screen description or end message that was one token holding an object, an array or a Date rendered String(value) — so no conversion is lossless (ADR-0087 D2) and none is applied. Arithmetic, function calls, the date macros and the run-user paths have no hole spelling: a hole is a path with a formatter, never logic. A flow carrying a single-brace token in a text slot is refused at registration, by objectstack validate and by the node contract; a stored flow carrying one is skipped at boot with a warn naming it. - Done when: Run objectstack validate: it reports each refused text slot as expression-invalid at the node and the slot's key, with the double-brace spelling of every path token. Rewrite each slot as that spelling; for a token no hole can spell, add the assignment the refusal names and write its variable as a hole. Re-run the flow paths that send those notifications or show those screens and compare the text with the text the 17.x renderer produced — in particular any slot that renders a date value or a whole object. -- **`flow-text-slot-unbound-dollar-root-refused`** — `flows[].nodes[].config of a notify node (title, message), a screen node (title, description) and an end node (message) — a string, or the source of a template envelope, carrying a double-brace hole whose root is a dollar-named variable the flow engine does not bind, such as {{ $User.Id }}` → a variable the run has, written as a hole. The run user is computed first, with an assignment node whose value slot still reads the run-user path (assignments: { by: '{$User.Id}' }), then written as {{ by }}. A variable the flow binds itself (a declared variable, an assignment target, an outputVariable, a try_catch errorVariable) is named without the dollar sign and written as {{ caught.message }}. The engine's own variables stay holes: {{ $error.message }}, {{ $record.name }}, {{ $runId }}, {{ $flowName }}, {{ $flowLabel }}, and a flat-graph loop's {{ $loopItems }} / {{ $loopIndex }} +- **`flow-text-slot-unbound-dollar-root-refused`** — `flows[].nodes[].config of a notify node (title, message), a screen node (title, description) and an end node (message) — a string, or the source of a template envelope, carrying a double-brace hole whose root is a dollar-named variable the flow engine does not bind, such as {{ $User.Id }}` → a variable the run has, written as a hole. The run user's id is computed first, with an assignment node whose CEL value envelope reads current_user, the run's user (assignments: { by: { dialect: 'cel', source: 'current_user.id' } }), then written as {{ by }}; every other run-user path never resolved in any shipped run, and an email or a name is read from the user record by current_user.id. A variable the flow binds itself (a declared variable, an assignment target, an outputVariable, a try_catch errorVariable) is named without the dollar sign and written as {{ caught.message }}. The engine's own variables stay holes: {{ $error.message }}, {{ $record.name }}, {{ $runId }}, {{ $flowName }}, {{ $flowLabel }}, and a flat-graph loop's {{ $loopItems }} / {{ $loopIndex }} - Why not automatic: The dollar-named variables are the flow engine's own: it binds $record, $runId, $flowName, $flowLabel and $error, and a flat-graph loop binds $loopItems and $loopIndex. A hole over any other dollar name answers to no variable — {{ $User.Id }} looks like the run user and is not one, since the run user has no hole spelling. In 17.x the slot was a plain string read by the single-brace interpolator, which substituted the inner token and left a literal brace on each side; the 18 text slots render holes through the template engine, where such a hole renders nothing and the run reports success. It is now refused by the node contract, at registration and by objectstack validate, with the remedy its single-brace spelling gets; a stored flow carrying one is skipped at boot with a warn naming it. No D2 conversion exists: what the author meant the hole to read is not in the flow, and the template engine binds no new variable to answer it. - Done when: Run objectstack validate: it reports each refused text slot as expression-invalid at the node and the slot's key, naming the hole and its remedy. For a run-user hole, add the assignment the remedy names and write its variable as the hole; for a variable the flow binds under a dollar name, drop the dollar sign at the binding and in the hole. Re-run the flow paths that send those notifications or show those screens and confirm the text carries the value, with no stray brace and no missing fragment. - **`flow-trigger-record-credential-masked`** — `the record and previous roots a record-change flow receives — a password or secret field, and an internal field, of the triggering record, on every object` → read a credential through a privileged binder — the flow credential channel for an http node's signing secret, or a privileged server-side read such as the engine's resolveSecretField — never off `record` or `previous`; on those roots a set credential-class field now reads as the mask `SECRET_MASK`, an unset one as null, and an `internal: true` field is absent - Why not automatic: ADR-0100: a credential-class value leaves the engine only through a privileged dereference, and every generic channel serves the mask. The record-change trigger built a flow's record and previous from the engine's own write result, which keeps the stored row whole for privileged in-process callers, so a password field's plaintext, a secret field's stored handle and an internal field's value reached the flow — and from there its variables, a paused run's persisted state and that state's read doors. The trigger now projects both roots through the same helper every external write response uses: a credential-class field (secret, and password outside the exempt managedBy buckets) carries the mask, or null when unset, and an internal field is omitted. Every other field keeps its value, every other variable is untouched, and the engine's own write result, the stored row and the privileged read paths are unchanged. - Done when: No flow reads a password, secret or internal field off its trigger record or previous values expecting the stored value; a flow that needs a credential obtains it through a privileged binder; a start or edge condition that compared such a field against a literal is rewritten to test whether it is set (not null). -- **`flow-value-slot-template-dialect-refused`** — `flows[].nodes[].config of an assignment node (the assignments map, the legacy assignments array and the legacy bare config) and of create_record and update_record nodes (the fields map) — a string value, or a string anywhere inside an array or object value, carrying a single-brace template token` → a CEL value envelope, { dialect: "cel", source: "…" }, evaluated to the value: a path is the same path (record.owner; a numeric segment becomes an index, items[0]; a variable whose name starts with $ is read through vars, vars["$error"].message), arithmetic is the same arithmetic with every integer divisor written as a double (round(x * 100) / 100.0), and text with holes is one concatenation ('Hello ' + o.name). A string with no token is the literal text it spells, and braces meant literally are a CEL string literal - - Why not automatic: The interpolator and the CEL engine answer differently for every token spelling authored in flows, so no conversion is lossless (ADR-0087 D2) and none is applied. A path, an absent variable, key or list index wrote nothing under the template and fails the run under CEL; text with a null hole rendered nothing and CEL refuses + null; CEL divides two integers as integers, so round(x * 100) / 100 truncates 123.46 to 123. Where a value may be absent, which of nothing, null or a default the field should take is the author's decision — the template decided it silently. Two spellings are kept with their old meaning, because CEL cannot write them yet: the date macros NOW() and TODAY() with a day offset (CEL yields a Timestamp, not the ISO text, and has no string form for one) and the run-user paths beginning $User. (the flow CEL scope binds no user). A flow carrying a refused value is refused at registration, by objectstack validate and by the executor; a stored flow carrying one is skipped at boot with a warn naming it. - - Done when: Run objectstack validate: it reports each refused value as expression-invalid at the node and the value's path, with the CEL spelling of its tokens. Rewrite each as that envelope; where a variable or key may be absent, guard it (has(record.owner) ? record.owner : null, has(vars.x) ? vars.x : null for a variable) or route around the node. Re-run the flow paths that write those fields and compare the stored values with the ones the template wrote. +- **`flow-value-slot-template-dialect-refused`** — `flows[].nodes[].config of an assignment node (the assignments map, the legacy assignments array and the legacy bare config) and of create_record and update_record nodes (the fields map) — a string value, or a string anywhere inside an array or object value, carrying a single-brace template token, the run-user paths beginning $User. included` → a CEL value envelope, { dialect: "cel", source: "…" }, evaluated to the value: a path is the same path (record.owner; a numeric segment becomes an index, items[0]; a variable whose name starts with $ is read through vars, vars["$error"].message), arithmetic is the same arithmetic with every integer divisor written as a double (round(x * 100) / 100.0), and text with holes is one concatenation ('Hello ' + o.name). The run user's id, $User.Id, is current_user.id — current_user is the run's user, or null when the run has none — and in a flow that can run without a user it is current_user != null ? current_user.id : null, which writes null where the template wrote nothing, so on update_record it clears a stored value the template left alone. Every other run-user path ($User.Email, $User.Name, …) never resolved in any shipped run: current_user carries only what the run holds (id, positions, organizationId, isPlatformAdmin), and an email or a name is read from the user record by current_user.id (a get_record node on sys_user). A string with no token is the literal text it spells, and braces meant literally are a CEL string literal + - Why not automatic: The interpolator and the CEL engine answer differently for every token spelling authored in flows, so no conversion is lossless (ADR-0087 D2) and none is applied. A path, an absent variable, key or list index wrote nothing under the template and fails the run under CEL; text with a null hole rendered nothing and CEL refuses + null; CEL divides two integers as integers, so round(x * 100) / 100 truncates 123.46 to 123. Where a value may be absent, which of nothing, null or a default the field should take is the author's decision — the template decided it silently. The run user's id was the run's userId under the template, and nothing in a run with no user (a schedule, a record change made by a system write); the flow CEL scope binds current_user to the run's user and to null in such a run, never a pseudo-user, so current_user.id fails there and its guarded form writes null. The other run-user paths read a user object no run carries, so they wrote nothing in every run. One spelling is kept with its old meaning, because CEL cannot write it yet: the date macros NOW() and TODAY() with a day offset (CEL yields a Timestamp, not the ISO text, and has no string form for one). A flow carrying a refused value is refused at registration, by objectstack validate and by the executor; a stored flow carrying one is skipped at boot with a warn naming it. + - Done when: Run objectstack validate: it reports each refused value as expression-invalid at the node and the value's path, with the CEL spelling of its tokens. Rewrite each as that envelope; where a variable or key may be absent, guard it (has(record.owner) ? record.owner : null, has(vars.x) ? vars.x : null for a variable) or route around the node. For the run user, find which flows can run without one (a schedule, a record change a system write can make): there, guard current_user.id, or skip the node with a start condition or a decision on current_user != null where an update_record must leave the stored value alone. Re-run the flow paths that write those fields and compare the stored values with the ones the template wrote. - **`flow-write-node-stored-metadata-target-refused`** — `a create_record, update_record or delete_record flow node whose config.objectName is the string sys_metadata or sys_metadata_history, at any depth including an ADR-0031 region body` → Change metadata through the metadata API (`PUT /api/v1/meta/:type/:name`, the metadata protocol), where it is validated and its provenance is recorded. Delete the node, or point its `objectName` at the object the flow really means to write. Elevation (`runAs`, a system context) does not change this. - Why not automatic: `FlowSchema` accepted a `create_record`, `update_record` or `delete_record` node whose `objectName` names `sys_metadata` or `sys_metadata_history`, the tables that hold stored metadata. The maintainer ruled (2026-10-03) that app-authored work may not write those tables: the metadata protocol is their only writer, where a change is validated and its provenance recorded, and a flow is app-authored automation. The runtime enforces that at the node, refusing the write before it resolves a filter, computes a field or calls the data engine, under every run identity; but every authoring door still accepted such a flow, and the author learned otherwise only at its first run. The parse now refuses it too, through the one judge `FlowSchema.parse`, `AutomationEngine.registerFlow` and `objectstack validate` share (`flowNodeConfigRefusals`), with the runtime's prescription: `objectstack validate`, `defineStack`, compile, an artifact's parse, `registerFlow` and the metadata save door each name the node at `nodes.N.config.objectName`. The refused set is exactly the runtime's: one of those three write nodes, whose `objectName` is a string naming a stored-metadata table by exact name. A `get_record` node is outside it (a read is not a write), and so is a dynamic target, a `{token}` template or an expression envelope: the parse cannot read it as a name, and the run judges the name it hands the data engine. No authored flow writing either table was measured in this repository, its examples, its skills or its docs. There is no mechanical rewrite: retargeting the node or deleting it each changes what the author wrote, and the runtime already never ran it. Where such a node already sits, the whole flow is refused: registered from `sys_metadata` at boot it is skipped with a warn naming it, its trigger not armed, while the flows beside it register. - Done when: `objectstack validate` reports no issue at a flow node's `config.objectName`: no `create_record`, `update_record` or `delete_record` node names `sys_metadata` or `sys_metadata_history`. Every change those nodes made to metadata is made through the metadata API instead. Saving each formerly affected flow through the metadata API succeeds instead of answering a 422 that names `config.objectName`, and boot logs no `failed to register flow` warn for it. @@ -1312,8 +1315,8 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-regis - Why not automatic: ADR-0049 enforce-or-remove; maintainer ruling 2026-09-06, option 3: keep a startup-result contract re-declared as the shape the kernel ships, and retire the rest. The module declared an orchestration design that never landed, and the spec and the kernel had already drifted into disagreement about the one shape that did: PluginStartupResultSchema described a plugin object, a required durationMs and a health member, while @objectstack/core shipped pluginName, an optional durationMs and timedOut. The ruling keeps a startup-result contract that describes what the kernel actually produces, and retires the rest. Re-measured on this card: zero implementers and zero consumers of the four retired surfaces in this repository and in the pinned objectui checkout, with lit same-corpus controls (defineStack, ManifestSchema); every remaining reference was a generated artifact or a released CHANGELOG.md. healthCheck and HealthStatus are the sharpest of the four: they name a per-plugin health probe the runtime has never had, the shape of the plugin sandboxing / integrity / approval config that was never wired to anything, which an AI author (ADR-0033) reads as proof the capability exists. With no authored document carrying any of the three defs there is no seam for a D2 conversion and no author to tombstone for: route 3, the shape of the dynamic plugin-loading family's removal and the advanced plugin-lifecycle config's retirement — RETIRED_DEFS_BY_MAJOR plus this entry ARE the declaration. The two keys of the SURVIVING result schema that leave (plugin, health) are tombstoned instead, and registered in RETIRED_KEYS_BY_MAJOR, because that def keeps emitting and its type is imported by @objectstack/core. A third key arrives on the spec surface only to leave it: core deprecated startTime alias, which held the same elapsed milliseconds as durationMs under a name that promises an instant. The re-declaration had to either mirror it or tombstone it, and mirroring is refused by check:duration-unit-keys (ruling B on duration-shaped number keys: the unit lives in the key name) since it is an elapsed number whose key name carries no unit and matches neither of that rule two schema-declared exemptions. So the L1 window closes here and the kernel stops populating it in the same change. - Done when: No code imports any of the 8 retired names from @objectstack/spec, @objectstack/spec/kernel or @objectstack/spec/contracts — every one is TS2305 after upgrade, pinned by resolved symbol identity in kernel/startup-orchestrator-retirement.test.ts. No metadata document needs editing: none of the three defs was reachable from a metadata-type binding, a stack collection or a manifest embed, so no authored document could ever carry one. PluginStartupResult SURVIVES on both entries with the shape the kernel ships — pluginName, success, optional durationMs, the serializable error projection, timedOut — and @objectstack/core now imports that type instead of declaring a twin, so the drift cannot recur. Writing plugin, health or startTime on a PluginStartupResult is a tsc error and a parse error carrying the rename or the deletion; a reader of the removed startTime alias reads durationMs, which has always carried the same value. Runtime behaviour is unchanged except for that one alias: nothing ever read the retired ORCHESTRATION surfaces, the kernel boot loop is untouched, and the only observable difference is that a startup result no longer carries startTime beside durationMs. - **`storage-scope-public-retired`** — `the storage scope public — the scope of an upload request, presigned or chunked, and ObjectStorageConfig.scope (StorageScope)` → another scope, or none for the default (`user` on an upload, `global` on a storage configuration), and `acl: 'public_read'` on the stored file record of each file that must be readable before sign-in (ADR-0104) - - Why not automatic: A storage scope never made a file publicly readable. The download doors judge a file by its `acl`, the `attachments` scope and field ownership alone, so a file uploaded with scope public and the default acl was stored private and needs a signed-in caller, while its scope said otherwise. The value is retired rather than enforced: enforcing it would let any uploader make a file anonymous at upload, and ADR-0104 keeps `acl: 'public_read'` the one opt-in for anonymous download. Whether a given file must be readable before sign-in is the caller's call, so no rewrite can make it: an upload that meant public needs its stored file record marked, and one that did not needs only another scope. The upload request itself carries no acl, and every upload is stored private. Files already stored with scope public are not touched and download exactly as before. ADR-0049 - - Done when: No upload call names scope public and no ObjectStorageConfig declares it; each upload that did now names another scope or none and is answered 200. Each file that must render before sign-in has acl 'public_read' on its stored file record, and fetching it with no session serves it; fetching any other uploaded file with no session is answered 401. + - Why not automatic: A storage scope never made a file publicly readable. The download doors judge a file by its `acl`, the `attachments` scope and field ownership alone, so a file uploaded with scope public and the default acl was stored private and needs a signed-in caller, while its scope said otherwise. The value is retired rather than enforced: enforcing it would let any uploader make a file anonymous at upload, and ADR-0104 keeps `acl: 'public_read'` the one opt-in for anonymous download. Whether a given file must be readable before sign-in is the caller's call, so no rewrite can make it: an upload that meant public needs its stored file record marked, and one that did not needs only another scope. The upload request itself carries no acl, and every upload is stored private. The stored file record retires the value too: the scope select of sys_file no longer lists public, so a deployment that stored files with it runs the one-time operator sweep that @objectstack/service-storage exports (`planSysFilePublicScopeBackfill` for the dry run, then `applySysFilePublicScopeBackfill`), which rewrites each of those records to scope user. No access changes: no reader tells user apart from public, the storage key and the file bytes are not touched, and those files download exactly as before. Until the sweep has run, a record write that names such a file while another field already owns it is refused, because the copy it makes carries scope public. ADR-0049 + - Done when: No upload call names scope public and no ObjectStorageConfig declares it; each upload that did now names another scope or none and is answered 200. Each file that must render before sign-in has acl 'public_read' on its stored file record, and fetching it with no session serves it; fetching any other uploaded file with no session is answered 401. On each deployment, a dry run of the sweep scans zero sys_file records with scope public. - **`strategy-context-aggregation-method-narrowed`** — `StrategyContext.executeAggregate aggregations[].method (contracts/analytics-service.ts, exported from @objectstack/spec/contracts) - the parameter type, declared as bare string` → AggregationFunction (count | sum | avg | min | max | count_distinct, data/query.zod.ts) - the same closed vocabulary IDataEngine.aggregate already declares for the identical slot (AggregationNodeSchema.function; the analytics bridge renames method to function and forwards). A caller filling method from a string-typed value narrows the value to the enum - typing it AggregationFunction, or parsing with the spec's own AggregationFunction zod enum where the value enters from data. Values outside the six were never served: the bridge has parsed-and-refused them at runtime since it stopped declaring its own engine type and began parsing the method with the spec enum, and that refusal stays as defence in depth - Why not automatic: Maintainer ruling 2026-08-28 (option A, census-first): one slot, one declaration. Two spec-declared surfaces described the same value and disagreed about its type: IDataEngine.aggregate's aggregations[].function is the closed six-value AggregationFunction enum while StrategyContext.executeAggregate declared the same slot aggregations[].method: string, so nothing on the analytics side of that seam was compile-checked against the engine's vocabulary - an author, very often an AI (ADR-0033), writing an analytics strategy got no compile-time help and could carry any method name all the way to the bridge's runtime refusal. One slot now has one declaration. Bookkeeping: this is a TYPE narrowing on a runtime TS interface member - no authorable metadata key, no wire shape and no walked-shape def changed, so nothing lands in RETIRED_KEYS_BY_MAJOR / RETIRED_DEFS_BY_MAJOR and the surface ratchets are expected byte-identical. It is a SEMANTIC entry rather than a D2 conversion because there is no authored document or sys_metadata row for the chain to rewrite: the only consumers are TypeScript call sites, and the compile error is the channel that reaches them. In-repo census at the ruling (hard precondition, measured before the narrowing landed): every implementor and every call site filling method is legal under the enum - ObjectQLStrategy.resolveMeasureAggregation emits only the six once it refuses a custom-SQL measure up front, the two literal producers write count, and every test fixture is implementor-side and stays assignable by contravariance. - Done when: External implementors of StrategyContext stay source-compatible: a handler accepting method: string accepts a superset and remains assignable to the narrowed member. External callers filling method with a string-typed or out-of-vocabulary value fail tsc at the executeAggregate call site on upgrade; the fix is narrowing the value's type to AggregationFunction (parsing with the spec enum where it enters from data), never widening a local mirror of the contract. Runtime behaviour is unchanged: the bridge's parse-and-refuse accepts and rejects exactly the same sets before and after, and no stored metadata or document needs editing. diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 73453680415..2e51716349b 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -2150,6 +2150,13 @@ "toMajor": 18, "rationale": "Every builtin executor (`service-automation` `builtin/`) parses its node's `config` against the contract `getBuiltinNodeConfigContracts()` names before it acts, and refuses the node on any finding. The build doors judged only the keys that contract requires, left out, so a present value it refuses — `create_record` `outputVariable: 42`, a screen field `min: '1'` (the shape the Studio designer used to store for a field's Min / Max) — passed `FlowSchema.parse`, `objectstack validate` and `objectstack compile`, registered, and then failed every run that reached the node: the config is metadata, and no rerun could succeed. The one judge `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first) and `objectstack validate` share (`flowNodeConfigRefusals`) now refuses such a value as `node-config-refused-by-contract`, anchored at the key, in the contract's own words — the code the approval contract already uses. It judges only what the build can know the run will parse, and holds one more class back by ruling: a value carrying a `{token}` is never refused at the build doors for its pre-interpolation type — which is no promise it runs, since every builtin but `http` parses its config as authored and so still refuses a token in a number or boolean slot at its first run; `http` parses after interpolating its whole config, so only token-free values are judged there and never `signingSecret`, which the credential channel may supply; a `loop` with no `body` is not parsed by its executor and is judged for nothing; the region slots of `loop`, `parallel` and `try_catch` are judged as graphs of their own and by `validateControlFlow`. An undeclared or retired key, a `predicate` ledger slot (a screen field `visibleWhen`) and a `value` ledger slot (a CRUD `fields` value) keep the judges they had. ⚠️ No D2 conversion: the platform cannot know the value the author meant. ⚠️ Where such a node already sits the whole flow is refused: registered from the metadata registry or `sys_metadata` at boot it is skipped with a `warn` naming it, its trigger not armed, while the flows beside it register; a `defineStack({ flows })` source throws `StackSchemaInvalidError` for the whole stack; an artifact file is refused whole at load. ADR-0087, ADR-0031." }, + { + "surface": "flows[].nodes[].config.condition, flows[].edges[].condition, a decision node's conditions[].expression, a screen node's fields[].visibleWhen, and the CEL value envelopes of an assignment node's assignments and of a create_record or update_record node's fields — a CEL source whose root identifier the flow does not bind, such as user.id, ctx.user.id or os.user.id", + "replacement": "current_user for the run's user: user.id, ctx.user.id and os.user.id become current_user.id, and a flow that can run without a user guards it as current_user != null ? current_user.id : null. Any other root becomes a name the flow binds — a declared variable, an outputVariable, an iterator, index or error variable, an assignment target, a node id or a screen field — or a field of the trigger record, read bare or as record.FIELD", + "migrationId": "flow-cel-unbound-root-refused", + "toMajor": 18, + "rationale": "A flow CEL expression evaluates in one scope per run: the flow's variables spread to top level, the trigger record's fields flattened beside them, and record, previous, vars and current_user bound by the engine. A root outside that scope fails the expression with an unknown-variable fault on every run that reaches it, and the build door, which judged a flow expression's syntax only, passed it. The run-user spellings are the reachable case: formulas, row-level security and the client bind the run's user under user, ctx.user and os.user as well, and flow CEL does not, because a top-level user would collide with a variable or a lookup field of that name. objectstack validate and the runtime publish gate now refuse such a root, naming current_user for the user spellings and the in-scope names for any other. A flow whose run can bind a name the reader cannot see is not judged: one with a script or connector_action node, a wait, subflow or map node a resume can fold a bag into, a screen with no field list, a plugin node type, or a trigger object the stack does not declare. No D2 conversion exists: none of these roots ever evaluated in a flow, so there is no behaviour to carry over, and a run without a user needs a guard only the author can choose." + }, { "surface": "a decision node branch — an element of config.conditions[] — written without its expression key, or with expression: null, at any depth including an ADR-0031 region body. That includes a branch whose predicate sits under another key (condition is the edge spelling). Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, defineFlow(), an exported stack passed to objectstack validate, a flow saved from the Studio flow designer with a branch row whose expression cell is empty, and a flow row already sitting in sys_metadata", "replacement": "the predicate the branch was meant to test, as non-blank bare CEL text under `expression` (`{ label: 'high', expression: 'record.amount > 10000' }`); a predicate written under `condition` moves to `expression`. To keep the branch and its label but never take it, write `expression: 'false'` — that is a CHANGE of behaviour, not a preserved one: a run that reached the branch used to fail there (`condition evaluation error`), and now routes on to the next branch or the declared fallback. ⚠️ Not by dropping a decision's only branch: with no `conditions` the node routes by its out-edges alone, so the out-edge that branch labelled is no longer held back", @@ -2201,14 +2208,14 @@ }, { "surface": "flows[].nodes[].config of a notify node (title, message), a screen node (title, description) and an end node (message) — a string, or the source of a template envelope, carrying a single-brace template token", - "replacement": "a double-brace template hole, rendered by the formula template engine over the flow's variables: a variable path with an optional formatter, {{ record.name }}, {{ $error.message }}, {{ rows.0.subject }}, {{ record.amount | currency }}. A token no hole can spell is computed into a variable first, with an assignment node — arithmetic and functions as a CEL value envelope, the date macros and the run-user paths as the value-slot spelling that still reads them — and written as {{ variable }}", + "replacement": "a double-brace template hole, rendered by the formula template engine over the flow's variables: a variable path with an optional formatter, {{ record.name }}, {{ $error.message }}, {{ rows.0.subject }}, {{ record.amount | currency }}. A token no hole can spell is computed into a variable first, with an assignment node — arithmetic and functions as a CEL value envelope, the date macros as the value-slot spelling that still reads them, the run user's id as the CEL value envelope current_user.id — and written as {{ variable }}", "migrationId": "flow-text-slot-single-brace-refused", "toMajor": 18, "rationale": "ADR-0032 Decision 3 fixes one template delimiter, double braces, and deletes the single brace: it collides with CEL map literals, and an author who meets both dialects in one flow mixes them. The 17.x interpolator and the template engine render the same text for a path holding a string, a number, a boolean, null, an absent key or variable, an ISO date string, an object or an array, but not for every value — a Date rendered JSON-quoted under the interpolator and as its ISO text under the engine, and a screen title, screen description or end message that was one token holding an object, an array or a Date rendered String(value) — so no conversion is lossless (ADR-0087 D2) and none is applied. Arithmetic, function calls, the date macros and the run-user paths have no hole spelling: a hole is a path with a formatter, never logic. A flow carrying a single-brace token in a text slot is refused at registration, by objectstack validate and by the node contract; a stored flow carrying one is skipped at boot with a warn naming it." }, { "surface": "flows[].nodes[].config of a notify node (title, message), a screen node (title, description) and an end node (message) — a string, or the source of a template envelope, carrying a double-brace hole whose root is a dollar-named variable the flow engine does not bind, such as {{ $User.Id }}", - "replacement": "a variable the run has, written as a hole. The run user is computed first, with an assignment node whose value slot still reads the run-user path (assignments: { by: '{$User.Id}' }), then written as {{ by }}. A variable the flow binds itself (a declared variable, an assignment target, an outputVariable, a try_catch errorVariable) is named without the dollar sign and written as {{ caught.message }}. The engine's own variables stay holes: {{ $error.message }}, {{ $record.name }}, {{ $runId }}, {{ $flowName }}, {{ $flowLabel }}, and a flat-graph loop's {{ $loopItems }} / {{ $loopIndex }}", + "replacement": "a variable the run has, written as a hole. The run user's id is computed first, with an assignment node whose CEL value envelope reads current_user, the run's user (assignments: { by: { dialect: 'cel', source: 'current_user.id' } }), then written as {{ by }}; every other run-user path never resolved in any shipped run, and an email or a name is read from the user record by current_user.id. A variable the flow binds itself (a declared variable, an assignment target, an outputVariable, a try_catch errorVariable) is named without the dollar sign and written as {{ caught.message }}. The engine's own variables stay holes: {{ $error.message }}, {{ $record.name }}, {{ $runId }}, {{ $flowName }}, {{ $flowLabel }}, and a flat-graph loop's {{ $loopItems }} / {{ $loopIndex }}", "migrationId": "flow-text-slot-unbound-dollar-root-refused", "toMajor": 18, "rationale": "The dollar-named variables are the flow engine's own: it binds $record, $runId, $flowName, $flowLabel and $error, and a flat-graph loop binds $loopItems and $loopIndex. A hole over any other dollar name answers to no variable — {{ $User.Id }} looks like the run user and is not one, since the run user has no hole spelling. In 17.x the slot was a plain string read by the single-brace interpolator, which substituted the inner token and left a literal brace on each side; the 18 text slots render holes through the template engine, where such a hole renders nothing and the run reports success. It is now refused by the node contract, at registration and by objectstack validate, with the remedy its single-brace spelling gets; a stored flow carrying one is skipped at boot with a warn naming it. No D2 conversion exists: what the author meant the hole to read is not in the flow, and the template engine binds no new variable to answer it." @@ -2221,11 +2228,11 @@ "rationale": "ADR-0100: a credential-class value leaves the engine only through a privileged dereference, and every generic channel serves the mask. The record-change trigger built a flow's record and previous from the engine's own write result, which keeps the stored row whole for privileged in-process callers, so a password field's plaintext, a secret field's stored handle and an internal field's value reached the flow — and from there its variables, a paused run's persisted state and that state's read doors. The trigger now projects both roots through the same helper every external write response uses: a credential-class field (secret, and password outside the exempt managedBy buckets) carries the mask, or null when unset, and an internal field is omitted. Every other field keeps its value, every other variable is untouched, and the engine's own write result, the stored row and the privileged read paths are unchanged." }, { - "surface": "flows[].nodes[].config of an assignment node (the assignments map, the legacy assignments array and the legacy bare config) and of create_record and update_record nodes (the fields map) — a string value, or a string anywhere inside an array or object value, carrying a single-brace template token", - "replacement": "a CEL value envelope, { dialect: \"cel\", source: \"…\" }, evaluated to the value: a path is the same path (record.owner; a numeric segment becomes an index, items[0]; a variable whose name starts with $ is read through vars, vars[\"$error\"].message), arithmetic is the same arithmetic with every integer divisor written as a double (round(x * 100) / 100.0), and text with holes is one concatenation ('Hello ' + o.name). A string with no token is the literal text it spells, and braces meant literally are a CEL string literal", + "surface": "flows[].nodes[].config of an assignment node (the assignments map, the legacy assignments array and the legacy bare config) and of create_record and update_record nodes (the fields map) — a string value, or a string anywhere inside an array or object value, carrying a single-brace template token, the run-user paths beginning $User. included", + "replacement": "a CEL value envelope, { dialect: \"cel\", source: \"…\" }, evaluated to the value: a path is the same path (record.owner; a numeric segment becomes an index, items[0]; a variable whose name starts with $ is read through vars, vars[\"$error\"].message), arithmetic is the same arithmetic with every integer divisor written as a double (round(x * 100) / 100.0), and text with holes is one concatenation ('Hello ' + o.name). The run user's id, $User.Id, is current_user.id — current_user is the run's user, or null when the run has none — and in a flow that can run without a user it is current_user != null ? current_user.id : null, which writes null where the template wrote nothing, so on update_record it clears a stored value the template left alone. Every other run-user path ($User.Email, $User.Name, …) never resolved in any shipped run: current_user carries only what the run holds (id, positions, organizationId, isPlatformAdmin), and an email or a name is read from the user record by current_user.id (a get_record node on sys_user). A string with no token is the literal text it spells, and braces meant literally are a CEL string literal", "migrationId": "flow-value-slot-template-dialect-refused", "toMajor": 18, - "rationale": "The interpolator and the CEL engine answer differently for every token spelling authored in flows, so no conversion is lossless (ADR-0087 D2) and none is applied. A path, an absent variable, key or list index wrote nothing under the template and fails the run under CEL; text with a null hole rendered nothing and CEL refuses + null; CEL divides two integers as integers, so round(x * 100) / 100 truncates 123.46 to 123. Where a value may be absent, which of nothing, null or a default the field should take is the author's decision — the template decided it silently. Two spellings are kept with their old meaning, because CEL cannot write them yet: the date macros NOW() and TODAY() with a day offset (CEL yields a Timestamp, not the ISO text, and has no string form for one) and the run-user paths beginning $User. (the flow CEL scope binds no user). A flow carrying a refused value is refused at registration, by objectstack validate and by the executor; a stored flow carrying one is skipped at boot with a warn naming it." + "rationale": "The interpolator and the CEL engine answer differently for every token spelling authored in flows, so no conversion is lossless (ADR-0087 D2) and none is applied. A path, an absent variable, key or list index wrote nothing under the template and fails the run under CEL; text with a null hole rendered nothing and CEL refuses + null; CEL divides two integers as integers, so round(x * 100) / 100 truncates 123.46 to 123. Where a value may be absent, which of nothing, null or a default the field should take is the author's decision — the template decided it silently. The run user's id was the run's userId under the template, and nothing in a run with no user (a schedule, a record change made by a system write); the flow CEL scope binds current_user to the run's user and to null in such a run, never a pseudo-user, so current_user.id fails there and its guarded form writes null. The other run-user paths read a user object no run carries, so they wrote nothing in every run. One spelling is kept with its old meaning, because CEL cannot write it yet: the date macros NOW() and TODAY() with a day offset (CEL yields a Timestamp, not the ISO text, and has no string form for one). A flow carrying a refused value is refused at registration, by objectstack validate and by the executor; a stored flow carrying one is skipped at boot with a warn naming it." }, { "surface": "a create_record, update_record or delete_record flow node whose config.objectName is the string sys_metadata or sys_metadata_history, at any depth including an ADR-0031 region body", @@ -2981,7 +2988,7 @@ "replacement": "another scope, or none for the default (`user` on an upload, `global` on a storage configuration), and `acl: 'public_read'` on the stored file record of each file that must be readable before sign-in (ADR-0104)", "migrationId": "storage-scope-public-retired", "toMajor": 18, - "rationale": "A storage scope never made a file publicly readable. The download doors judge a file by its `acl`, the `attachments` scope and field ownership alone, so a file uploaded with scope public and the default acl was stored private and needs a signed-in caller, while its scope said otherwise. The value is retired rather than enforced: enforcing it would let any uploader make a file anonymous at upload, and ADR-0104 keeps `acl: 'public_read'` the one opt-in for anonymous download. Whether a given file must be readable before sign-in is the caller's call, so no rewrite can make it: an upload that meant public needs its stored file record marked, and one that did not needs only another scope. The upload request itself carries no acl, and every upload is stored private. Files already stored with scope public are not touched and download exactly as before. ADR-0049" + "rationale": "A storage scope never made a file publicly readable. The download doors judge a file by its `acl`, the `attachments` scope and field ownership alone, so a file uploaded with scope public and the default acl was stored private and needs a signed-in caller, while its scope said otherwise. The value is retired rather than enforced: enforcing it would let any uploader make a file anonymous at upload, and ADR-0104 keeps `acl: 'public_read'` the one opt-in for anonymous download. Whether a given file must be readable before sign-in is the caller's call, so no rewrite can make it: an upload that meant public needs its stored file record marked, and one that did not needs only another scope. The upload request itself carries no acl, and every upload is stored private. The stored file record retires the value too: the scope select of sys_file no longer lists public, so a deployment that stored files with it runs the one-time operator sweep that @objectstack/service-storage exports (`planSysFilePublicScopeBackfill` for the dry run, then `applySysFilePublicScopeBackfill`), which rewrites each of those records to scope user. No access changes: no reader tells user apart from public, the storage key and the file bytes are not touched, and those files download exactly as before. Until the sweep has run, a record write that names such a file while another field already owns it is refused, because the copy it makes carries scope public. ADR-0049" }, { "surface": "StrategyContext.executeAggregate aggregations[].method (contracts/analytics-service.ts, exported from @objectstack/spec/contracts) - the parameter type, declared as bare string", @@ -5781,6 +5788,13 @@ "toMajor": 18, "rationale": "Every builtin executor (`service-automation` `builtin/`) parses its node's `config` against the contract `getBuiltinNodeConfigContracts()` names before it acts, and refuses the node on any finding. The build doors judged only the keys that contract requires, left out, so a present value it refuses — `create_record` `outputVariable: 42`, a screen field `min: '1'` (the shape the Studio designer used to store for a field's Min / Max) — passed `FlowSchema.parse`, `objectstack validate` and `objectstack compile`, registered, and then failed every run that reached the node: the config is metadata, and no rerun could succeed. The one judge `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first) and `objectstack validate` share (`flowNodeConfigRefusals`) now refuses such a value as `node-config-refused-by-contract`, anchored at the key, in the contract's own words — the code the approval contract already uses. It judges only what the build can know the run will parse, and holds one more class back by ruling: a value carrying a `{token}` is never refused at the build doors for its pre-interpolation type — which is no promise it runs, since every builtin but `http` parses its config as authored and so still refuses a token in a number or boolean slot at its first run; `http` parses after interpolating its whole config, so only token-free values are judged there and never `signingSecret`, which the credential channel may supply; a `loop` with no `body` is not parsed by its executor and is judged for nothing; the region slots of `loop`, `parallel` and `try_catch` are judged as graphs of their own and by `validateControlFlow`. An undeclared or retired key, a `predicate` ledger slot (a screen field `visibleWhen`) and a `value` ledger slot (a CRUD `fields` value) keep the judges they had. ⚠️ No D2 conversion: the platform cannot know the value the author meant. ⚠️ Where such a node already sits the whole flow is refused: registered from the metadata registry or `sys_metadata` at boot it is skipped with a `warn` naming it, its trigger not armed, while the flows beside it register; a `defineStack({ flows })` source throws `StackSchemaInvalidError` for the whole stack; an artifact file is refused whole at load. ADR-0087, ADR-0031." }, + { + "surface": "flows[].nodes[].config.condition, flows[].edges[].condition, a decision node's conditions[].expression, a screen node's fields[].visibleWhen, and the CEL value envelopes of an assignment node's assignments and of a create_record or update_record node's fields — a CEL source whose root identifier the flow does not bind, such as user.id, ctx.user.id or os.user.id", + "replacement": "current_user for the run's user: user.id, ctx.user.id and os.user.id become current_user.id, and a flow that can run without a user guards it as current_user != null ? current_user.id : null. Any other root becomes a name the flow binds — a declared variable, an outputVariable, an iterator, index or error variable, an assignment target, a node id or a screen field — or a field of the trigger record, read bare or as record.FIELD", + "migrationId": "flow-cel-unbound-root-refused", + "toMajor": 18, + "rationale": "A flow CEL expression evaluates in one scope per run: the flow's variables spread to top level, the trigger record's fields flattened beside them, and record, previous, vars and current_user bound by the engine. A root outside that scope fails the expression with an unknown-variable fault on every run that reaches it, and the build door, which judged a flow expression's syntax only, passed it. The run-user spellings are the reachable case: formulas, row-level security and the client bind the run's user under user, ctx.user and os.user as well, and flow CEL does not, because a top-level user would collide with a variable or a lookup field of that name. objectstack validate and the runtime publish gate now refuse such a root, naming current_user for the user spellings and the in-scope names for any other. A flow whose run can bind a name the reader cannot see is not judged: one with a script or connector_action node, a wait, subflow or map node a resume can fold a bag into, a screen with no field list, a plugin node type, or a trigger object the stack does not declare. No D2 conversion exists: none of these roots ever evaluated in a flow, so there is no behaviour to carry over, and a run without a user needs a guard only the author can choose." + }, { "surface": "a decision node branch — an element of config.conditions[] — written without its expression key, or with expression: null, at any depth including an ADR-0031 region body. That includes a branch whose predicate sits under another key (condition is the edge spelling). Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, defineFlow(), an exported stack passed to objectstack validate, a flow saved from the Studio flow designer with a branch row whose expression cell is empty, and a flow row already sitting in sys_metadata", "replacement": "the predicate the branch was meant to test, as non-blank bare CEL text under `expression` (`{ label: 'high', expression: 'record.amount > 10000' }`); a predicate written under `condition` moves to `expression`. To keep the branch and its label but never take it, write `expression: 'false'` — that is a CHANGE of behaviour, not a preserved one: a run that reached the branch used to fail there (`condition evaluation error`), and now routes on to the next branch or the declared fallback. ⚠️ Not by dropping a decision's only branch: with no `conditions` the node routes by its out-edges alone, so the out-edge that branch labelled is no longer held back", @@ -5832,14 +5846,14 @@ }, { "surface": "flows[].nodes[].config of a notify node (title, message), a screen node (title, description) and an end node (message) — a string, or the source of a template envelope, carrying a single-brace template token", - "replacement": "a double-brace template hole, rendered by the formula template engine over the flow's variables: a variable path with an optional formatter, {{ record.name }}, {{ $error.message }}, {{ rows.0.subject }}, {{ record.amount | currency }}. A token no hole can spell is computed into a variable first, with an assignment node — arithmetic and functions as a CEL value envelope, the date macros and the run-user paths as the value-slot spelling that still reads them — and written as {{ variable }}", + "replacement": "a double-brace template hole, rendered by the formula template engine over the flow's variables: a variable path with an optional formatter, {{ record.name }}, {{ $error.message }}, {{ rows.0.subject }}, {{ record.amount | currency }}. A token no hole can spell is computed into a variable first, with an assignment node — arithmetic and functions as a CEL value envelope, the date macros as the value-slot spelling that still reads them, the run user's id as the CEL value envelope current_user.id — and written as {{ variable }}", "migrationId": "flow-text-slot-single-brace-refused", "toMajor": 18, "rationale": "ADR-0032 Decision 3 fixes one template delimiter, double braces, and deletes the single brace: it collides with CEL map literals, and an author who meets both dialects in one flow mixes them. The 17.x interpolator and the template engine render the same text for a path holding a string, a number, a boolean, null, an absent key or variable, an ISO date string, an object or an array, but not for every value — a Date rendered JSON-quoted under the interpolator and as its ISO text under the engine, and a screen title, screen description or end message that was one token holding an object, an array or a Date rendered String(value) — so no conversion is lossless (ADR-0087 D2) and none is applied. Arithmetic, function calls, the date macros and the run-user paths have no hole spelling: a hole is a path with a formatter, never logic. A flow carrying a single-brace token in a text slot is refused at registration, by objectstack validate and by the node contract; a stored flow carrying one is skipped at boot with a warn naming it." }, { "surface": "flows[].nodes[].config of a notify node (title, message), a screen node (title, description) and an end node (message) — a string, or the source of a template envelope, carrying a double-brace hole whose root is a dollar-named variable the flow engine does not bind, such as {{ $User.Id }}", - "replacement": "a variable the run has, written as a hole. The run user is computed first, with an assignment node whose value slot still reads the run-user path (assignments: { by: '{$User.Id}' }), then written as {{ by }}. A variable the flow binds itself (a declared variable, an assignment target, an outputVariable, a try_catch errorVariable) is named without the dollar sign and written as {{ caught.message }}. The engine's own variables stay holes: {{ $error.message }}, {{ $record.name }}, {{ $runId }}, {{ $flowName }}, {{ $flowLabel }}, and a flat-graph loop's {{ $loopItems }} / {{ $loopIndex }}", + "replacement": "a variable the run has, written as a hole. The run user's id is computed first, with an assignment node whose CEL value envelope reads current_user, the run's user (assignments: { by: { dialect: 'cel', source: 'current_user.id' } }), then written as {{ by }}; every other run-user path never resolved in any shipped run, and an email or a name is read from the user record by current_user.id. A variable the flow binds itself (a declared variable, an assignment target, an outputVariable, a try_catch errorVariable) is named without the dollar sign and written as {{ caught.message }}. The engine's own variables stay holes: {{ $error.message }}, {{ $record.name }}, {{ $runId }}, {{ $flowName }}, {{ $flowLabel }}, and a flat-graph loop's {{ $loopItems }} / {{ $loopIndex }}", "migrationId": "flow-text-slot-unbound-dollar-root-refused", "toMajor": 18, "rationale": "The dollar-named variables are the flow engine's own: it binds $record, $runId, $flowName, $flowLabel and $error, and a flat-graph loop binds $loopItems and $loopIndex. A hole over any other dollar name answers to no variable — {{ $User.Id }} looks like the run user and is not one, since the run user has no hole spelling. In 17.x the slot was a plain string read by the single-brace interpolator, which substituted the inner token and left a literal brace on each side; the 18 text slots render holes through the template engine, where such a hole renders nothing and the run reports success. It is now refused by the node contract, at registration and by objectstack validate, with the remedy its single-brace spelling gets; a stored flow carrying one is skipped at boot with a warn naming it. No D2 conversion exists: what the author meant the hole to read is not in the flow, and the template engine binds no new variable to answer it." @@ -5852,11 +5866,11 @@ "rationale": "ADR-0100: a credential-class value leaves the engine only through a privileged dereference, and every generic channel serves the mask. The record-change trigger built a flow's record and previous from the engine's own write result, which keeps the stored row whole for privileged in-process callers, so a password field's plaintext, a secret field's stored handle and an internal field's value reached the flow — and from there its variables, a paused run's persisted state and that state's read doors. The trigger now projects both roots through the same helper every external write response uses: a credential-class field (secret, and password outside the exempt managedBy buckets) carries the mask, or null when unset, and an internal field is omitted. Every other field keeps its value, every other variable is untouched, and the engine's own write result, the stored row and the privileged read paths are unchanged." }, { - "surface": "flows[].nodes[].config of an assignment node (the assignments map, the legacy assignments array and the legacy bare config) and of create_record and update_record nodes (the fields map) — a string value, or a string anywhere inside an array or object value, carrying a single-brace template token", - "replacement": "a CEL value envelope, { dialect: \"cel\", source: \"…\" }, evaluated to the value: a path is the same path (record.owner; a numeric segment becomes an index, items[0]; a variable whose name starts with $ is read through vars, vars[\"$error\"].message), arithmetic is the same arithmetic with every integer divisor written as a double (round(x * 100) / 100.0), and text with holes is one concatenation ('Hello ' + o.name). A string with no token is the literal text it spells, and braces meant literally are a CEL string literal", + "surface": "flows[].nodes[].config of an assignment node (the assignments map, the legacy assignments array and the legacy bare config) and of create_record and update_record nodes (the fields map) — a string value, or a string anywhere inside an array or object value, carrying a single-brace template token, the run-user paths beginning $User. included", + "replacement": "a CEL value envelope, { dialect: \"cel\", source: \"…\" }, evaluated to the value: a path is the same path (record.owner; a numeric segment becomes an index, items[0]; a variable whose name starts with $ is read through vars, vars[\"$error\"].message), arithmetic is the same arithmetic with every integer divisor written as a double (round(x * 100) / 100.0), and text with holes is one concatenation ('Hello ' + o.name). The run user's id, $User.Id, is current_user.id — current_user is the run's user, or null when the run has none — and in a flow that can run without a user it is current_user != null ? current_user.id : null, which writes null where the template wrote nothing, so on update_record it clears a stored value the template left alone. Every other run-user path ($User.Email, $User.Name, …) never resolved in any shipped run: current_user carries only what the run holds (id, positions, organizationId, isPlatformAdmin), and an email or a name is read from the user record by current_user.id (a get_record node on sys_user). A string with no token is the literal text it spells, and braces meant literally are a CEL string literal", "migrationId": "flow-value-slot-template-dialect-refused", "toMajor": 18, - "rationale": "The interpolator and the CEL engine answer differently for every token spelling authored in flows, so no conversion is lossless (ADR-0087 D2) and none is applied. A path, an absent variable, key or list index wrote nothing under the template and fails the run under CEL; text with a null hole rendered nothing and CEL refuses + null; CEL divides two integers as integers, so round(x * 100) / 100 truncates 123.46 to 123. Where a value may be absent, which of nothing, null or a default the field should take is the author's decision — the template decided it silently. Two spellings are kept with their old meaning, because CEL cannot write them yet: the date macros NOW() and TODAY() with a day offset (CEL yields a Timestamp, not the ISO text, and has no string form for one) and the run-user paths beginning $User. (the flow CEL scope binds no user). A flow carrying a refused value is refused at registration, by objectstack validate and by the executor; a stored flow carrying one is skipped at boot with a warn naming it." + "rationale": "The interpolator and the CEL engine answer differently for every token spelling authored in flows, so no conversion is lossless (ADR-0087 D2) and none is applied. A path, an absent variable, key or list index wrote nothing under the template and fails the run under CEL; text with a null hole rendered nothing and CEL refuses + null; CEL divides two integers as integers, so round(x * 100) / 100 truncates 123.46 to 123. Where a value may be absent, which of nothing, null or a default the field should take is the author's decision — the template decided it silently. The run user's id was the run's userId under the template, and nothing in a run with no user (a schedule, a record change made by a system write); the flow CEL scope binds current_user to the run's user and to null in such a run, never a pseudo-user, so current_user.id fails there and its guarded form writes null. The other run-user paths read a user object no run carries, so they wrote nothing in every run. One spelling is kept with its old meaning, because CEL cannot write it yet: the date macros NOW() and TODAY() with a day offset (CEL yields a Timestamp, not the ISO text, and has no string form for one). A flow carrying a refused value is refused at registration, by objectstack validate and by the executor; a stored flow carrying one is skipped at boot with a warn naming it." }, { "surface": "a create_record, update_record or delete_record flow node whose config.objectName is the string sys_metadata or sys_metadata_history, at any depth including an ADR-0031 region body", @@ -6612,7 +6626,7 @@ "replacement": "another scope, or none for the default (`user` on an upload, `global` on a storage configuration), and `acl: 'public_read'` on the stored file record of each file that must be readable before sign-in (ADR-0104)", "migrationId": "storage-scope-public-retired", "toMajor": 18, - "rationale": "A storage scope never made a file publicly readable. The download doors judge a file by its `acl`, the `attachments` scope and field ownership alone, so a file uploaded with scope public and the default acl was stored private and needs a signed-in caller, while its scope said otherwise. The value is retired rather than enforced: enforcing it would let any uploader make a file anonymous at upload, and ADR-0104 keeps `acl: 'public_read'` the one opt-in for anonymous download. Whether a given file must be readable before sign-in is the caller's call, so no rewrite can make it: an upload that meant public needs its stored file record marked, and one that did not needs only another scope. The upload request itself carries no acl, and every upload is stored private. Files already stored with scope public are not touched and download exactly as before. ADR-0049" + "rationale": "A storage scope never made a file publicly readable. The download doors judge a file by its `acl`, the `attachments` scope and field ownership alone, so a file uploaded with scope public and the default acl was stored private and needs a signed-in caller, while its scope said otherwise. The value is retired rather than enforced: enforcing it would let any uploader make a file anonymous at upload, and ADR-0104 keeps `acl: 'public_read'` the one opt-in for anonymous download. Whether a given file must be readable before sign-in is the caller's call, so no rewrite can make it: an upload that meant public needs its stored file record marked, and one that did not needs only another scope. The upload request itself carries no acl, and every upload is stored private. The stored file record retires the value too: the scope select of sys_file no longer lists public, so a deployment that stored files with it runs the one-time operator sweep that @objectstack/service-storage exports (`planSysFilePublicScopeBackfill` for the dry run, then `applySysFilePublicScopeBackfill`), which rewrites each of those records to scope user. No access changes: no reader tells user apart from public, the storage key and the file bytes are not touched, and those files download exactly as before. Until the sweep has run, a record write that names such a file while another field already owns it is refused, because the copy it makes carries scope public. ADR-0049" }, { "surface": "StrategyContext.executeAggregate aggregations[].method (contracts/analytics-service.ts, exported from @objectstack/spec/contracts) - the parameter type, declared as bare string", diff --git a/packages/spec/src/migrations/entries/semantic/18.flow-cel-unbound-root-refused.ts b/packages/spec/src/migrations/entries/semantic/18.flow-cel-unbound-root-refused.ts new file mode 100644 index 00000000000..500b47ad250 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.flow-cel-unbound-root-refused.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// A flow CEL expression is refused when it reads a root the flow does not bind. +// Formulas, row-level security and the client bind the run's user as user, +// ctx.user and os.user as well as current_user; flow CEL binds current_user only, +// so an alias there failed every run that reached it while objectstack validate +// passed it. Semantic-only — none of these roots ever evaluated in a flow, so no +// D2 conversion has behaviour to carry over. +export const entry: SemanticMigration = { + id: 'flow-cel-unbound-root-refused', + // No backticks in `surface` — build-upgrade-guide renders it inside a code + // span already, and a nested backtick would close it. + surface: + 'flows[].nodes[].config.condition, flows[].edges[].condition, a decision node\'s conditions[].expression, a ' + + 'screen node\'s fields[].visibleWhen, and the CEL value envelopes of an assignment node\'s assignments and of ' + + 'a create_record or update_record node\'s fields — a CEL source whose root identifier the flow does not bind, ' + + 'such as user.id, ctx.user.id or os.user.id', + replacement: + 'current_user for the run\'s user: user.id, ctx.user.id and os.user.id become current_user.id, and a flow that ' + + 'can run without a user guards it as current_user != null ? current_user.id : null. Any other root becomes a ' + + 'name the flow binds — a declared variable, an outputVariable, an iterator, index or error variable, an ' + + 'assignment target, a node id or a screen field — or a field of the trigger record, read bare or as ' + + 'record.FIELD', + reason: + 'A flow CEL expression evaluates in one scope per run: the flow\'s variables spread to top level, the trigger ' + + 'record\'s fields flattened beside them, and record, previous, vars and current_user bound by the engine. A ' + + 'root outside that scope fails the expression with an unknown-variable fault on every run that reaches it, ' + + 'and the build door, which judged a flow expression\'s syntax only, passed it. The run-user spellings are ' + + 'the reachable case: formulas, row-level security and the client bind the run\'s user under user, ctx.user ' + + 'and os.user as well, and flow CEL does not, because a top-level user would collide with a variable or a ' + + 'lookup field of that name. objectstack validate and the runtime publish gate now refuse such a root, naming ' + + 'current_user for the user spellings and the in-scope names for any other. A flow whose run can bind a name ' + + 'the reader cannot see is not judged: one with a script or connector_action node, a wait, subflow or map node ' + + 'a resume can fold a bag into, a screen with no field list, a plugin node type, or a trigger object the stack ' + + 'does not declare. No D2 conversion exists: none of these roots ever evaluated in a flow, so there is no ' + + 'behaviour to carry over, and a run without a user needs a guard only the author can choose.', + acceptanceCriteria: + 'Run objectstack validate: it reports each refused root as expression-invalid at the flow, the node or edge and ' + + 'the slot, naming the root and its remedy. Replace each run-user spelling with current_user, guarded where the ' + + 'flow can run without a user, and bind or correct every other root. Re-run the flow paths that evaluate those ' + + 'expressions and confirm each one evaluates instead of failing with an unknown-variable fault.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index a7e47728488..2b1343e314b 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5683,6 +5683,19 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'value the author meant. Its D3 record is the semantic entry ' + '`flow-builtin-node-config-values-refused`.', }, + { + id: 'flow-cel-unbound-root-refused', + order: 92, + text: + 'It also refuses, at `objectstack validate` and the runtime publish gate, a flow CEL root the flow does not ' + + 'bind — in a trigger gate, an edge, a `decision` branch, a screen field `visibleWhen` or a CEL value ' + + 'envelope. Such a root fails every run that reaches it with an unknown-variable fault, and the build used ' + + 'to pass it. The run-user spellings formulas, row-level security and the client accept (`user`, ' + + '`ctx.user`, `os.user`) are the reachable case: flow CEL binds the run\'s user as `current_user` only, and ' + + 'the refusal names it. A flow whose run can bind a name the reader cannot see is left unjudged, and no D2 ' + + 'conversion exists: none of these roots ever evaluated in a flow. Its D3 record is the semantic entry ' + + '`flow-cel-unbound-root-refused`.', + }, { id: 'flow-decision-edge-branching-first-match', order: 45, @@ -13700,6 +13713,46 @@ const step18: MigrationStep = { + 'number or boolean slot of any other builtin it fails at its first run exactly as it did, so write a ' + 'literal there.', }, + // A flow CEL expression is refused when it reads a root the flow does not bind. + // Formulas, row-level security and the client bind the run's user as user, + // ctx.user and os.user as well as current_user; flow CEL binds current_user only, + // so an alias there failed every run that reached it while objectstack validate + // passed it. Semantic-only — none of these roots ever evaluated in a flow, so no + // D2 conversion has behaviour to carry over. + { + id: 'flow-cel-unbound-root-refused', + // No backticks in `surface` — build-upgrade-guide renders it inside a code + // span already, and a nested backtick would close it. + surface: + 'flows[].nodes[].config.condition, flows[].edges[].condition, a decision node\'s conditions[].expression, a ' + + 'screen node\'s fields[].visibleWhen, and the CEL value envelopes of an assignment node\'s assignments and of ' + + 'a create_record or update_record node\'s fields — a CEL source whose root identifier the flow does not bind, ' + + 'such as user.id, ctx.user.id or os.user.id', + replacement: + 'current_user for the run\'s user: user.id, ctx.user.id and os.user.id become current_user.id, and a flow that ' + + 'can run without a user guards it as current_user != null ? current_user.id : null. Any other root becomes a ' + + 'name the flow binds — a declared variable, an outputVariable, an iterator, index or error variable, an ' + + 'assignment target, a node id or a screen field — or a field of the trigger record, read bare or as ' + + 'record.FIELD', + reason: + 'A flow CEL expression evaluates in one scope per run: the flow\'s variables spread to top level, the trigger ' + + 'record\'s fields flattened beside them, and record, previous, vars and current_user bound by the engine. A ' + + 'root outside that scope fails the expression with an unknown-variable fault on every run that reaches it, ' + + 'and the build door, which judged a flow expression\'s syntax only, passed it. The run-user spellings are ' + + 'the reachable case: formulas, row-level security and the client bind the run\'s user under user, ctx.user ' + + 'and os.user as well, and flow CEL does not, because a top-level user would collide with a variable or a ' + + 'lookup field of that name. objectstack validate and the runtime publish gate now refuse such a root, naming ' + + 'current_user for the user spellings and the in-scope names for any other. A flow whose run can bind a name ' + + 'the reader cannot see is not judged: one with a script or connector_action node, a wait, subflow or map node ' + + 'a resume can fold a bag into, a screen with no field list, a plugin node type, or a trigger object the stack ' + + 'does not declare. No D2 conversion exists: none of these roots ever evaluated in a flow, so there is no ' + + 'behaviour to carry over, and a run without a user needs a guard only the author can choose.', + acceptanceCriteria: + 'Run objectstack validate: it reports each refused root as expression-invalid at the flow, the node or edge and ' + + 'the slot, naming the root and its remedy. Replace each run-user spelling with current_user, guarded where the ' + + 'flow can run without a user, and bind or correct every other root. Re-run the flow paths that evaluate those ' + + 'expressions and confirm each one evaluates instead of failing with an unknown-variable fault.', + }, // The absent half of the decision-branch predicate rule. A SEPARATE entry from // `flow-predicate-slot-blank-string-refused` on purpose: that one keeps the // run a blank predicate made (it evaluated `false`, so `'false'` runs the same From bfeef6a5e85cadccb661413d3fea7795be47178d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 04:27:31 +0000 Subject: [PATCH 4/7] chore(spec): leave the two registry projections to the publish lane The pull-request stage generates spec-changes.json and the upgrade guide in memory and compares no committed copy; a registry-entry PR needs no regeneration commit. Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- docs/protocol-upgrade-guide.md | 19 +++++++---------- packages/spec/spec-changes.json | 38 +++++++++++---------------------- 2 files changed, 20 insertions(+), 37 deletions(-) diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 20c68d99f23..0f23876693b 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -473,7 +473,7 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-conte ## Protocol 17 → 18 -Protocol 18 extends the publish-time refusal of unresolved placeholders, which protocol 17 applied to datasource connection config, to the memory driver's config-material persistence keys: `persistence.path` (file persistence and the `auto` override) and `persistence.key` (localStorage and the `auto` override) refuse `${…}` placeholder syntax at publish. Nothing resolves a placeholder there — the driver would create a literal `./${DATA_DIR}/…` path or write under the literal localStorage key — the same authored-under-a-false-belief shape, one surface over. The memory driver's `initialData` stays deliberately unjudged: it carries arbitrary record values, where a literal `${…}` may be legitimate data. It also retires `MetadataPluginConfig.additionalTypes` (ADR-0049 enforce-or-remove): the key was documented as THE plugin kind-declaration channel and read by nothing — the manager's type registry is seeded once from `DEFAULT_METADATA_TYPE_REGISTRY` and never merged with it, so authoring it configured nothing. A kind enters the live set as a side effect of registering an item of that kind. It also refuses malformed field `scale`/`precision` declarations: both are digit counts, so a non-integer or negative value (`scale: 2.5`, `precision: -1`) has no defined meaning — the write-time `scale` check, which refuses an over-scale value rather than rounding it, deliberately left it unenforced rather than invent floor/round semantics, which made the declaration silently inert. The schema now refuses both at parse (`z.number().int().min(0)`); the mechanical conversion deletes a malformed value from old sources and stored rows (behaviour-preserving), and the semantic entry tells the author to re-declare the count they meant. Finally, it removes the `objects["*"].allowExport` grant from the shipped admin permission sets — `admin_full_access`, `organization_admin` and the derived `organization_admin_no_bypass`. Measured on 17.0.0 GA, that wildcard made the export axis undeniable for an org admin: an application could declare an object exportable by nobody and the platform exported it anyway, with no supported opt-out, because a code-package set cannot be edited (`403 [not_overridable]`) and the admin held no app-authored set in which to write the per-object `false` that would have won. It is the earlier removal of `member_default`'s CRUD wildcard applied to the export axis, which had kept its wildcard by omission rather than by decision. From 18 an admin exports exactly what an app-authored set grants — a posture the same run measured to be already precise. Unlike everything else in this step it changes no schema, so nothing refuses at publish: the upgrade signal is behavioural and belongs here. Finally, it converges `record:chatter` / `record:discussion` `position` on the renderer's vocabulary (maintainer ruling 2026-08-15): the schema declared `sidebar`/`inline`/`drawer` — values no renderer branch ever compared, so the schema's own `sidebar` default silently rendered in flow while the value that actually docks the panel (`right`) was refused at publish. The row now speaks `bottom`/`right`/`left`; the mechanical conversion rewrites the old spellings (`sidebar` → `right`, `inline` → `bottom`, `drawer` → `right`), and the three schema defaults (`position`, `collapsible`, `defaultCollapsed`) are dropped per the `maxVisible` principle — renderer fallbacks stay the renderer's facts. It also retires `targetVariable` on `element:text_input` and `element:record_picker` (ADR-0049 enforce-or-remove): a declarative hint with zero readers in any repo — the live binding runs the other direction, resolved from the page variable whose `source` names the component's `id` (PageVariableSchema) — so an author who wrote only `targetVariable` got an input that wrote nothing, with a success receipt. The mechanical conversion strips the key from old sources (pure lossless delete — it never had an effect to lose); the tombstone's prescription says how to declare the binding that works. Finally, it retires the whole `element:filter` element (ADR-0049 enforce-or-remove at ELEMENT grain — the wider finding that the `targetVariable` retirement recorded and left for its own card): no renderer for the element ever shipped in any repo — objectui registers none, Studio's designer palette lists it as a no-renderer exclusion, and the 2026-06 page-liveness audit recorded it rendering "Unknown component type" — so every one of its six authorable keys was a capability claim nothing kept. All six are retiredKey tombstones; the mechanical conversion strips them from old sources (pure lossless deletes) and leaves the bare node, which the parse then refuses by name — delete the component. List surfaces own their filtering: a view's `userFilters` quick-filter bar / the list toolbar's filter builder. It also retires the whole `element:form` element (ADR-0049 enforce-or-remove at ELEMENT grain — the `element:filter` shape one element over, recorded by that retirement's own verdict sweep): no renderer for the element ever shipped in any repo — objectui registers none, Studio's designer palette lists it as a no-renderer exclusion naming the live replacement, and the 2026-06 page-liveness audit recorded it rendering "Unknown component type" — so every one of its six authorable keys was a capability claim nothing kept. All six are retiredKey tombstones; the mechanical conversion strips them from old sources (pure lossless deletes) and leaves the bare node, which the parse then refuses by name — delete the component. Use the object-bound `object-form` block instead — rendered, designer-publishable, its props declared for the component-props gate, and carrying the same intent (`objectName`, `fields`, `mode`, `submitText`). It also closes the two explicit column lists on relationship fields: `field.inlineColumns` entries are now the strict, name-keyed InlineGridColumnSchema (mirroring the objectui grid renderer's measured reads — objectui aligned the widget to `name` and retired the `field` spelling with no tolerant alias), and `field.relatedListColumns` entries are child field-name strings (the only form the related-list renderer hydrates fully). Both were z.array(z.any()) — a mis-keyed column published clean and rendered as blank cells with the right row count. The mechanical conversion respells inline `{ field }` entries as `{ name }` and folds related-list column objects to their identity string; unknown keys are named rejections at publish from this major. It also retires `measures..filters` on analytics cubes (ADR-0049 enforce-or-remove): a declared per-metric raw-SQL filter with zero consumers — both SQL strategies aggregate the metric's `sql` and never read `filters`, so a hand-authored `filters: [{ sql: "stage = 'closed_won'" }]` parsed, registered, and silently returned the UNFILTERED aggregate under the author's metric name (the same defect the dataset path had, on a hand-authored cube; the dataset half was repaired through its own structured channel when the analytics strategy began compiling each dataset measure's `filter`). The raw-SQL fragment also ran against the platform's structured-FilterCondition direction — it cannot be parameterized, re-targeted per driver dialect, or walked by the lint filter rules. The mechanical conversion strips the key from old sources (pure lossless delete — it never had an effect to lose); filter at query time with `where`, or use an ADR-0021 dataset measure's structured `filter` (a metric's own `sql` is a column reference, see `cube-member-sql-expression-retired`). Finally, it retires the stack `themes` carrier and `ThemeSchema` whole (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-21, disposition B: 退役授权面): the pipeline was live from the authoring gate through artifact ingest and stopped there — zero non-test readers of stored `theme` items, `theme` never a registered metadata type, no first-party app mounting the spec-aware provider, nothing selecting an active theme — so an authored theme shipped through every green gate and changed nothing on screen. `app.branding` stays the one colour surface; objectui's ThemeEngine/ThemeContext and their unit tests are retained. Semantic rather than mechanical: an authored palette has no lossless target (N themes vs M apps is a judgment), so the entry prescribes the hand move instead of deleting authored content silently. It also retires the `record:highlights` highlight-field `icon` (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-21, executing the 2026-08-20 census verdict): a declared key with zero read points in any direction — objectui's renderer normalized the authored object and carried `icon` into a highlight chip with no icon slot, `useRegisterHighlightFields` registers field NAMES only (structurally unable to carry it), and the Studio designer publishes the field list as plain strings — while six author-facing surfaces advertised the key (the shape that got the reference-rail `icon` refused, on the highlight chip). The mechanical conversion strips the key from the object entries of every `record:highlights` `fields[]` (pure lossless delete — the chip renders label and value only, so it never had an effect to lose); there is no replacement, and the live neighbour `readonly`, declared because the chip's read-only gate reads it, is untouched. It also retires the import mapping `lookup` transform's steering params (ADR-0049 enforce-or-remove — the sub-walk half of the 17.0.0 mapping cleanup that retired `extractQuery` / `errorPolicy` / `batchSize`): `fieldMapping[].params.object` / `.fromField` / `.toField` / `.autoCreate` declared a per-entry reference-resolution dialect the import path never implemented — `lookup` copies the cell through and resolution runs off the target field's own metadata — and `autoCreate` read as create-if-missing while an unresolved reference actually fails the row (`import_reference_not_found`), with or without the key. The eleven alias spellings convert to guidance so every spelling lands on the prescription; the mechanical conversion strips the four keys from stored sources (pure lossless deletes — none ever had an effect to lose). Finally, it retires the component-translation copy key `pages..components..submitLabel` and its `submit` alias (ADR-0049; maintainer ruling 2026-08-22): the face is measured, not mirrored — each copy key exists because some component in `ComponentPropsMap` declares it — and `submitLabel`'s only declarer was `element:form`, retired whole above, so the key had no declared component left to translate and the resolver overlay was its only reader. Retire won over re-anchor because the live form surface (`object-form`) speaks `submitText` (`I18nLabelSchema`), localizable at its own authoring site; re-anchoring would have widened the face for one word. The mechanical conversion strips the key from stored bundles and items (pure lossless delete — nothing read it once `element:form` was retired), at the acknowledged cost of dropping the bespoke-component route for that one word. Finally, it retires `page.components[].responsive` and the whole `ResponsiveConfig` layout vocabulary it carried (ADR-0049 D2; maintainer ruling 2026-08-22): the key was the destination the `dashboard.widgets[].responsive` tombstone prescribed as the live alternative, and a two-repo measurement (tsc-probe methodology with positive and negative controls) found the claim false — objectui's two implementations of the contract (`useResponsiveConfig`, `ResponsiveProtocol`) had zero callers and nothing read `.responsive` off a page component, so the prescribed migration moved an inert key to an inert key while the platform's own error message vouched for it. The same change repairs every shipped text that carried that redirect. `ResponsiveConfigSchema`, its two breakpoint maps and the `BreakpointName` enum had no other authorable carrier and leave with the key (RETIRED_DEFS_BY_MAJOR[18]); the live per-breakpoint channel on a page component is `responsiveStyles` (ADR-0065), which objectui really compiles. The mechanical conversion strips the key from stored pages (pure lossless delete — it never had an effect to lose). Finally, it retires nine of the eleven members of the plugin manifest's `contributes` block (ADR-0049 enforce-or-remove; triage graded 2026-08-21, cloud census leg discharged clean 2026-08-24): `events`, `menus`, `themes`, `translations`, `actions`, `drivers`, `fieldTypes`, `functions` and `commands`. A census of all three repos, with controls, measured that the whole monorepo contains exactly one non-test read of `manifest.contributes`, and it reads `kinds`; the other nine members parsed, entered the manifest, and changed nothing, while published docs and the schema's own JSDoc kept teaching them (`commands` documented Commander.js resolution the CLI dropped for oclif; `fieldTypes` advertised a registration seam that never existed). All nine are retiredKey tombstones mirroring `loading`; `kinds` survives (live reader), and `routes` was left to a ruling of its own, which retired it as well (the `plugin-manifest-contributes-routes-retired` entry). D3 semantic, no D2 conversion: a manifest is not a stack collection member, so a conversion would be a transform with no seam that ever runs. On the surviving `kinds` bucket it also retires the `globs` sub-field (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-24): the schema promised that declaring `globs` enables file-type discovery, but discovery globs `filePatterns` off the metadata type registry — which `contributes.kinds` does not extend, as `metadata-plugin.zod.ts` records outright — so an authored `globs` was accepted, stored, served back through `GET /metadata/kind`, and never consulted (zero value reads; the only non-test occurrences were the schema declaration and two type positions). The `kind` bucket itself and its `id` are untouched; file-type discovery stays single-channel on `filePatterns`. D3 semantic `plugin-manifest-kind-globs-retired`, same no-seam reasoning. Finally, it retires `object-grid`'s `defaultSort` (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-25, decision-inbox batch 4 — the producer half of objectui's `table.defaultSort` retirement, which the maintainer's 2026-08-22 「接受所有」 ruling on objectui's sort sink ordered): the legacy second spelling of `sort`, a single `{ field, order }` pair the renderer read only when `sort` was absent (measured at the `.objectui-sha` pin `190fbd01d`, `plugin-grid/src/ObjectGrid.tsx:1244-1246` and `:2847`, which wraps it `[schema.defaultSort]` — the exact array shape `sort` carries). One intent, two spellings; objectui's mirror schema is parity-test-only and parses nothing at runtime, so only the spec strictObject can refuse the key. The mechanical conversion carries the pair over — renamed to `sort` and wrapped in the array shape — when `sort` is absent, and strips it as a pure lossless delete when `sort` is present (the renderer's own precedence made it unread then). Finally, it retires the object-permission lifecycle bits `allowRestore` and `allowPurge` (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-26, decision-inbox batch 5, which chose retiring the two bits over gating operations that do not exist): the `restore` / `purge` ObjectQL operations the bits claimed to gate have never existed — no destructive lifecycle verb is in the engine's dispatch vocabulary, which a test pins — so granting the bits delivered nothing, and an author who declared `allowPurge: false` believed a lock on GDPR hard-deletion existed when the operation itself did not. Both keys are retiredKey tombstones; the evaluator's pre-mapping rows retired in the same batch (a dispatched `restore`/`purge` stays denied fail-closed via the DESTRUCTIVE_OPERATIONS backstop, so there is no ungated window), and the mechanical conversion strips the keys from every object grant in `permissions[].objects` (pure lossless delete — they never had an effect to lose). `allowTransfer` is ENFORCED — the server guards who may rewrite a record's owner — and stays. The keys return with the M2 lifecycle initiative (feature + RBAC in one batch), which stays open as their anchor. Finally, it narrows the per-option `default` key OUT of the form-view options vocabulary (ADR-0049 declared-but-unenforced; maintainer ruling 2026-08-28 on the console form renderer's analysis, disposition 甲): `SelectOptionSchema` serves two surfaces and only the OBJECT-field face reads `default` (enforced there by a maintainer ruling of 2026-08-10 — `applyFieldDefaults` falls back to the option marked `default: true`; that face, its alias rows and its precedence pin are untouched). On a form-view field's option list the key parsed clean and nothing read it — the insert-path fallback consults the object definition's options, never a form view's, and no form renderer seeds a value from it (measured against the console's form controls, none of which reads the key; the ruled census found ZERO authored occurrences across the tree, the example apps and the published *.form.ts corpus). The FormView vocabulary's own option shape (`FormSelectOptionSchema`, ui/view.zod.ts) now refuses the key with the prescription; the mechanical conversion strips it from stored sources (pure lossless delete — it never had an effect on this surface to lose). It also retires the paper metadata-customization protocol whole (ADR-0049 enforce-or-remove, maintainer ruling 2026-08-29): `kernel/metadata-customization.zod.ts` — the three-layer platform/user patch-overlay model with field-level change tracking and a 3-way-merge story — was exported, documented as the customization architecture, and implemented ONLY by an unreachable `packages/metadata` limb (no route served the paper `…/overlay`/`…/effective` endpoints; the four optional service members were called only by their own unit tests). ADR-0126 §6 wall 4 supersedes it on the record ("nothing may build against it"). The module's seven defs and the three section-5 API contracts leave via RETIRED_DEFS_BY_MAJOR; the authorable carriers `MetadataPluginConfig.customizationPolicies` / `.mergeStrategy` and `MetadataManagerConfig.persistence.overlayWritable` are retiredKey tombstones (no D2 conversion — plugin/manager configs are not stack collection members, the additionalTypes reasoning). The customization that actually ships: ADR-0005's org overlay and ADR-0126's packaged-metadata model. Finally, it canonicalizes the legacy objectql field-key dialect `reference_to` → `reference` on lookup/master_detail fields (the server half of the maintainer's 2026-08-31 ruling that the server normalizes the protocol and the renderer only executes it). `FieldSchema` has always refused `reference_to` by name, but stored `sys_metadata` rows written by seams that bypass the parse still carry it, held up today only by objectui's `reference ?? reference_to` fallback arms — which the ruling's objectui half deletes. The mechanical conversion renames the key (the house precedence for a shadowed alias: a canonical `reference` wins, a disagreeing pair is kept for the author), replays on every stored-row rehydration so the serve face only ever emits the canonical spelling, and `os migrate meta` rewrites old sources; the authoring-surface rejection with its rename prescription is unchanged. It also retires `connector.errorMapping` (ADR-0049 enforce-or-remove; triage ruling 2026-09-02): `ErrorMappingConfig` (4 keys) and its `ErrorMappingRule[]` (7 keys) were authorable through `ConnectorSchema` — and, via `DeclarativeConnectorEntrySchema`, through `stack.connectors[]` and the `/meta/connector` door — and read by nothing: no provider, dispatcher or materializer ever mapped an external error through the rules, so `unmappedBehavior` configured nothing and a rule's `userMessage` was never shown to anyone. That spelling is the live API-error channel's (`ApiError.userMessage`), so an author who wrote a rule here reasonably believed they were marking a refusal for an end user; the failure was silent in both directions. The carrier key is a retiredKey tombstone on the non-strict `ConnectorSchema` (a bare deletion would be a silent strip), the three defs — `integration/ErrorMappingConfig`, `integration/ErrorMappingRule` and the orphaned `integration/ConnectorErrorCategory` enum — leave via RETIRED_DEFS_BY_MAJOR, and the mechanical conversion strips the block from `connectors[]` (pure lossless delete; it never had an effect to lose). It also retires the fourteen hour/minute/day-shaped deadline keys of the incident-response, training and change-management families (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-02): six on the incident-response schemas, five on the training schemas and three nested in the change-management schemas, every one on the published surface and read by nothing — the schemas are mounted by no stack key and registered as no metadata type — so a compliance author who wrote `triageDeadlineHours: 4` held a deadline the platform never kept. All fourteen are retiredKey tombstones (the schemas are not strict; a bare deletion would be a silent strip) with no D2 conversion, for the additionalTypes reason: none of these schemas is a stack collection member, so the chain has no seam. It then retires those three compliance-shaped families WHOLE (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-05, ruled A, not roadmapped): the nineteen defs of `system/incident-response.zod.ts`, `system/training.zod.ts` and `system/change-management.zod.ts` — roughly a hundred declared keys, exported from `@objectstack/spec/system`, mounted by no stack key, registered as no metadata type, absent from the liveness ledgers, read by nothing repo-wide (examples, skills and objectui at the pinned sha included) — leave via RETIRED_DEFS_BY_MAJOR with one D3 semantic entry per family; the fourteen deadline-key tombstones leave with their defs' source and their RETIRED_KEYS_BY_MAJOR[18] entries stay as history. Boolean capability claims such as `notifyRegulators`, `requirePostIncidentReview`, `trackCompletion` and `approval.required` were the sharpest declared-≠-enforced shape left: an author writing `notifyRegulators: true` held a compliance promise the platform never kept. And it resolves the branch the deadline-key ruling held open — no roadmapped e-signature consumer — so `ESignatureConfig.expirationDays` / `reminderDays` (`data/document.zod.ts`, defaults 30 / 7 days, read by nothing) are retiredKey tombstones with no D2 conversion (`document` is no stack collection member), registered in RETIRED_KEYS_BY_MAJOR[18] with one D3 semantic entry. Finally, it moves the unit of every duration-shaped `z.number()` key whose unit lived only in its description into the key name (maintainer ruling 2026-09-02, no grandfathered baseline): `hook.timeout` and `job.timeout` become `timeoutMs` (mechanical rename, retired from the load path), and the five keys with no stack seam — `MetadataManagerConfig.cache.ttl` / `cache.databaseLoader.ttl` (seconds and milliseconds fourteen lines apart under one name), `DriverOptions.timeout`, and the tenant `connectionPool.idleTimeout` / `accessControl.sessionTimeout` whose unit the reference pages never published — are retiredKey tombstones with a semantic entry each, naming the suffixed key. The `data`, `ui`, `ai` and `integration` remainder closes the same sweep: `dashboard.refreshInterval` → `refreshIntervalSeconds`, the connector pair `health.circuitBreaker.monitoringWindow` → `monitoringWindowMs` and `triggers[].interval` → `intervalSeconds` (both halves later absorbed by the removal of the block each key lived in — see the connector retirements below), and the two datasource config keys `memory config.persistence.autoSaveInterval` → `autoSaveIntervalMs` (BOTH union arms — the `auto` arm forwards the same value to the same file adapter, so splitting them would have left one value with two spellings) and `turso config.timeout` → `timeoutMs` all convert, because a dashboard, a connector and a datasource are stack collection members stored as rows; the two with no seam — `ConversationAnalytics.duration`, computed at runtime and never authored, and `NoSQLQueryOptions.timeout`, a per-call driver argument — are retiredKey tombstones with a semantic entry each. That remainder is what takes `check:duration-unit-keys` to zero offenders over `packages/spec/src/**`; the gate goes red again by design when its declared population widens beyond that subtree. It also retires the three outer keys of `MetadataManagerConfig.cache` — `enabled`, `ttlSeconds` (the duration rename's respelling of `ttl`, never shipped) and `maxSize` — that the rename above surfaced (ADR-0049 enforce-or-remove): declared, defaulted and published, read by nothing — `MetadataManager` hands only `cache.databaseLoader` to the loader — so `cache: { enabled: false }` switched nothing off. All three are retiredKey tombstones registered in RETIRED_KEYS_BY_MAJOR[18] with one D3 semantic entry and no D2 conversion (a manager config is no stack collection member); the rename is folded into the removal, so `cache.ttl` now prescribes deletion rather than a hop to a retired key. It also retires the seven cron-typed positions nothing evaluated (ADR-0049; the 2026-09-06 ruling retired each family rather than marking it experimental): the two export-schedule crons, `ScheduleState.cronExpression`, `DataSyncConfig.schedule`, `CacheWarmup.schedule` and the two disaster-recovery crons were parsed into the cron envelope and read by nothing (the D7 ledger row `cron-declared-unwired`). All seven are DELETED OUTRIGHT — no retiredKey tombstone, no RETIRED_KEYS_BY_MAJOR[18] entry, no D2 conversion and no D3 semantic entry — so this step replays nothing for them and `migrate meta` lists no edit: the keys simply stop existing. That the chain is silent does NOT make the deletion silent to an author: the PARSE strips (no schema here is `.strict()`), but above it `lintUnknownAuthoringKeys` names the dropped key for the one position a stack manifest reaches — `os validate` and `os build` both print `connectors..syncConfig.schedule: 'schedule' is not a declared connector key, so its value is dropped at load.`, and `os validate --strict` EXITS 1 on that warning. The other six positions are unreachable from a manifest, so for those the parse-level strip is the whole of it. That is the maintainer ruling of 2026-09-10 on the retirement PR, taken over the seat recommendation to keep the connector D2, on the reading that customers do not upgrade major by major in order. It also retires the `type: 'page'` LIST-VIEW mount and its `pageName` binding (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-09 「撤」). The member was added so a view could render nothing of its own and delegate to an already-published page, but only the spec half landed: no renderer ever routed it — objectui's list-view switch shares its default arm with `grid` — so a page view drew an empty table where the page belonged, and the three parse refusals policing the binding policed a mount that never mounted anything. The enum VALUE carries its prescription on the `type` enum's own error map (an enum-value narrowing has no tombstone to hang one on, the `exportOptions` 'pdf' precedent); `pageName` is a retiredKey tombstone on both list-view doors. The D2 conversion STRIPS both keys rather than rewriting `type` to `'grid'`: `type` defaults to `grid` in the schema, so deleting it lands the row on exactly what it already rendered without this registry guessing a view type. The surviving page mount is the app navigation item (`PageNavItem.pageName`), untouched. It also retires `object-kanban`'s `quickAdd` (ADR-0049 enforce-or-remove; the spec half of the director-seat ruling of 2026-09-08 that the board grows no inline record-creation path and retires the key). The board FORWARDED the key into the shared renderer but the affordance is gated on both `quickAdd` and `onQuickAdd`, and `onQuickAdd` is a host-supplied FUNCTION JSON cannot carry and no producer puts on an `object-kanban` node — so the gate was permanently false. The drop was NOT silent, and that is what made it worse than silence: objectui's html tier reported the published key as `unknown-prop`, the same diagnostic a typo gets, so an author following the contract met a tool contradicting it with no way to tell which side was wrong. A retiredKey tombstone on `ObjectKanbanPropsSchema` with one D2 conversion that is a pure lossless DELETE (the key never had an effect to preserve) scoped by component `type`. Delete the key; `object-kanban` offers no quick-add control. It also retires the bare STRING `sort` clause on the list-view doors (ruled 2026-09-07: the legacy string clause is retired, one spelling, the array). This is the PRODUCER half of the seam whose consumer half shipped in objectui first: `convertSortToQueryParams` now refuses a runtime string, so `ListViewSchema.sort` was minting documents its own consumer rejects — a document that validated upstream failed downstream, and the author was told off by the wrong layer. Like the `type` value above it is a VALUE narrowing with no tombstone to hang a prescription on, so the surviving array member's own error map carries it, keyed on `issue.input` being a string. The D2 conversion REWRITES rather than strips, because the clause is losslessly mechanical: `'created_at desc'` is the tuple `{ field, order }`, a bare field name meant ascending and is written out as `order: 'asc'`, and the comma-separated multi-key form becomes one entry per key in the same order. A string that does not parse as that grammar — the `'-field'` dialect above all — is left alone and meets the door instead: that dialect belongs to `RecordRelatedListProps.sort`, never reaches `convertSortToQueryParams`, and retiring it was NOT ruled. It also removes `page.assignedProfiles` (ADR-0090 D2 / ADR-0049 enforce-or-remove; maintainer ruling 2026-09-12 「同意」). The key was authorable on the published `PageSchema` and named for the Profile concept ADR-0090 D2 deleted, while the schema's own alias table CORRECTED an authored `profiles:` into it — two files from `security/permission.zod.ts` answering the same word with "no Profile concept". Measured across this repository and objectui it had zero readers, so a page that "assigned profiles" was open to every caller who could reach it. It is a retiredKey tombstone on `PageSchema` — the def is still parsed from the `page` root, so there is an author to teach — and the two alias entries became refusals naming the permission-set route. The D2 conversion STRIPS the key — there is no lossless target, because which permission set a given profile name corresponds to is a judgement no walker can make, which is what the paired D3 semantic entry is for. Finally, it removes `aria` from the chart config (ADR-0049 enforce-or-remove; maintainer decision of 2026-09-12 — judge the protocol wrong for this one key). It is the last member of the `aria` family retired for the same measured reason as `dashboard.aria` and `dashboard.widgets[].aria` before it: an ARIA block an author can declare and nothing lowers to the DOM. It survived those two sweeps by depth — it sits inside the widget’s `chartConfig` bag, which no drill had reached until the per-key pass recorded in `liveness/dashboard.json`. That pass found `aria` to be the one `ChartConfigSchema` key with no reader on EITHER face: the chart implementation declares no `aria` prop, the presentation lowering names it nowhere, and the react block omits it from ``’s `dataProps`. Remove rather than enforce, because the same chart config already carries a WORKING accessible-name channel in `description` (lowered as `role="img"` + `aria-label`), and giving `aria` a reader would put two accessible-name sources on one element behind a precedence rule nobody has written — one node, one accessibility vocabulary. The tombstone rides `ChartConfigSchema` and therefore copies into `ReportChartSchema`, so the key is registered twice; the D2 conversion STRIPS it from all three authored sites (`dashboards[].widgets[].chartConfig`, `reports[].chart`, `reports[].blocks[].chart`) as a pure lossless delete — it never had an effect to lose. The two alias spellings that pointed at it, `accessibility` and `ariaProps`, became refusals carrying the same prescription rather than renames onto a tombstone. It also states, and enforces, who owns a dataset-bound chart's STRUCTURE (ADR-0021; maintainer ruling 2026-09-12): the dataset decides which series exist and which column each one reads, `chartConfig` carries appearance, and `dashboard.widgets[].chartConfig`'s `type`, `xAxis`, `yAxis` and `series` are refused by name on that carrier — the widget's own `type` is the chart family and `dimensions`/`values` are the selection. An authored `yAxis[].field` was a live membership channel: the renderer synthesised a series from it when the chart declared none, so one authored axis could silently re-point a dataset-bound series at another column and the chart still drew. The D2 conversion strips the four keys from dashboard widgets only — `ReportChartSchema` and the inline-data react `` tier keep their own axes — and the paired semantic entry carries what the stripped keys were saying, because an authored axis field may name a column the widget never selected and no walker can move that intent into the dataset. Finally, it splits the translation bundle type in two (maintainer ruling 2026-09-13: settings copy belongs to the platform): the platform bundle keeps all eleven groups and the per-app bundle (`stack.translations`, `defineTranslationBundle`) no longer declares `settings`, which is keyed by `SettingsManifest.namespace` and only platform code declares a manifest. Both bundles load into ONE served tree, so an app-authored `settings` branch did not sit inert — but nor did it override the platform: the app’s bundles arrive in `AppPlugin`’s `start()` (Phase 2) and the platform’s at `kernel:ready` (Phase 3), and `deepMerge` gives the later source the leaf, so what an application had was a GAP FILLER on a namespace it does not own — rendering only where the platform bundle carried no string for that key and locale. The registered `translation` ITEM follows the file door (maintainer ruling 2026-09-22: one app metadata type, two authoring doors, one accepted shape) and no longer declares `settings` either; there the group had been STRONGER, because the runtime-authored layer is read over the shipped bundles, so a stored item overrode the platform’s own copy. The D2 conversion strips the group from per-app bundle entries and from bare items alike — the runtime translation sync replays it over every stored row before merging — and the paired semantic entry says what the strip means at each door, because a notice reading "(removed)" says neither that an item’s overrides give way to the platform’s string nor that a gap falls back to the manifest's own English literal. Finally it retires object `tenancy.organizationField` (ADR-0049 enforce-or-remove). The key named the column a PLATFORM ROW is stamped from, as opposed to the column the object is WALLED by (`tenantField`); on an ordinary object those are the same column, and the entire protocol declared it exactly once — on `sys_api_key`, a better-auth-managed credential table this platform ships and no application authors. Its three readers were all platform-row writers, scope-pinned by name, so an application declaration was inert by construction while still forcing every future piece of organization logic to ask "what if somebody set this?". The divergence is NOT retired, only its authorability: it moves to `PLATFORM_STAMP_ORGANIZATION_COLUMNS` in `@objectstack/metadata-core`, keyed by object name and read by the stamp face alone, so audit stamping, the approval-row writer and the automation-run recorder keep their behaviour with no authorable input. The conversion is a lossless delete, and a lossless delete still leaves the author a judgment, which the family's D3 entry `object-tenancy-organization-field-retired` carries — an application whose tenant column genuinely is not `organization_id` declares `tenancy.tenantField`, which both walls the object and stamps its platform rows. It also retires `connector.connectionTimeoutMs` (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-22, letter A — the narrower SECOND decision the key was owed after the ruling that made its nine ledger siblings live deliberately left this one dead). Bounded, defaulted, `.describe()`d and served back by `/meta/connector`, so an author had every signal it worked — and no site ever applied it as a deadline. This retirement is NOT the zero-mention shape: five sites outside `packages/spec` read the key (the materialization fingerprint and the provider-context build in the automation service, `ctx.connectionTimeoutMs` in the `rest` and `openapi` provider factories, and the `?? 30000` fallbacks that put it back on the reported def), but every one is a pass-through whose only termini are the def `GET /connectors` echoes and the fingerprint that decides whether to re-materialize. The one mapping from authored policy onto the platform's outbound `fetch` was handed `retryConfig` and `requestTimeoutMs` only, so the key was carried and never honoured — the same parsed-unmarked-unenforced state ADR-0049 forbids, wearing a longer route. Nor was the `实现` arm available: a WHATWG `fetch` exposes one `AbortSignal` over the whole operation and never the connect phase, so bounding time-to-response with it would kill a slow-but-connected upstream the author meant to allow with a large `requestTimeoutMs`. `requestTimeoutMs` is the replacement and the bound the platform can keep. The carrier key is a retiredKey tombstone on the non-strict `ConnectorSchema` (a bare deletion would be a silent strip), registered under both def keys because `DeclarativeConnectorEntrySchema` carries it too, both carriers wrapping the same private `ConnectorBaseSchema`; the D2 conversion strips it from `connectors[]` as a pure lossless delete — it never had an effect to lose — because a stored connector row CAN carry it (the `PUT /meta/connector/:name` door persists the authored value and the stored-row rehydration seam is live for this type, both measured); and the withdrawn `ConnectorProviderContext` member, which is code and has no authored source to rewrite, leaves via the paired semantic entry instead. Finally it gives the one-filter-orthography convergence (ruled 2026-08-25: one filter spelling platform-wide, the rule array) its mechanical half at rest (ruled 2026-09-12): the D2 conversion `page-component-filter-record-to-rule-array` rewrites a record-form or single-level AST `filter` at the converged rule-array doors — `dataSource.filter`, the `object-*` / `element:number` / `element:record_picker` `filter` props and `object-grid.defaultFilters` — to the rule array wherever the mapping is lossless, and leaves a filter carrying `$and` / `$or` / `$not` (or any part with no lossless rule spelling) exactly as stored, because flattening a combinator changes which rows a page selects. It is retired from the load path, so authors are still refused at the door and taught the array; the stored-row seams and this chain replay it. It also retires the view item's `owner` and `hidden` (ADR-0049 enforce-or-remove). Both sat on the view-item identity layer, were accepted by the strict authoring door and by the wire member the `view` write door validates, and were stored verbatim — and nothing read either: both switcher read paths filter on `viewKind` + `object` and sort on `order`, so `hidden: true` hid nothing, and no per-user scope ever read `owner`, so a view marked as one user's was listed for everyone who can read the object. Per-user view scoping is a parked direction (ADR-0017, amended 2026-09-04), not a shipped mechanism. Both keys are `retiredKey()` tombstones on the SHARED shape, because that shape also feeds the `.strip()` wire member, where a bare deletion would be a silent strip. The D2 conversion `view-item-owner-hidden-removed` strips them from the view item RECORD spelling only, as a lossless delete, in both collections a record travels in — `views` (stack sources and stored rows) and the assembled-manifest `viewItems` channel (package export, environment artifacts), whose registration parse would otherwise refuse an artifact assembled before this release. It also retires a `joined` report's `chart` at both coordinates (ADR-0049 enforce-or-remove): the joined renderer draws each block as a table and returns before the one container `chart` read, and no renderer reads a block's `chart` at all, so a chart on a joined report parsed, passed the chart-bindings lint, and plotted nothing. The key leaves `JoinedReportBlockSchema`'s closed shape (its `guidance` table carries the prescription) and the joined arm of `ReportSchema`'s refinement refuses a container `chart`; `chart` stays live on every non-joined report. The D2 conversion `report-joined-chart-removed` strips both as a pure lossless delete — neither ever had an effect to lose — because a stored report row CAN carry them (the Studio report form offered a block `chart` input until this change); it is retired from the load path, so authors are refused at parse rather than rewritten. It retires the view item's `owner` / `hidden` pair on the flattened overlay door too (ADR-0049; the view item's disposition for the same key pair, followed here as triage directed): the lean personalization PUT with no `config` declared its own `owner` / `hidden`, accepted and stored them, and nothing read either. Both are `retiredKey()` tombstones on the two overlay members with the view item's own prescription texts, and the D2 conversion `view-overlay-owner-hidden-removed` strips them from the flattened spelling (no `config`, no container slot) in `views` and `viewItems`, so a stored overlay row is served without them. A row that held other view keys is then valid again and re-saves; a row that held nothing but its identity and the two keys is left identity-only, which the door refuses, so it is badged invalid, refused on a whole-row re-save and reported `failed` by `os migrate meta --stored --apply` until it is deleted or given the setting its author meant. Its D3 record is the semantic entry `view-overlay-owner-hidden-retired`. It also narrows form `layout` to `vertical` | `horizontal` on both surfaces that declared the four-arm enum — the `object-form` page component and the form view (ADR-0049 enforce-or-remove). No renderer ever gave `inline` or `grid` a behaviour of its own: every form presentation folded both to `vertical`, multi-column is `columns` (honoured under either layout), and `inline` is a toolbar / filter-row pattern rather than a record-form layout — redundant vocabulary under the maintainer's family criterion (a capability mainstream platforms have is served once, here by `columns`), retired with no alias window. Both enums refuse the two values with a per-value prescription naming `columns`; the D2 conversion `form-layout-inline-grid-to-vertical` rewrites them to `vertical` (behaviour-preserving, `columns` untouched) on `object-form` page components, on every form payload a view carries, and on the assembled-manifest `viewItems` channel. It also removes `currencyConfig.precision` (ADR-0049 enforce-or-remove): declared and validated against ISO 4217, read by no renderer or runtime — a currency amount's decimal places are its currency's ISO 4217 minor unit, derived from the currency itself. The D2 conversion `currency-config-precision-removed` strips it from every field's `currencyConfig` as a pure lossless delete, which matters most at rest: the schema used to bake `precision: 2` into parse output, so stored object rows and built artifacts carry it without anyone having written it. Retired from the load path; an authored key is refused with the prescription. It also retires the RLS policy's `tags` (ADR-0049 enforce-or-remove; graded RETIRE by the maintainer's criterion — no mainstream platform tags a row-level policy): the key promised categorization and reporting for governance and compliance, and nothing ever read it — the RLS compiler never consulted it and no preview rendered it. It is a `retiredKey()` tombstone on `RowLevelSecurityPolicySchema` (the `priority` posture one key over), and the D2 conversion `permission-rls-tags-removed` strips it from every policy in `permissions[].rowLevelSecurity` as a lossless delete, so a stored permission row that still carries it replays clean. It is retired from the load path, so authors are refused at parse rather than rewritten. Its D3 record is the semantic entry `permission-rls-tags-retired`. Finally, it removes `aria` from the action (ADR-0049 enforce-or-remove), the fourth member of the `aria` family after `dashboard.aria`, `dashboard.widgets[].aria` and the chart config's, and retired for the same measured reason: an ARIA block an author can declare and nothing lowers to the DOM. The liveness ledger had graded it `live` on an uncited "partial" note with no reader behind it; at the pinned renderer, none of the surfaces that render an action — button, icon, menu, group and bar, the row and bulk action menus, the record quick-actions toolbar — reads it. Remove rather than enforce, because every one of them already takes the accessible name from the action's required `label` (visible text, or `aria-label` on an icon-only action), and the node that places the actions carries the node-level `aria` block — a per-action block would be a second spelling of both. The D2 conversion `action-aria-removed` STRIPS the key from stack actions and object-nested actions as a pure lossless delete, retired from the load path so authors are refused at parse; its D3 record is the semantic entry `action-aria-retired`. It also retires the connector resilience family (ADR-0049 enforce-or-remove, one batch): `connector.health` — the `healthCheck` probe (eight keys) and the `circuitBreaker` (six) — `connector.status` and the connector-nested `webhooks`, sixteen authorable keys with no reader outside the spec package. No loop ever polled a connector endpoint or tripped a breaker; nothing read an authored `status` (the runtime publishes a computed `state`, and participation is `enabled`); and a webhook nested in a connector was never registered as a `webhook` item, so it was never materialized or delivered — the top-level `webhooks:` collection is the delivered one. The three carrier keys are retiredKey tombstones on `ConnectorBaseSchema`, registered under both carrier defs; `status`, defaulted `'inactive'`, joins `connectionTimeoutMs` in the retired-default residue stage, because every 17.x parse emitted it into every connector. Seven defs leave whole — `ConnectorHealth`, `HealthCheckConfig`, `CircuitBreakerConfig`, `ConnectorStatus`, `WebhookConfig`, `WebhookEvent`, `WebhookSignatureAlgorithm` — and the D2 conversion `connector-resilience-keys-removed` strips the three keys from `connectors[]` and stored rows as a pure lossless delete (the nested webhooks are stripped, never moved: moving them would start deliveries that never happened). It ABSORBS the breaker half of the duration rename above: `health.circuitBreaker.monitoringWindow` → `monitoringWindowMs` is no longer converted, because the whole block it lived in is now removed. Finally it makes edge-branched `decision` nodes EXCLUSIVE (maintainer ruling 2026-09-23, 「跟主流对齐」): the first conditioned out-edge that holds, in declaration order, is the branch, and taking every true branch is the declared `mode: 'inclusive'`. The D2 conversion `flow-decision-mode-inclusive-explicit` writes that key onto every decision with two or more conditioned out-edges and no `conditions` list, so a flow written while every true branch ran keeps its behaviour; it is a default flip, so it is retired from the load path AND refused by the flow rehydration seam and the artifact-ingestion door, and replays only here — the paired semantic entry carries the judgment the diff then asks for. BREAKING for flows stored in `sys_metadata`, by maintainer ruling: such a decision with no `mode` takes the first-match meaning on upgrade and nothing rewrites it; `os migrate meta --stored` lists each one for review, and `mode: 'inclusive'` is the one-line fix where a node meant every branch. It also retires the list view's own `tabs` (ADR-0049 enforce-or-remove). The key parsed and was stored at every list-view door and drew nothing: a list view's own `tabs` has no reader, the one component that would draw it has no production mount, and the tab strip above an object's records is the saved-view switcher, which renders one tab per `listViews` entry and reads no `tabs` key (`userFilters.tabs`, a different key of the same element type, is read and rendered, and stays). The key is a `retiredKey()` tombstone on the list-view shape (its prescription says how to move each tab to a named `listViews` entry); `ViewTabSchema` itself stays, because the page-only `userFilters.tabs` preset bar reuses it and renders. The D2 conversion `view-list-tabs-removed` strips the key from every list payload in `stack.views[]` as a lossless delete, and is retired from the load path, so authors are refused at parse rather than rewritten. It retires the inner `name` on cube members — `measures..name` and `dimensions..name` (ADR-0049 enforce-or-remove) — by the mainstream criterion: Cube.dev and LookML key a member by its declared name, with no second inner name that can disagree. Both member bags are records, and every consumer already resolved a member by its record KEY, publishing and querying it as `.`; the REQUIRED inner copy was read by nothing, and one that disagreed with its key was silently ignored. The keys are retiredKey tombstones on `MetricSchema` and `DimensionSchema`, and because the key was required, every stored or built cube carries it: the D2 conversion `cube-member-inner-name-removed` strips it from every member of every cube, retired from the load path, and its notice prints a disagreeing value beside the key that stays. Its D3 record is the semantic entry `cube-member-inner-name-retired`, which asks the author of a disagreeing name which spelling they meant. It also retires the connector `triggers` array (ADR-0049 enforce-or-remove; ADR-0041 keeps connector-event triggers in its third tier, as their own trigger package): the `ConnectorTrigger` shape — `key`, `label`, `description`, `type` (`polling` / `webhook`) and `intervalSeconds` — was read by nothing. The automation engine registered a connector's actions only, its trigger registry holds FLOW trigger kinds that no connector trigger ever entered, no polling loop read an interval and no receiver was driven by a `webhook` trigger, so a declared trigger never started a flow. `triggers` is a retiredKey tombstone on `ConnectorBaseSchema`, registered under both carrier defs; the provider-bound refusal of the key, whose reason (the provider derives triggers) was untrue, is gone with it, since the tombstone refuses every value on every carrier. `ConnectorTrigger` leaves whole, and the D2 conversion `connector-triggers-removed` strips the array from `connectors[]` and stored rows as a pure lossless delete — never turning a trigger into a flow, which is the author's decision (an `api` flow for an external event, a `schedule` flow for a scheduled pull, each calling the connector's action). It ABSORBS the trigger half of the connector duration rename (its breaker half went with `health` above), so `connector-health-and-trigger-durations-unit-in-key`, with neither half left, is no longer in this step. It also retires a cube's `refreshKey` whole — the refresh cadence `every` and the data-change probe `sql` (ADR-0049 enforce-or-remove). Nothing read either key, and no analytics result is cached, so a declared cadence refreshed nothing and every query was computed when it was asked, as it still is. The key is a retiredKey tombstone on `CubeSchema`, and the D2 conversion `cube-refresh-key-removed` strips the whole block from every cube as a pure lossless delete, retired from the load path. Its D3 record is the semantic entry `cube-refresh-key-retired`. A refresh cadence is declared again when a result cache exists. It also narrows the `time` stored form to the zone-less wall clock the record validator already enforces (ADR-0053 D-C1), so a field default or an action param default or value with a `Z` or a UTC offset is refused when it is authored or submitted rather than on every insert that falls back to it. The D2 conversion `time-default-utc-suffix-dropped` drops a `Z` or a zero offset, which names the same wall clock, and leaves a non-zero offset as stored for its author to rewrite; its D3 record is the semantic entry `time-default-zone-refused`. It also retires the page header's `breadcrumb` switch (ADR-0049 enforce-or-remove): no renderer ever drew a trail for it — objectui drew an empty slot that nothing filled — and the navigation trail is drawn once, by the app shell's header. The key is a retiredKey tombstone on `PageHeaderProps`, beside the `icon` that row lost at 17, and the D2 conversion `page-header-breadcrumb-removed` strips it from every `page:header`, `true` and `false` alike, retired from the load path. Its D3 record is the semantic entry `page-header-breadcrumb-retired`. The `nav:breadcrumb` component type is not part of it: the Studio page palette still offers it. It also retires connector-attached sync from the connector (ADR-0049, the ENFORCE route by ruling): `connector.syncConfig` — `strategy`, `direction`, `realtimeSync`, `timestampField`, `conflictResolution`, `batchSize`, `deleteMode`, `filters` — and `connector.fieldMappings` — `source`, `target`, `defaultValue`, `dataType`, `required`, `syncMode` — fourteen keys no engine ever executed, whose `latest_wins` and `soft_delete` defaults read as configured policy and did nothing. The capability is mainstream, so the definition moves rather than lapses: every mainstream platform binds a sync to its TARGET, so a `mapping` gains `connectorSource`, the `rest` / `openapi` connector it pulls from, the read action and an optional timestamp `watermark`, and a `job` sets the cadence (no schedule key returns to the connector). That binding is declared in this step and executed in a later one. Both connector keys are retiredKey tombstones on `ConnectorBaseSchema`, registered under both carrier defs; `DataSyncConfig`, `SyncStrategy`, `ConnectorConflictResolution` and `ConnectorFieldMapping` leave whole; and the D2 conversion `connector-sync-keys-removed` strips both keys from `connectors[]` and stored rows as a pure lossless delete — never writing a `mapping`, which would start writes that never happened. It also narrows an analytics cube member's `sql` — `measures..sql` and `dimensions..sql` — to a column reference: a field of the cube's object, a relationship path ending in one, or `'*'` (maintainer ruling D, ADR-0021 "zero raw SQL / zero raw expressions" carried from the dataset layer to the cube members it compiles to; ADR-0049 enforce-or-remove). A SQL expression there names no single field, so no platform check could judge which fields it reads, and the two analytics strategies never agreed on it: the raw-SQL path ran it verbatim, the ObjectQL path refused it. It is now refused at parse with a prescription naming the ADR-0021 dataset form — a measure with its own structured `filter` for a conditional count or sum, and `derived: { op, of: [...] }` over named measures for a ratio, sum, difference or product. No D2 conversion: an expression has no mechanical rewrite into a dataset, so the semantic entry `cube-member-sql-expression-retired` carries the move, including the scale change a ratio makes (a `derived` ratio is a 0–1 fraction). It also closes the form view's inline grid columns: `subforms[].columns`, on `view.form` and on `formViews` entries, was `z.array(z.any())` while a relationship field's `inlineColumns` was already the strict `InlineGridColumnSchema`, so a mis-keyed column published clean and drew a blank grid column, and `scale` on a currency column, which the other carrier refuses under the maintainer's rulings of 2026-09-23 (option B) and 2026-09-24 (option 乙), published green. The carrier now references that schema, so both carriers are judged by it, with its own prescriptions. The D2 conversion `form-view-subform-columns-canonicalized` respells a `{ field }` column as `{ name }`, the respelling `field-column-lists-canonicalized` makes on `inlineColumns`: it rewrites stored rows and assembled artifacts and lists the edit under `os migrate meta`, and it is retired from the load path, so an author writing `field` meets the refusal. A view saved with a failing column is refused with the column schema's prescription, and a stored row carrying one is diagnosed at rehydration; neither is stripped, because which column an unknown key or a mixed `field`/`name` entry meant is the author's call, and a conversion that dropped the key would accept at load what the parse now refuses. Its D3 record is the semantic entry `form-view-subform-columns-closed`. On both carriers, the reach of `inline-grid-column-currency-scale-refused` extends to a column that declares no `type`: such a column takes its type from the child field, which the column schema cannot see, when the console hydrates it, so `defineStack`'s cross-reference check re-parses a column whose `name` is a `currency` field of the child object as the type it renders as, and the refusal of its `scale` is the column schema's own. Reach: the child object must be declared in the same stack; a column naming no field of it, or a subform whose child object comes from another package, is not judged there. It has no D2 conversion, for the declared-type entry's reason: deleting the key is the migration, and a conversion that dropped it would accept it at load, the grace window ruling B refused. Its D3 record is the semantic entry `inline-grid-column-identity-only-currency-scale-refused`. It also gives the executor target of an action one spelling on the page blocks that run one. `ActionSchema` has always refused `endpoint` with the rename to `target`, while the `action:button` and `action:icon` component rows declared `endpoint` as a key of their own, and the console's `api` handler reads `target` only — so an `api` button authored with `endpoint` was accepted by the props gate and called nothing. The rows now refuse it with the same rename, read from the one alias table both share. The D2 conversion `action-block-endpoint-to-target` renames the key on an `api` action, where the rename is lossless, retired from the load path so authors are refused at the door while stored rows and `os migrate meta` replay it; an `endpoint` on a block with no `actionType` or another one is left as stored and reported as a TODO. Its D3 record is the semantic entry `action-block-endpoint-spelling-retired`. Finally, it retires the form field's `publicPicker` block (ADR-0087 D2, immediate — the maintainer's ruling E, which reverses the earlier ruling that had declared it): an anonymous public form no longer offers record search. The block opted a lookup, `master_detail` or `user` field on a public form into a picker served by an unauthenticated route; that route is deleted, and the public-form resolve route now leaves those three field types off the anonymous rendering unconditionally. The schema refuses the key with the prescription; the mechanical conversion `form-field-public-picker-removed` strips it from old sources and stored rows (lossless in effect — its only reader was the deleted route), and the semantic entry asks the author how a visitor should now choose: a `select` field with static `options`, or a form behind sign-in. It also closes the third carrier of the inline grid column: an `object-master-detail-form` page block's `details` was `z.array(z.unknown())`, so a key its renderer does not read and `scale` on a currency column, which the other two carriers refuse under the maintainer's rulings of 2026-09-23 (option B) and 2026-09-24 (option 乙), went through `objectstack validate` green. Each detail entry is now a strict shape of the twelve keys the renderer reads, and its `columns` references `InlineGridColumnSchema`. Page-component `properties` is read by the component-props gate, which reports a failing entry or column as an advisory finding, and is not parsed on the metadata save or load path, so a stored page still saves and loads and no conversion is registered; the authored census found nothing to respell. `defineStack`'s identity-only check reaches the block wherever a page carries it, with the reach `inline-grid-column-identity-only-currency-scale-refused` records for the other two carriers. Its D3 record is the semantic entry `ui-object-master-detail-form-details-closed`. It closes the fourth carrier the same way: `record:line_items` had no `ComponentPropsMap` row — it was the one entry on the string-arm registration ledger — so the component-props gate skipped its props, and the showcase project page's five `field`-keyed columns published green over a grid of empty cells. The row declares the fifteen keys the renderer reads, requires `relationshipField` and at least one column, and its `columns` references `InlineGridColumnSchema`; the showcase columns are respelled `name` in the same change. The panel draws its columns as authored, with no hydration from the child object's field, so `defineStack`'s identity-only check does not reach it. Its D3 record is the semantic entry `ui-record-line-items-props-closed`. It also holds an ADR-0021 dataset's `field` — `dimensions[].field` and `measures[].field` — to the accept set the cube members it compiles to already hold, from one shared declaration: a field of the dataset's object, a relationship path ending in one, and on a measure also `'*'` (ADR-0021 "zero raw SQL / zero raw expressions"; ADR-0049 enforce-or-remove). The slot was a bare string that parsed any expression, while the analytics dataset door already refused one on every query, so an expression could be saved and never answered. It is now refused at parse with a prescription naming the ADR-0021 form — a measure with its own structured `filter`, or `derived: { op, of: [...] }` over named measures — and so are an empty string (a count omits `field` instead) and `'*'` on a dimension, which names no axis. The one lossless repair is D2: `dataset-count-measure-empty-field-removed` drops a `count` measure's empty `field`, which still counts rows. An expression has no mechanical rewrite into a column, so the semantic entry `dataset-member-field-expression-refused` carries the rest. It also closes the export options of an `object-grid` page block. `exportOptions` was `z.unknown()`, so a bare format array — the list view's legacy spelling, which the list view lifts to `{ formats }` — was accepted on the grid, whose renderer reads `exportOptions.formats` and lifts nothing: the export menu offered its csv/json default and the author's list was dropped. The row now takes the list view's five-member export options object by identity, not the list view's union, and refuses a bare array with the object form named, a format outside the enum and an undeclared key. Page-component `properties` is read by the component-props gate, which reports these as advisory findings, and is not parsed on the metadata save or load path, so a stored page still saves and loads and no conversion is registered: the bare array never worked here, and lifting it would change the menu a deployed grid shows. The authored census found nothing to respell. Its D3 record is the semantic entry `ui-object-grid-export-options-closed`. It also makes an agent's structured output JSON-only (ADR-0049 enforce-or-remove). The cloud AI runtime, which executes agents, enforces `structuredOutput` on every final answer and refused four of its members before an agent's first turn: the `regex`, `grammar` and `xml` formats — no key ever carried a pattern or grammar to check against, and an answer is checked only as JSON — and the `coerce_types` step, for which no coercion engine exists. All four are refused at parse with a prescription, and the D2 conversion `agent-structured-output-refused-members-removed` deletes a block whose `format` was retired, deletes a retired `fallbackFormat` and drops `coerce_types` from the pipeline, retired from the load path. It also retires the metric sub-caption at both ends (maintainer ruling 2026-10-01, which reverses the 2026-08-06 ruling that gave it a translation key of its own; ADR-0049). The widget translation key `dashboards..widgets..subCaption` overlaid a widget's `options.description`, a key the dashboard schema never declared and no authored widget wrote, so the overlay in `translateDashboard` was its only writer. The overlay is removed, `subCaption` is a `retiredKey()` tombstone on the widget translation node, and its former `subtitle` alias now carries the retirement instead of a rename onto a key that accepts nothing. A widget keeps one authored description, `widget.description`, which renders as the card-header subtitle and is translated by the widget's `description` key. The D2 conversion `translation-widget-sub-caption-removed` strips the key from bundle entries and stored translation items as a lossless delete of what is served, retired from the load path so authors are refused at parse; its D3 record is the semantic entry `translation-widget-sub-caption-retired`. It also makes an agent's memory contract state exactly what the runtime honours (ADR-0049 enforce-or-remove). The cloud AI runtime, which executes agents, recalls the newest `maxEntries` long-term notes before the first round, writes one every `reflectionInterval` delivered interactions, and keeps them in its own database store; before an agent's first turn it refused the `vector` store (the old default) and `redis`, an enabled `longTerm` missing either number, and a `reflectionInterval` without one. So `longTerm.store` is retired as a whole key — the memory store is platform infrastructure, not agent metadata — and the D2 conversion `agent-memory-long-term-store-removed` deletes it, losslessly, retired from the load path; and with long-term memory enabled both numbers are required at authoring, with no default declared, so an upgrading author chooses them. It also retires an agent's conversation state machine, `agent.lifecycle` (ADR-0049 enforce-or-remove). It was parsed and never read: no runtime moved an agent through a declared state or refused an undeclared transition, and enforcing it would have meant a statechart interpreter beside Flow, the two-engine shape ADR-0020 rejected. What it reached for is served elsewhere — a conversation phase is a skill selected by its `triggerConditions`, a multi-step process is a Flow, a record's status transitions are the `state_machine` validation rule — so authoring refuses the key with that prescription, and the D2 conversion `agent-lifecycle-removed` deletes it, losslessly, retired from the load path. The XState `StateMachineSchema` family, kept by ADR-0020 only for this door, left the package with it. It also retires a cube measure's custom-SQL-expression types — `number`, `string` and `boolean` from `AggregationMetricType`, so from `measures..type` (ADR-0049 enforce-or-remove). They marked a measure whose `sql` was the whole computation, and with that `sql` now a column reference they had nothing left to compute: the raw-SQL path returned the column unaggregated and the ObjectQL path refused the measure. Each is refused at parse with a prescription naming the six aggregates. No D2 conversion: the column alone does not say which aggregate the author meant, so the semantic entry `cube-metric-expression-types-retired` carries the choice, and a stored cube that still carries one is refused rather than rewritten. It also retires `object-grid`'s `resizableColumns` (ADR-0049 enforce-or-remove; objectui's ruling that `resizable` is canonical, under the startup rule of immediate retirement): the legacy second spelling of `resizable`, read only as `schema.resizable ?? schema.resizableColumns` (measured at the `.objectui-sha` pin `89cad75d55`, `plugin-grid/src/ObjectGrid.tsx:5361`). One switch, two spellings, and zero writers in either repository, so there is no window. A retiredKey tombstone on `ObjectGridPropsSchema` with one D2 conversion that follows the renderer's precedence: the value moves to `resizable` when that is absent, and strips as a lossless delete when it is present (it was never read then). Its D3 record is the semantic entry `object-grid-resizable-columns-retired`. It also types seven members of an `object-grid` page block: `rowHeight`, `rowColor`, `navigation`, `conditionalFormatting`, `bulkActionDefs`, `aggregations` and `operations` were `z.unknown()` (an array of it for `bulkActionDefs`), although the grid reads each with one shape, so `rowHeight: 42` passed every door and rendered as `compact`. The five a list view also declares take the list view's own schemas by reference; `aggregations` takes the measured `[{ field, type }]` with the query AST's aggregation functions, and `operations` the four booleans a grid read point names (`create`, `update`, `delete`, `export`), refusing `read` and `import`, which nothing reads. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-grid-row-members-typed`. It also types `navigation` on the `object-map`, `object-gantt` and `object-tree` page blocks (the first stage of the `ComponentPropsMap` `z.unknown()` close-out): each renderer hands it to the shared navigation hook, which reads `navigation.mode` and falls back to `page`, so `navigation: 42` and a bare mode string passed every door and opened the record page. The three rows now take the list view's `NavigationConfigSchema` by reference, the carrier the grid, kanban, calendar and timeline blocks already take. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-map-gantt-tree-navigation-typed`. It also retires the `ai:chat_window` page element (ADR-0049 enforce-or-remove), the `user:profile` shape one namespace over: no renderer for it ever shipped, and none is wanted — the console leaves it unregistered on purpose, because the floating chat overlay it mounts on every page is the supported AI chat entry point — so a page that placed one validated clean and drew "Unknown component type", and its four props configured nothing. The name leaves `PageComponentType` and is refused by name at the node, its `ComponentPropsMap` row stays as a whole-bag refusal carrying the same prescription, and the props def `AIChatWindowProps` is unpublished. No conversion is registered: the only edit is deleting the node, a layout decision that is the author's. Its D3 record is the semantic entry `ui-ai-chat-window-retired`; `ai:suggestion` is unchanged. It also narrows page `requires` to the kinds whose source is compiled at save (ADR-0080 §5; maintainer ruling 2026-10-03, letter A): the plugin-namespace list is derived from an html page's source when the page is saved, while on `react`, `full` and `slotted` pages nothing derived it, the Studio page editor dropped it on every save, and a load-time warning was its one reader. `PageSchema` now accepts the key only when `kind` is `html` or its deprecated alias `jsx`, and refuses it at `requires` on every other kind, a page that omits `kind` included, naming the key, the page's kind and the compiled kinds. The key stays live on html pages, so there is no tombstone. The D2 conversion `page-requires-non-compiled-kind-removed` deletes the key from those pages, retired from the load path, so stored rows and artifacts replay clean while authored sources are refused until edited; the delete is lossless. Its D3 record is the semantic entry `page-requires-non-compiled-kind-refused`. It also types eight list members of the `object-grid`, `object-kanban` and `object-calendar` page blocks (the second stage of the `ComponentPropsMap` `z.unknown()` close-out): the grid's `fields`, `selection`, `selectable`, `rowActions`, `bulkActions` and `batchActions`, the kanban's `columns` and the calendar's `calendar` were `z.unknown()` (an array of it for the lists), although each renderer reads them with one shape, so a `{ name }` entry in `bulkActions` passed every door and was skipped. The members a list view declares take the list view's own by reference (`batchActions`, the spelling the grid reads first, takes `bulkActions`'s); the grid's `fields` and `selectable` and the kanban lane take the measured shape. The grid's `columns` stays open: its group headers draw an authored column's `options`, which the list view's column entry does not declare. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-grid-kanban-calendar-list-members-typed`. It also refuses, at parse, a hook whose `body` targets a table of stored metadata, `sys_metadata` or `sys_metadata_history` (maintainer ruling 2026-10-03, letter A: an app-authored body may not touch those tables, whose only writer for a body is the metadata protocol). The runtime already refused such a hook where a body becomes a handler, so it never ran, while the metadata save door answered 200 for it. `HookSchema` now refuses the same set at `object`, or at the list member, with the runtime's prescription to change metadata through the metadata API, judged by the one predicate the runtime uses: a hook with a `body` in any form whose target names either table. A code `handler` and the wildcard `'*'` stay outside it, as they are at registration. No key is removed, so there is no tombstone, and no D2 conversion exists: a refused hook carries no intent a rewrite could keep. Its D3 record is the semantic entry `hook-body-stored-metadata-target-refused`. It also types four members of the `object-form` page block (the third stage of the `ComponentPropsMap` `z.unknown()` close-out): `contentLayout`, `submitBehavior`, `navigateOnSuccess` and `mobile` were `z.unknown()`, although the form reads each with one shape, so a `submitBehavior` `kind` the form does not know passed every door and fell through to the thank-you panel. `submitBehavior` takes the form view's own block by reference; the other three take the measured shape. The form's `fields` and `sections` and the master-detail form's two stay open — the form draws a `{ name }` field entry and an inline runtime field inside a section, which the typed shapes would refuse — and `customFields` stays open until the spec declares the runtime form field its entries are. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-form-members-typed`. It also completes the `element:text` `variant` convergence (the second release of the ruled two-release split): the enum is the nine values `ui:text` publishes — `h1`-`h6`, `body`, `caption`, `overline` — and the pre-convergence spellings `heading` and `subheading`, which every release since the nine were added still accepted, are refused by name with a prescription naming the level to write. The D2 conversion `element-text-variant-heading-levels` rewrites `heading` to `h2` and `subheading` to `h3` on every `element:text` page component — the heading element each one always rendered, so the outline is unchanged and the heading takes that level's style. The `body` default for an absent `variant` is unchanged. It also types two members of the `object-metric` page block (the fourth stage of the `ComponentPropsMap` `z.unknown()` close-out): `aggregate` and `trend` were `z.unknown()`, although the tile reads each with one shape, so `aggregate: 'count'` and a trend with no `value` passed every door, and the tile asked the server for a measure it does not have, or painted a lone `%`. `aggregate` takes the query AST's aggregation functions and the chart aggregate's `groupBy` union by reference, with `groupBy` optional because a metric is one number; `trend` takes the badge's measured shape. `drillDown` and `compareTo` stay open: each by-reference candidate declares a key the tile never reads (the chart drill-down's `filter`, the dashboard comparison's `dimension`), and the chart drill-down refuses the `report` the tile draws, so each waits on a ruling. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-metric-aggregate-trend-typed`. It also retires an `object-master-detail-form` detail entry's `sortField` (ADR-0049 enforce-or-remove; the spec half of objectui's own retirement of the override). The console stopped reading the authored override: the field its line grid stamps with each line's position on drag-reorder is derived from the child object — its first field named `position`, `sort_order`, `sequence`, `line_no`, `line_number` or `sort` — and the pinned console had crossed that change while the spec still declared the key, so an authored value published green and was dropped. A retiredKey tombstone on the strict detail entry with one D2 conversion that is a pure lossless DELETE scoped by component `type` and by position (`properties.details[]`); its D3 entry `object-master-detail-form-detail-sort-field-retired` carries the one judgment left, whether the child object declares the field the line order is kept in. It also types the `object-metric` page block's `compareTo` (the fifth stage of the `ComponentPropsMap` `z.unknown()` close-out) to the tile's read, per the ruling between the reference and the read: `{ kind }`, with `kind` the dashboard widget comparison's own vocabulary by reference, and `dimension` refused by name, because this inline tile shifts the date macros in its own `filter` and never reads a dataset time dimension. A bare kind string, a kind outside the two and a `dimension` passed every door and compared the wrong window. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-metric-compare-to-typed`. It also types the `object-metric` page block's `drillDown` to the tile's read (the same stage and ruling): its five list members — `enabled`, `title`, `target`, `columns`, `maxRows` — are the chart drill-down's own by reference, and `filter` and `mode` are refused by name, because a metric tile has no click event for a drill filter to resolve against and no row for `mode` to open; both passed every door and were ignored. The drill `report` stays open: the tile draws a dataset-bound report, but the spec declares no drill report yet, and declares that contract first. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-metric-drill-down-typed`. It also types the `object-grid` page block's `columns` (the fifth stage of the `ComponentPropsMap` `z.unknown()` close-out), the list member the second stage held: the grid's group headers drew a column's `options`, which the list view's column entry does not declare, and objectui has since retired that read and takes the labels from the object field only. So the member takes the list view's own `columns` by reference — all field names or all column entries — and a column keyed `accessorKey` / `header` / `name`, a mixed list or an undeclared column key (`editable`, `options`, `reference`), which passed every door and drew no column or was ignored, is refused. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-grid-columns-typed`. It also refuses, at parse, a flow `create_record`, `update_record` or `delete_record` node whose `objectName` is the string `sys_metadata` or `sys_metadata_history` (the maintainer ruling of 2026-10-03, letter A, applied to flows: app-authored work may not write those tables, whose only writer is the metadata protocol). The runtime already refused such a node before any write, at its first run, while every authoring door accepted the flow. `FlowSchema` now refuses the same set at `nodes.N.config.objectName`, through the one judge `registerFlow` and `objectstack validate` share, with the runtime's prescription to change metadata through the metadata API: one of those three write nodes whose `objectName` names either table by exact name. A `get_record` node and a dynamic target stay outside it: the run judges the name it hands the data engine. No key is removed, so there is no tombstone, and no D2 conversion exists: a refused node carries no intent a rewrite could keep. Its D3 record is the semantic entry `flow-write-node-stored-metadata-target-refused`. It also types the top-level `fields` of the `object-form` and `object-master-detail-form` page blocks (the last stage of the `ComponentPropsMap` `z.unknown()` close-out), the two members the third stage held: the form drew a `{ name }` field entry its own page-builder guide taught, with a `label`, `type` and `required` it silently dropped, and objectui has since retired that entry from every authoring face, drawing only a stored one by its name. So both rows take field names, objectui's own declaration of the member, and refuse an object entry with what to write instead — a `{ name }` entry is its bare name, and a `{ field }` entry belongs in a section. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-form-fields-names-typed`. It also types the `object-gantt` page block's `markers` (the same stage): its entries were `z.unknown()` because the marker contract lived only in objectui, so a marker with no `date`, a numeric `date` or a misspelled member passed every door and the chart drew no line, or drew it unlabelled. The spec now declares objectui's own authoring declaration of a marker, `{ date, label?, color? }` with `date` a string, and the row takes it. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-gantt-markers-typed`. It also types the `object-timeline` page block's `mapping` (the same stage): the binding record — four optional field names for an entry's title, date, description and marker colour — was `z.unknown()` because its contract lived only in objectui, so a bare field name or a misspelled member passed every door and the rail drew the default field. The spec now declares objectui's own declaration of it, and the row takes it. The stage's other members — the metric drill-down's `report`, the form's `customFields` and both forms' `sections`, the timeline's `items` and the action containers' members — stay open: each contract has more than one viable shape that no ruling decides yet. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-timeline-mapping-typed`. It also types the `object-kanban` page block's `conditionalFormatting`, the one member the `ComponentPropsMap` `z.unknown()` close-out held for a ruling: it was `z.unknown()` while objectui's kanban also authored a native rule dialect the list view refuses, so `42` or a rule with no `style` passed every door and the board painted no card for it. objectui has since made the list view's `{ condition, style }` rule the member's only authoring dialect, and the board evaluates it with the grid's evaluator, so the row takes the list view's own member by reference, as `object-grid` does. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-kanban-conditional-formatting-typed`. It also requires every block of a `joined` report to bind a `dataset` (ADR-0021 single-form, enforced under ADR-0049 enforce-or-remove): the schema comment and the reports guide both said each block is dataset-bound, but the joined arm of `ReportSchema`'s refinement required only a non-empty `blocks`, so a block with no `dataset` parsed, passed `objectstack validate` and every save door, and drew nothing: the joined renderer issues no query for it, and a report whose blocks all lack one falls through to the pre-9.0 presentation bridge, which issues none either. The arm now refuses each such block at `blocks[i].dataset`, naming the block, with the prescription to bind it to a dataset; `dataset` stays optional on the block shape, which is read only on a `joined` report. No key is removed, so there is no tombstone, and no D2 conversion exists: only the author knows which dataset a block was meant to show. Its D3 record is the semantic entry `ui-report-joined-block-dataset-required`. It also types the `object-form` page block's `customFields`, one of the two contracts the `ComponentPropsMap` `z.unknown()` close-out held as forks and the maintainer has since ruled: each member is the runtime form field the form draws, which the spec did not declare, so a member with no `name` or a misspelled member passed every door and the form drew the field without it. The spec now declares a closed runtime form field of the members the form draws, in camelCase, keyed by `name` — the `grid` widget's snake_case keys stay out until the widget reads a camelCase spelling — and the row takes a list of it. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-form-custom-fields-typed`. It also types the `sections` of the `object-form` and `object-master-detail-form` page blocks, the other ruled fork: a section's `fields` draws an inline runtime form field beside a name and the form view's `{ field }` entry, which the stored form view's section refuses, so the sections stayed `z.unknown()` and a misspelled key passed every door. Both rows now take one page-block section shape of their own — the form view's section keys plus those three entry arms, the inline arm the runtime form field — in canonical spellings only: a page block's `properties` is never parsed on the way to the form, so a deprecated section `visibleOn` or a string `columns`, which a form view folds at parse, was dropped, and is refused with the canonical spelling. The stored form view is unchanged. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-form-sections-typed`. It then types the three members the stages above held open, as the maintainer ruled them on the decision card for those forks. The `object-metric` drill-down's `report` is `ReportSchema`, by reference (fork 1, letter B): it waited until a joined report refused a block that binds no dataset, and since then every report the member admits is one the drill drawer draws — a report with no `dataset`, a bare report name or a `{ name }` reference, which the drawer answered by listing the records, is refused. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-metric-drill-down-report-typed`. It types the `object-timeline` page block's `items` (fork 4, letter B): each entry is one of objectui's two ruled kinds, closed — a feed entry `{ time, title, description, variant, icon, content, className }` or a gantt row `{ label, items }` of bars `{ title, startDate, endDate, variant }`, each date a string or epoch milliseconds — and a row refinement pairs each entry with the kind the block's `variant` selects, so a feed entry with no `title`, or a gantt row on a feed timeline, is refused instead of drawn empty. A feed entry's `content` (child components) is held unjudged until a writer appears. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-timeline-items-typed`. And it types the members of the `action:group` and `action:menu` page blocks, the last of those forks (the same card, fork 5, letter A): each member was an open record the container draws and runs itself, so a misspelled key, a node-style `actionType` or an `endpoint` no `api` handler reads passed every door. A member now takes `action:button`'s keys with its executor spelled `type`, measured from the containers' reads — an `action:menu` item reads no `size` and declares none — with the rows' prescriptions; `outcomeMessages`, a member `className` and a member `properties.params` are refused, and `outcomeMessages` stays undeclared on all four action blocks as one decision. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-action-group-menu-members-typed`. It then closes the one static-values spelling those members still accepted and the containers drop: an `action:group` or `action:menu` member's `params` takes the input list, an `ActionParam[]` array, only, unless the member's `type` is `api`, whose object `params` keeps its request-payload window. `params` carries one shape and no second value-bag key is declared, so an object `params` on any other member, which parsed and then reached no action, is refused at `actions.N.params` with the prescription to author an action with static parameter values as its own `action:button` node. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-action-group-menu-member-params-array-only`. It also judges an `approval` flow node's `config` at parse against the contract the spec declares for it, `ApprovalNodeConfigSchema`, WHOLE. The approval executor fails the node on any issue of that contract, while `objectstack validate` and `objectstack compile` exited 0 on an undeclared `escalation.bogusKey` or a `timeoutHours: 0.5` and compile copied it into the artifact. The approval node now joins a declared contract map beside the builtin executor contracts, read by the one judge `registerFlow` and `objectstack validate` share, with no plugin loaded: an undeclared key or a refused value is refused at `nodes.N.config.` in the contract's own words, its did-you-mean included, and a key left out as before. The builtin arm stays presence-only. No key is removed, so there is no tombstone, and no D2 conversion exists: the platform cannot know what the author meant. Its D3 record is the semantic entry `flow-approval-node-config-contract-refused`. Then the builtin arm stops being presence-only: a present value a builtin node's executor contract refuses is refused at parse, at `nodes.N.config.`, with the same `node-config-refused-by-contract` code. Every builtin executor parses its config against that contract before it acts, so a `create_record` `outputVariable: 42` or a screen field `min: '1'` used to pass `objectstack validate` and `objectstack compile`, register, and fail every run that reached the node. The arm judges only what the build can know the run will parse: never a value carrying a `{token}`, whatever its slot's type (held back by ruling, not admitted: outside `http` such a token in a number or boolean slot still fails at its first run, so those slots take a literal); on `http`, which parses after interpolating, only token-free values and never the credential-held `signingSecret`; on a `loop`, only one with a `body`; on the region containers, never the region slots. Key membership is untouched. No key is removed, so there is no tombstone, and no D2 conversion exists: the platform cannot know the value the author meant. Its D3 record is the semantic entry `flow-builtin-node-config-values-refused`. It also retires the flat-list form of a package manifest's `permissions` (ADR-0049 enforce-or-remove): `ManifestPermissionsSchema` was a union of a list of permission strings and the structured ADR-0025 block `{ services, hooks, network, fs }`, and nothing ever acted on the list — the loader registers the consented grant set, never the manifest's request — so the block is now the only form. A list is refused at parse with its prescription, and the D2 conversion `manifest-permissions-string-list-removed` strips it from the stack's manifest and every `packages[].manifest` as a lossless delete, retired from the load path; translating what each dropped string meant into the four lists is the author's judgement, not a rewrite. It also makes a declared index state its uniqueness scope (ADR-0120 D1, staged to this protocol by D7). On `indexes[].unique`, bare `true` was the one spelling whose scope was positional: it built the index over exactly `fields`, one holder across the whole installation, while reading like "unique per organization" to an author who knew the field-level meaning. The parse now refuses it with a prescription naming both words — `'global'` (installation-wide, the index bare `true` built) and `'organization'` (one holder per organization). Field-level `unique: true` is untouched. The D2 conversion `declared-index-unique-scope` rewrites a declared index's bare `true` to `'global'`, which is lossless and drift-free by construction, retired from the load path so authors are refused at the door while stored rows, built artifacts and `os migrate meta` replay it. Its D3 record is the semantic entry `declared-index-bare-unique-true-retired`: whether each respelled index was really meant installation-wide is the author's call. It also takes the injected organization column off seven deployment-level platform tables — `sys_job`, `sys_job_run`, `sys_job_queue`, `sys_flow_dispatch`, `sys_migration`, `sys_migration_journal` and `sys_presence` (ADR-0131 D7). A writer census found no writer that attributes a row of any of them to an organization, so the column only ever held NULL, and under a walled posture the tenant wall hid every row from every reader. Each now declares `systemFields: { tenant: false }` and the object-level capability gate `requiredPermissions: ['manage_platform_settings']`: with no column there is no wall, so reads are governed by object permission, and the gate keeps one organization's administrator off another organization's rows. Nothing in stack metadata is rewritten; an existing database keeps the column as an orphan the boot drift report names, and `os migrate apply --allow-destructive` drops it. The D3 records are the seven `sys-*-organization-column-retired` semantic entries. It also refuses, at parse, a flow edge that does not resolve in its own graph or that repeats an earlier one. An edge's `source` and `target` must name nodes of the graph that declares it — the flow's own nodes, or the region body's for an edge inside a region — because the engine resolves them there alone, and a dangling edge carried the run nowhere, silently; and an edge with the same `source`, `target`, `type`, `condition` and branch `label` as an earlier edge of that graph is refused, because the engine runs a target once per out-edge it selects and a copy ran it again. Both are judged in the region walk the node-id rule uses, so `objectstack validate`, `registerFlow` and the metadata save door agree. No key is removed, so there is no tombstone, and no D2 conversion exists: a dangling endpoint carries no intent a rewrite could recover, and dropping a copy changes how often its target runs. Its D3 record is the semantic entry `flow-edge-unresolved-or-repeated-refused`. And the builtin arm judges key membership where no other door does: a key a `script` or `subflow` node's executor contract does not declare is refused at parse, at `nodes.N.config.`, with the same `node-config-refused-by-contract` code. Those two descriptors publish no `configSchema`, so `registerFlow`'s undeclared-key check skipped them, while their executors parse the strict contract and refuse the node on an undeclared key: a `script` `bogusKey` used to pass `objectstack validate`, `objectstack compile` and registration and fail every run that reached the node. Every other builtin keeps its undeclared keys at registration, against its descriptor; a retired `script` key keeps its tombstone. No key is removed, so there is no tombstone, and no D2 conversion exists: the platform cannot know what an undeclared key was meant to be. Its D3 record is the semantic entry `flow-script-subflow-config-undeclared-keys-refused`. It also moves the settings cascade's global rung out of the tenant-scoped `sys_setting` into the new tenant-less `sys_platform_setting` (ADR-0131 D7): one row per namespace and key for the deployment, no organization column, reads governed by the `manage_platform_settings` capability. The settings service writes a global-scope key there and reads the rung from there alone, and the `global` option of `sys_setting.scope` retires because no write reaches it. The cascade order and the `global` resolution source are unchanged. Nothing moves automatically: the v18 upgrade ceremony moves existing global rows, `sys_secret` handles included, and they open unchanged because the ADR-0128 AAD binds no holder object and no organization. The D3 record is the `sys-setting-global-rung-moved` semantic entry. It also retires the document family WHOLE (ADR-0049 enforce-or-remove; the ruling of record on PDF and print documents, letter B′, 2026-10-08: "A document is a page with a print declaration; no new template type"): the four defs of `data/document.zod.ts` — `data/DocumentTemplate` (a docx template with placeholders), `data/Document`, `data/ESignatureConfig` and the orphaned `data/DocumentVersion` — exported from `@objectstack/spec/data`, mounted by no stack key, registered as no metadata type and read by nothing in this repository, objectui or hotcrm, leave via RETIRED_DEFS_BY_MAJOR with one D3 semantic entry, so that "template" means one thing: a printable document is a page that declares `print`. The `ESignatureConfig` deadline-key tombstones leave with their def's source and their RETIRED_KEYS_BY_MAJOR[18] entries stay as history. It retires the single-brace `{…}` template dialect from the flow VALUE slots (the C half of the maintainer's ruling D on the flow expression dialects): the `assignment` node's values, in all three shapes, and the `fields` map of `create_record` and `update_record`, where a CEL value envelope is already the expression form. A string there is now the literal text it spells, and one carrying a `{…}` token is refused — by `FlowValueSlotSchema`, `registerFlow`, `objectstack validate` and the executor alike — with the CEL spelling of each token. No D2 conversion exists: every authored spelling was measured lossy (an absent key writes nothing under the template and fails under CEL; CEL divides two integers as integers), so which value an absent key should write is the author's judgment. The `$User` paths are refused too: the flow CEL scope binds `current_user`, the run's user or `null` in a run with none, so `{$User.Id}` is `current_user.id`, guarded where a flow can run without a user, and the other `$User` paths, which never resolved, name a read of the user record. The date macros keep their meaning until CEL can spell them. Its D3 record is the semantic entry `flow-value-slot-template-dialect-refused`. It also takes the injected organization column off the compliance ledger, `sys_audit_log` (ADR-0131 D7): some of its rows are about deployment-level actions no organization owns, so the organization a row is about stays in the attribution field `tenant_id`, which every writer already stamps, and never becomes the tenancy anchor. With no column there is no wall, so a platform administrator now reads the rows about no organization too; an organization reader is scoped to the rows about its active organization by the platform row policy `sys_audit_log_org`, stripped when no wall is enforced, and `organization_admin` names the ledger without the superuser bits so its wildcard bypass cannot skip that policy. Per-tenant retention partitions on `tenant_id`. Nothing moves automatically: an existing database keeps the column as an orphan the boot drift report names, for the v18 ceremony to drop once its values are confirmed in `tenant_id`. The D3 record is the `sys-audit-log-organization-column-retired` semantic entry. Then the builtin key arm covers every builtin whose contract registration could judge: a key the executor contract of a `get_record`, `create_record`, `update_record`, `delete_record`, `notify`, `http`, `screen`, `map`, `loop` or `parallel` node does not declare is refused at parse, at `nodes.N.config.`, with the same `node-config-refused-by-contract` code, closed with the rename-or-remove remedy. Registration's descriptor walk refused those keys already, after `objectstack validate` and `objectstack compile` had passed them, and it now stands aside for those types, so each has one judge; the declared key sets were measured equal first, so registration refuses what it refused before. `try_catch` waits for its contract's `retry` to close (below): it stripped an unknown key where its descriptor closes it. No key is removed, so there is no tombstone, and no D2 conversion exists. Its D3 record is the semantic entry `flow-builtin-node-config-undeclared-keys-refused`. It executes ADR-0032 Decision 3 in the flow TEXT slots — a `notify` node's `title` and `message`, a `screen` node's `title` and `description`, a refusing `end` node's `message`: they render through the formula template engine, so their placeholders are `{{ }}` holes, a variable path with an optional formatter (the engine's hole grammar now admits a `$`-named variable, so `{{ $error.message }}` is a hole). A single-brace `{…}` token there is refused — by the node contract, `registerFlow` and `objectstack validate` alike — with the hole spelling of each path token, or, for arithmetic, a function, a date macro or a run-user path, the `assignment` that computes it into a variable. No D2 conversion exists: the 17.x interpolator and the engine render a `Date` differently (JSON-quoted against ISO text), and a whole-slot object differently in a screen or `end` text, so the rewrite is the author's to check. Every other flow string keeps the single-brace dialect. Its D3 record is the semantic entry `flow-text-slot-single-brace-refused`. It also makes the deployment's platform-global declaration total (ADR-0131 D7): an object a deployment declares platform-global in its `org-scoping` service's `platformGlobalObjects` gets no organization column on that deployment, because the injected-columns plan reads the declaration, so the organization wall and the driver agree by having nothing to scope. The engine reads it at its plugin start, before the first schema sync, once every plugin init has run, and re-plans the objects registered before it; the security layer's stand-down for such an object retires with it. An absent declaration changes nothing, and a malformed one is refused and declares nothing. Nothing moves automatically: a declaring deployment's existing table keeps the column as an orphan the boot drift report names. The D3 record is the `platform-global-object-organization-column-retired` semantic entry. It also retires the `sys_view_definition` platform object as inert (ADR-0131 D13): no framework code wrote or read its rows, and runtime-authored views are `view` items in `sys_metadata`. The object, its two registrations, its `kernel:ready` active-row index migration and that migration's exports leave, and its name leaves the platform-object registry. Nothing in stack metadata is rewritten; an existing database keeps the table, which no platform path drops. The D3 record is the `sys-view-definition-retired` semantic entry. Then the retry policy closes, and `try_catch` joins the builtin key arm: `RetryPolicySchema`, the one declaration behind `job.retryPolicy` and a `try_catch` node's `retry`, refuses a key it does not declare, naming it with a did-you-mean, where it used to strip it — and with opt-in defaults a stripped `maxRetries` meant no retry at all. No writer relied on the strip. With `retry` closed to the five keys the descriptor declares, a key a `try_catch` node's contract does not declare is refused at parse, at `nodes.N.config.`, with the same `node-config-refused-by-contract` code, and the descriptor walk keeps plugin node types only. A `retryDelayMs` the conversion leaves beside a different `backoffMs` meets its tombstone there, as it met the walk. No key is removed, so there is no new tombstone, and no D2 conversion exists. Its D3 record is the semantic entry `try-catch-and-retry-policy-undeclared-keys-refused`. In those same text slots a `{{ }}` hole may root at a `$`-named variable only when the flow engine binds it (`$record`, `$runId`, `$flowName`, `$flowLabel`, `$error`, and a flat-graph `loop`'s `$loopItems` / `$loopIndex`): a hole such as `{{ $User.Id }}`, which the 17.x contracts accepted as a plain string and which renders nothing under the template engine, is refused by the node contract, `registerFlow` and `objectstack validate` with the remedy its single-brace spelling gets — compute the value with an `assignment` node, then write the variable as a hole. The template engine binds no new variable, and no D2 conversion exists: what the hole was meant to read is not in the flow. Its D3 record is the semantic entry `flow-text-slot-unbound-dollar-root-refused`. It also refuses, at `objectstack validate` and the runtime publish gate, a flow CEL root the flow does not bind — in a trigger gate, an edge, a `decision` branch, a screen field `visibleWhen` or a CEL value envelope. Such a root fails every run that reaches it with an unknown-variable fault, and the build used to pass it. The run-user spellings formulas, row-level security and the client accept (`user`, `ctx.user`, `os.user`) are the reachable case: flow CEL binds the run's user as `current_user` only, and the refusal names it. A flow whose run can bind a name the reader cannot see is left unjudged, and no D2 conversion exists: none of these roots ever evaluated in a flow. Its D3 record is the semantic entry `flow-cel-unbound-root-refused`. +Protocol 18 extends the publish-time refusal of unresolved placeholders, which protocol 17 applied to datasource connection config, to the memory driver's config-material persistence keys: `persistence.path` (file persistence and the `auto` override) and `persistence.key` (localStorage and the `auto` override) refuse `${…}` placeholder syntax at publish. Nothing resolves a placeholder there — the driver would create a literal `./${DATA_DIR}/…` path or write under the literal localStorage key — the same authored-under-a-false-belief shape, one surface over. The memory driver's `initialData` stays deliberately unjudged: it carries arbitrary record values, where a literal `${…}` may be legitimate data. It also retires `MetadataPluginConfig.additionalTypes` (ADR-0049 enforce-or-remove): the key was documented as THE plugin kind-declaration channel and read by nothing — the manager's type registry is seeded once from `DEFAULT_METADATA_TYPE_REGISTRY` and never merged with it, so authoring it configured nothing. A kind enters the live set as a side effect of registering an item of that kind. It also refuses malformed field `scale`/`precision` declarations: both are digit counts, so a non-integer or negative value (`scale: 2.5`, `precision: -1`) has no defined meaning — the write-time `scale` check, which refuses an over-scale value rather than rounding it, deliberately left it unenforced rather than invent floor/round semantics, which made the declaration silently inert. The schema now refuses both at parse (`z.number().int().min(0)`); the mechanical conversion deletes a malformed value from old sources and stored rows (behaviour-preserving), and the semantic entry tells the author to re-declare the count they meant. Finally, it removes the `objects["*"].allowExport` grant from the shipped admin permission sets — `admin_full_access`, `organization_admin` and the derived `organization_admin_no_bypass`. Measured on 17.0.0 GA, that wildcard made the export axis undeniable for an org admin: an application could declare an object exportable by nobody and the platform exported it anyway, with no supported opt-out, because a code-package set cannot be edited (`403 [not_overridable]`) and the admin held no app-authored set in which to write the per-object `false` that would have won. It is the earlier removal of `member_default`'s CRUD wildcard applied to the export axis, which had kept its wildcard by omission rather than by decision. From 18 an admin exports exactly what an app-authored set grants — a posture the same run measured to be already precise. Unlike everything else in this step it changes no schema, so nothing refuses at publish: the upgrade signal is behavioural and belongs here. Finally, it converges `record:chatter` / `record:discussion` `position` on the renderer's vocabulary (maintainer ruling 2026-08-15): the schema declared `sidebar`/`inline`/`drawer` — values no renderer branch ever compared, so the schema's own `sidebar` default silently rendered in flow while the value that actually docks the panel (`right`) was refused at publish. The row now speaks `bottom`/`right`/`left`; the mechanical conversion rewrites the old spellings (`sidebar` → `right`, `inline` → `bottom`, `drawer` → `right`), and the three schema defaults (`position`, `collapsible`, `defaultCollapsed`) are dropped per the `maxVisible` principle — renderer fallbacks stay the renderer's facts. It also retires `targetVariable` on `element:text_input` and `element:record_picker` (ADR-0049 enforce-or-remove): a declarative hint with zero readers in any repo — the live binding runs the other direction, resolved from the page variable whose `source` names the component's `id` (PageVariableSchema) — so an author who wrote only `targetVariable` got an input that wrote nothing, with a success receipt. The mechanical conversion strips the key from old sources (pure lossless delete — it never had an effect to lose); the tombstone's prescription says how to declare the binding that works. Finally, it retires the whole `element:filter` element (ADR-0049 enforce-or-remove at ELEMENT grain — the wider finding that the `targetVariable` retirement recorded and left for its own card): no renderer for the element ever shipped in any repo — objectui registers none, Studio's designer palette lists it as a no-renderer exclusion, and the 2026-06 page-liveness audit recorded it rendering "Unknown component type" — so every one of its six authorable keys was a capability claim nothing kept. All six are retiredKey tombstones; the mechanical conversion strips them from old sources (pure lossless deletes) and leaves the bare node, which the parse then refuses by name — delete the component. List surfaces own their filtering: a view's `userFilters` quick-filter bar / the list toolbar's filter builder. It also retires the whole `element:form` element (ADR-0049 enforce-or-remove at ELEMENT grain — the `element:filter` shape one element over, recorded by that retirement's own verdict sweep): no renderer for the element ever shipped in any repo — objectui registers none, Studio's designer palette lists it as a no-renderer exclusion naming the live replacement, and the 2026-06 page-liveness audit recorded it rendering "Unknown component type" — so every one of its six authorable keys was a capability claim nothing kept. All six are retiredKey tombstones; the mechanical conversion strips them from old sources (pure lossless deletes) and leaves the bare node, which the parse then refuses by name — delete the component. Use the object-bound `object-form` block instead — rendered, designer-publishable, its props declared for the component-props gate, and carrying the same intent (`objectName`, `fields`, `mode`, `submitText`). It also closes the two explicit column lists on relationship fields: `field.inlineColumns` entries are now the strict, name-keyed InlineGridColumnSchema (mirroring the objectui grid renderer's measured reads — objectui aligned the widget to `name` and retired the `field` spelling with no tolerant alias), and `field.relatedListColumns` entries are child field-name strings (the only form the related-list renderer hydrates fully). Both were z.array(z.any()) — a mis-keyed column published clean and rendered as blank cells with the right row count. The mechanical conversion respells inline `{ field }` entries as `{ name }` and folds related-list column objects to their identity string; unknown keys are named rejections at publish from this major. It also retires `measures..filters` on analytics cubes (ADR-0049 enforce-or-remove): a declared per-metric raw-SQL filter with zero consumers — both SQL strategies aggregate the metric's `sql` and never read `filters`, so a hand-authored `filters: [{ sql: "stage = 'closed_won'" }]` parsed, registered, and silently returned the UNFILTERED aggregate under the author's metric name (the same defect the dataset path had, on a hand-authored cube; the dataset half was repaired through its own structured channel when the analytics strategy began compiling each dataset measure's `filter`). The raw-SQL fragment also ran against the platform's structured-FilterCondition direction — it cannot be parameterized, re-targeted per driver dialect, or walked by the lint filter rules. The mechanical conversion strips the key from old sources (pure lossless delete — it never had an effect to lose); filter at query time with `where`, or use an ADR-0021 dataset measure's structured `filter` (a metric's own `sql` is a column reference, see `cube-member-sql-expression-retired`). Finally, it retires the stack `themes` carrier and `ThemeSchema` whole (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-21, disposition B: 退役授权面): the pipeline was live from the authoring gate through artifact ingest and stopped there — zero non-test readers of stored `theme` items, `theme` never a registered metadata type, no first-party app mounting the spec-aware provider, nothing selecting an active theme — so an authored theme shipped through every green gate and changed nothing on screen. `app.branding` stays the one colour surface; objectui's ThemeEngine/ThemeContext and their unit tests are retained. Semantic rather than mechanical: an authored palette has no lossless target (N themes vs M apps is a judgment), so the entry prescribes the hand move instead of deleting authored content silently. It also retires the `record:highlights` highlight-field `icon` (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-21, executing the 2026-08-20 census verdict): a declared key with zero read points in any direction — objectui's renderer normalized the authored object and carried `icon` into a highlight chip with no icon slot, `useRegisterHighlightFields` registers field NAMES only (structurally unable to carry it), and the Studio designer publishes the field list as plain strings — while six author-facing surfaces advertised the key (the shape that got the reference-rail `icon` refused, on the highlight chip). The mechanical conversion strips the key from the object entries of every `record:highlights` `fields[]` (pure lossless delete — the chip renders label and value only, so it never had an effect to lose); there is no replacement, and the live neighbour `readonly`, declared because the chip's read-only gate reads it, is untouched. It also retires the import mapping `lookup` transform's steering params (ADR-0049 enforce-or-remove — the sub-walk half of the 17.0.0 mapping cleanup that retired `extractQuery` / `errorPolicy` / `batchSize`): `fieldMapping[].params.object` / `.fromField` / `.toField` / `.autoCreate` declared a per-entry reference-resolution dialect the import path never implemented — `lookup` copies the cell through and resolution runs off the target field's own metadata — and `autoCreate` read as create-if-missing while an unresolved reference actually fails the row (`import_reference_not_found`), with or without the key. The eleven alias spellings convert to guidance so every spelling lands on the prescription; the mechanical conversion strips the four keys from stored sources (pure lossless deletes — none ever had an effect to lose). Finally, it retires the component-translation copy key `pages..components..submitLabel` and its `submit` alias (ADR-0049; maintainer ruling 2026-08-22): the face is measured, not mirrored — each copy key exists because some component in `ComponentPropsMap` declares it — and `submitLabel`'s only declarer was `element:form`, retired whole above, so the key had no declared component left to translate and the resolver overlay was its only reader. Retire won over re-anchor because the live form surface (`object-form`) speaks `submitText` (`I18nLabelSchema`), localizable at its own authoring site; re-anchoring would have widened the face for one word. The mechanical conversion strips the key from stored bundles and items (pure lossless delete — nothing read it once `element:form` was retired), at the acknowledged cost of dropping the bespoke-component route for that one word. Finally, it retires `page.components[].responsive` and the whole `ResponsiveConfig` layout vocabulary it carried (ADR-0049 D2; maintainer ruling 2026-08-22): the key was the destination the `dashboard.widgets[].responsive` tombstone prescribed as the live alternative, and a two-repo measurement (tsc-probe methodology with positive and negative controls) found the claim false — objectui's two implementations of the contract (`useResponsiveConfig`, `ResponsiveProtocol`) had zero callers and nothing read `.responsive` off a page component, so the prescribed migration moved an inert key to an inert key while the platform's own error message vouched for it. The same change repairs every shipped text that carried that redirect. `ResponsiveConfigSchema`, its two breakpoint maps and the `BreakpointName` enum had no other authorable carrier and leave with the key (RETIRED_DEFS_BY_MAJOR[18]); the live per-breakpoint channel on a page component is `responsiveStyles` (ADR-0065), which objectui really compiles. The mechanical conversion strips the key from stored pages (pure lossless delete — it never had an effect to lose). Finally, it retires nine of the eleven members of the plugin manifest's `contributes` block (ADR-0049 enforce-or-remove; triage graded 2026-08-21, cloud census leg discharged clean 2026-08-24): `events`, `menus`, `themes`, `translations`, `actions`, `drivers`, `fieldTypes`, `functions` and `commands`. A census of all three repos, with controls, measured that the whole monorepo contains exactly one non-test read of `manifest.contributes`, and it reads `kinds`; the other nine members parsed, entered the manifest, and changed nothing, while published docs and the schema's own JSDoc kept teaching them (`commands` documented Commander.js resolution the CLI dropped for oclif; `fieldTypes` advertised a registration seam that never existed). All nine are retiredKey tombstones mirroring `loading`; `kinds` survives (live reader), and `routes` was left to a ruling of its own, which retired it as well (the `plugin-manifest-contributes-routes-retired` entry). D3 semantic, no D2 conversion: a manifest is not a stack collection member, so a conversion would be a transform with no seam that ever runs. On the surviving `kinds` bucket it also retires the `globs` sub-field (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-24): the schema promised that declaring `globs` enables file-type discovery, but discovery globs `filePatterns` off the metadata type registry — which `contributes.kinds` does not extend, as `metadata-plugin.zod.ts` records outright — so an authored `globs` was accepted, stored, served back through `GET /metadata/kind`, and never consulted (zero value reads; the only non-test occurrences were the schema declaration and two type positions). The `kind` bucket itself and its `id` are untouched; file-type discovery stays single-channel on `filePatterns`. D3 semantic `plugin-manifest-kind-globs-retired`, same no-seam reasoning. Finally, it retires `object-grid`'s `defaultSort` (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-25, decision-inbox batch 4 — the producer half of objectui's `table.defaultSort` retirement, which the maintainer's 2026-08-22 「接受所有」 ruling on objectui's sort sink ordered): the legacy second spelling of `sort`, a single `{ field, order }` pair the renderer read only when `sort` was absent (measured at the `.objectui-sha` pin `190fbd01d`, `plugin-grid/src/ObjectGrid.tsx:1244-1246` and `:2847`, which wraps it `[schema.defaultSort]` — the exact array shape `sort` carries). One intent, two spellings; objectui's mirror schema is parity-test-only and parses nothing at runtime, so only the spec strictObject can refuse the key. The mechanical conversion carries the pair over — renamed to `sort` and wrapped in the array shape — when `sort` is absent, and strips it as a pure lossless delete when `sort` is present (the renderer's own precedence made it unread then). Finally, it retires the object-permission lifecycle bits `allowRestore` and `allowPurge` (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-26, decision-inbox batch 5, which chose retiring the two bits over gating operations that do not exist): the `restore` / `purge` ObjectQL operations the bits claimed to gate have never existed — no destructive lifecycle verb is in the engine's dispatch vocabulary, which a test pins — so granting the bits delivered nothing, and an author who declared `allowPurge: false` believed a lock on GDPR hard-deletion existed when the operation itself did not. Both keys are retiredKey tombstones; the evaluator's pre-mapping rows retired in the same batch (a dispatched `restore`/`purge` stays denied fail-closed via the DESTRUCTIVE_OPERATIONS backstop, so there is no ungated window), and the mechanical conversion strips the keys from every object grant in `permissions[].objects` (pure lossless delete — they never had an effect to lose). `allowTransfer` is ENFORCED — the server guards who may rewrite a record's owner — and stays. The keys return with the M2 lifecycle initiative (feature + RBAC in one batch), which stays open as their anchor. Finally, it narrows the per-option `default` key OUT of the form-view options vocabulary (ADR-0049 declared-but-unenforced; maintainer ruling 2026-08-28 on the console form renderer's analysis, disposition 甲): `SelectOptionSchema` serves two surfaces and only the OBJECT-field face reads `default` (enforced there by a maintainer ruling of 2026-08-10 — `applyFieldDefaults` falls back to the option marked `default: true`; that face, its alias rows and its precedence pin are untouched). On a form-view field's option list the key parsed clean and nothing read it — the insert-path fallback consults the object definition's options, never a form view's, and no form renderer seeds a value from it (measured against the console's form controls, none of which reads the key; the ruled census found ZERO authored occurrences across the tree, the example apps and the published *.form.ts corpus). The FormView vocabulary's own option shape (`FormSelectOptionSchema`, ui/view.zod.ts) now refuses the key with the prescription; the mechanical conversion strips it from stored sources (pure lossless delete — it never had an effect on this surface to lose). It also retires the paper metadata-customization protocol whole (ADR-0049 enforce-or-remove, maintainer ruling 2026-08-29): `kernel/metadata-customization.zod.ts` — the three-layer platform/user patch-overlay model with field-level change tracking and a 3-way-merge story — was exported, documented as the customization architecture, and implemented ONLY by an unreachable `packages/metadata` limb (no route served the paper `…/overlay`/`…/effective` endpoints; the four optional service members were called only by their own unit tests). ADR-0126 §6 wall 4 supersedes it on the record ("nothing may build against it"). The module's seven defs and the three section-5 API contracts leave via RETIRED_DEFS_BY_MAJOR; the authorable carriers `MetadataPluginConfig.customizationPolicies` / `.mergeStrategy` and `MetadataManagerConfig.persistence.overlayWritable` are retiredKey tombstones (no D2 conversion — plugin/manager configs are not stack collection members, the additionalTypes reasoning). The customization that actually ships: ADR-0005's org overlay and ADR-0126's packaged-metadata model. Finally, it canonicalizes the legacy objectql field-key dialect `reference_to` → `reference` on lookup/master_detail fields (the server half of the maintainer's 2026-08-31 ruling that the server normalizes the protocol and the renderer only executes it). `FieldSchema` has always refused `reference_to` by name, but stored `sys_metadata` rows written by seams that bypass the parse still carry it, held up today only by objectui's `reference ?? reference_to` fallback arms — which the ruling's objectui half deletes. The mechanical conversion renames the key (the house precedence for a shadowed alias: a canonical `reference` wins, a disagreeing pair is kept for the author), replays on every stored-row rehydration so the serve face only ever emits the canonical spelling, and `os migrate meta` rewrites old sources; the authoring-surface rejection with its rename prescription is unchanged. It also retires `connector.errorMapping` (ADR-0049 enforce-or-remove; triage ruling 2026-09-02): `ErrorMappingConfig` (4 keys) and its `ErrorMappingRule[]` (7 keys) were authorable through `ConnectorSchema` — and, via `DeclarativeConnectorEntrySchema`, through `stack.connectors[]` and the `/meta/connector` door — and read by nothing: no provider, dispatcher or materializer ever mapped an external error through the rules, so `unmappedBehavior` configured nothing and a rule's `userMessage` was never shown to anyone. That spelling is the live API-error channel's (`ApiError.userMessage`), so an author who wrote a rule here reasonably believed they were marking a refusal for an end user; the failure was silent in both directions. The carrier key is a retiredKey tombstone on the non-strict `ConnectorSchema` (a bare deletion would be a silent strip), the three defs — `integration/ErrorMappingConfig`, `integration/ErrorMappingRule` and the orphaned `integration/ConnectorErrorCategory` enum — leave via RETIRED_DEFS_BY_MAJOR, and the mechanical conversion strips the block from `connectors[]` (pure lossless delete; it never had an effect to lose). It also retires the fourteen hour/minute/day-shaped deadline keys of the incident-response, training and change-management families (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-02): six on the incident-response schemas, five on the training schemas and three nested in the change-management schemas, every one on the published surface and read by nothing — the schemas are mounted by no stack key and registered as no metadata type — so a compliance author who wrote `triageDeadlineHours: 4` held a deadline the platform never kept. All fourteen are retiredKey tombstones (the schemas are not strict; a bare deletion would be a silent strip) with no D2 conversion, for the additionalTypes reason: none of these schemas is a stack collection member, so the chain has no seam. It then retires those three compliance-shaped families WHOLE (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-05, ruled A, not roadmapped): the nineteen defs of `system/incident-response.zod.ts`, `system/training.zod.ts` and `system/change-management.zod.ts` — roughly a hundred declared keys, exported from `@objectstack/spec/system`, mounted by no stack key, registered as no metadata type, absent from the liveness ledgers, read by nothing repo-wide (examples, skills and objectui at the pinned sha included) — leave via RETIRED_DEFS_BY_MAJOR with one D3 semantic entry per family; the fourteen deadline-key tombstones leave with their defs' source and their RETIRED_KEYS_BY_MAJOR[18] entries stay as history. Boolean capability claims such as `notifyRegulators`, `requirePostIncidentReview`, `trackCompletion` and `approval.required` were the sharpest declared-≠-enforced shape left: an author writing `notifyRegulators: true` held a compliance promise the platform never kept. And it resolves the branch the deadline-key ruling held open — no roadmapped e-signature consumer — so `ESignatureConfig.expirationDays` / `reminderDays` (`data/document.zod.ts`, defaults 30 / 7 days, read by nothing) are retiredKey tombstones with no D2 conversion (`document` is no stack collection member), registered in RETIRED_KEYS_BY_MAJOR[18] with one D3 semantic entry. Finally, it moves the unit of every duration-shaped `z.number()` key whose unit lived only in its description into the key name (maintainer ruling 2026-09-02, no grandfathered baseline): `hook.timeout` and `job.timeout` become `timeoutMs` (mechanical rename, retired from the load path), and the five keys with no stack seam — `MetadataManagerConfig.cache.ttl` / `cache.databaseLoader.ttl` (seconds and milliseconds fourteen lines apart under one name), `DriverOptions.timeout`, and the tenant `connectionPool.idleTimeout` / `accessControl.sessionTimeout` whose unit the reference pages never published — are retiredKey tombstones with a semantic entry each, naming the suffixed key. The `data`, `ui`, `ai` and `integration` remainder closes the same sweep: `dashboard.refreshInterval` → `refreshIntervalSeconds`, the connector pair `health.circuitBreaker.monitoringWindow` → `monitoringWindowMs` and `triggers[].interval` → `intervalSeconds` (both halves later absorbed by the removal of the block each key lived in — see the connector retirements below), and the two datasource config keys `memory config.persistence.autoSaveInterval` → `autoSaveIntervalMs` (BOTH union arms — the `auto` arm forwards the same value to the same file adapter, so splitting them would have left one value with two spellings) and `turso config.timeout` → `timeoutMs` all convert, because a dashboard, a connector and a datasource are stack collection members stored as rows; the two with no seam — `ConversationAnalytics.duration`, computed at runtime and never authored, and `NoSQLQueryOptions.timeout`, a per-call driver argument — are retiredKey tombstones with a semantic entry each. That remainder is what takes `check:duration-unit-keys` to zero offenders over `packages/spec/src/**`; the gate goes red again by design when its declared population widens beyond that subtree. It also retires the three outer keys of `MetadataManagerConfig.cache` — `enabled`, `ttlSeconds` (the duration rename's respelling of `ttl`, never shipped) and `maxSize` — that the rename above surfaced (ADR-0049 enforce-or-remove): declared, defaulted and published, read by nothing — `MetadataManager` hands only `cache.databaseLoader` to the loader — so `cache: { enabled: false }` switched nothing off. All three are retiredKey tombstones registered in RETIRED_KEYS_BY_MAJOR[18] with one D3 semantic entry and no D2 conversion (a manager config is no stack collection member); the rename is folded into the removal, so `cache.ttl` now prescribes deletion rather than a hop to a retired key. It also retires the seven cron-typed positions nothing evaluated (ADR-0049; the 2026-09-06 ruling retired each family rather than marking it experimental): the two export-schedule crons, `ScheduleState.cronExpression`, `DataSyncConfig.schedule`, `CacheWarmup.schedule` and the two disaster-recovery crons were parsed into the cron envelope and read by nothing (the D7 ledger row `cron-declared-unwired`). All seven are DELETED OUTRIGHT — no retiredKey tombstone, no RETIRED_KEYS_BY_MAJOR[18] entry, no D2 conversion and no D3 semantic entry — so this step replays nothing for them and `migrate meta` lists no edit: the keys simply stop existing. That the chain is silent does NOT make the deletion silent to an author: the PARSE strips (no schema here is `.strict()`), but above it `lintUnknownAuthoringKeys` names the dropped key for the one position a stack manifest reaches — `os validate` and `os build` both print `connectors..syncConfig.schedule: 'schedule' is not a declared connector key, so its value is dropped at load.`, and `os validate --strict` EXITS 1 on that warning. The other six positions are unreachable from a manifest, so for those the parse-level strip is the whole of it. That is the maintainer ruling of 2026-09-10 on the retirement PR, taken over the seat recommendation to keep the connector D2, on the reading that customers do not upgrade major by major in order. It also retires the `type: 'page'` LIST-VIEW mount and its `pageName` binding (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-09 「撤」). The member was added so a view could render nothing of its own and delegate to an already-published page, but only the spec half landed: no renderer ever routed it — objectui's list-view switch shares its default arm with `grid` — so a page view drew an empty table where the page belonged, and the three parse refusals policing the binding policed a mount that never mounted anything. The enum VALUE carries its prescription on the `type` enum's own error map (an enum-value narrowing has no tombstone to hang one on, the `exportOptions` 'pdf' precedent); `pageName` is a retiredKey tombstone on both list-view doors. The D2 conversion STRIPS both keys rather than rewriting `type` to `'grid'`: `type` defaults to `grid` in the schema, so deleting it lands the row on exactly what it already rendered without this registry guessing a view type. The surviving page mount is the app navigation item (`PageNavItem.pageName`), untouched. It also retires `object-kanban`'s `quickAdd` (ADR-0049 enforce-or-remove; the spec half of the director-seat ruling of 2026-09-08 that the board grows no inline record-creation path and retires the key). The board FORWARDED the key into the shared renderer but the affordance is gated on both `quickAdd` and `onQuickAdd`, and `onQuickAdd` is a host-supplied FUNCTION JSON cannot carry and no producer puts on an `object-kanban` node — so the gate was permanently false. The drop was NOT silent, and that is what made it worse than silence: objectui's html tier reported the published key as `unknown-prop`, the same diagnostic a typo gets, so an author following the contract met a tool contradicting it with no way to tell which side was wrong. A retiredKey tombstone on `ObjectKanbanPropsSchema` with one D2 conversion that is a pure lossless DELETE (the key never had an effect to preserve) scoped by component `type`. Delete the key; `object-kanban` offers no quick-add control. It also retires the bare STRING `sort` clause on the list-view doors (ruled 2026-09-07: the legacy string clause is retired, one spelling, the array). This is the PRODUCER half of the seam whose consumer half shipped in objectui first: `convertSortToQueryParams` now refuses a runtime string, so `ListViewSchema.sort` was minting documents its own consumer rejects — a document that validated upstream failed downstream, and the author was told off by the wrong layer. Like the `type` value above it is a VALUE narrowing with no tombstone to hang a prescription on, so the surviving array member's own error map carries it, keyed on `issue.input` being a string. The D2 conversion REWRITES rather than strips, because the clause is losslessly mechanical: `'created_at desc'` is the tuple `{ field, order }`, a bare field name meant ascending and is written out as `order: 'asc'`, and the comma-separated multi-key form becomes one entry per key in the same order. A string that does not parse as that grammar — the `'-field'` dialect above all — is left alone and meets the door instead: that dialect belongs to `RecordRelatedListProps.sort`, never reaches `convertSortToQueryParams`, and retiring it was NOT ruled. It also removes `page.assignedProfiles` (ADR-0090 D2 / ADR-0049 enforce-or-remove; maintainer ruling 2026-09-12 「同意」). The key was authorable on the published `PageSchema` and named for the Profile concept ADR-0090 D2 deleted, while the schema's own alias table CORRECTED an authored `profiles:` into it — two files from `security/permission.zod.ts` answering the same word with "no Profile concept". Measured across this repository and objectui it had zero readers, so a page that "assigned profiles" was open to every caller who could reach it. It is a retiredKey tombstone on `PageSchema` — the def is still parsed from the `page` root, so there is an author to teach — and the two alias entries became refusals naming the permission-set route. The D2 conversion STRIPS the key — there is no lossless target, because which permission set a given profile name corresponds to is a judgement no walker can make, which is what the paired D3 semantic entry is for. Finally, it removes `aria` from the chart config (ADR-0049 enforce-or-remove; maintainer decision of 2026-09-12 — judge the protocol wrong for this one key). It is the last member of the `aria` family retired for the same measured reason as `dashboard.aria` and `dashboard.widgets[].aria` before it: an ARIA block an author can declare and nothing lowers to the DOM. It survived those two sweeps by depth — it sits inside the widget’s `chartConfig` bag, which no drill had reached until the per-key pass recorded in `liveness/dashboard.json`. That pass found `aria` to be the one `ChartConfigSchema` key with no reader on EITHER face: the chart implementation declares no `aria` prop, the presentation lowering names it nowhere, and the react block omits it from ``’s `dataProps`. Remove rather than enforce, because the same chart config already carries a WORKING accessible-name channel in `description` (lowered as `role="img"` + `aria-label`), and giving `aria` a reader would put two accessible-name sources on one element behind a precedence rule nobody has written — one node, one accessibility vocabulary. The tombstone rides `ChartConfigSchema` and therefore copies into `ReportChartSchema`, so the key is registered twice; the D2 conversion STRIPS it from all three authored sites (`dashboards[].widgets[].chartConfig`, `reports[].chart`, `reports[].blocks[].chart`) as a pure lossless delete — it never had an effect to lose. The two alias spellings that pointed at it, `accessibility` and `ariaProps`, became refusals carrying the same prescription rather than renames onto a tombstone. It also states, and enforces, who owns a dataset-bound chart's STRUCTURE (ADR-0021; maintainer ruling 2026-09-12): the dataset decides which series exist and which column each one reads, `chartConfig` carries appearance, and `dashboard.widgets[].chartConfig`'s `type`, `xAxis`, `yAxis` and `series` are refused by name on that carrier — the widget's own `type` is the chart family and `dimensions`/`values` are the selection. An authored `yAxis[].field` was a live membership channel: the renderer synthesised a series from it when the chart declared none, so one authored axis could silently re-point a dataset-bound series at another column and the chart still drew. The D2 conversion strips the four keys from dashboard widgets only — `ReportChartSchema` and the inline-data react `` tier keep their own axes — and the paired semantic entry carries what the stripped keys were saying, because an authored axis field may name a column the widget never selected and no walker can move that intent into the dataset. Finally, it splits the translation bundle type in two (maintainer ruling 2026-09-13: settings copy belongs to the platform): the platform bundle keeps all eleven groups and the per-app bundle (`stack.translations`, `defineTranslationBundle`) no longer declares `settings`, which is keyed by `SettingsManifest.namespace` and only platform code declares a manifest. Both bundles load into ONE served tree, so an app-authored `settings` branch did not sit inert — but nor did it override the platform: the app’s bundles arrive in `AppPlugin`’s `start()` (Phase 2) and the platform’s at `kernel:ready` (Phase 3), and `deepMerge` gives the later source the leaf, so what an application had was a GAP FILLER on a namespace it does not own — rendering only where the platform bundle carried no string for that key and locale. The registered `translation` ITEM follows the file door (maintainer ruling 2026-09-22: one app metadata type, two authoring doors, one accepted shape) and no longer declares `settings` either; there the group had been STRONGER, because the runtime-authored layer is read over the shipped bundles, so a stored item overrode the platform’s own copy. The D2 conversion strips the group from per-app bundle entries and from bare items alike — the runtime translation sync replays it over every stored row before merging — and the paired semantic entry says what the strip means at each door, because a notice reading "(removed)" says neither that an item’s overrides give way to the platform’s string nor that a gap falls back to the manifest's own English literal. Finally it retires object `tenancy.organizationField` (ADR-0049 enforce-or-remove). The key named the column a PLATFORM ROW is stamped from, as opposed to the column the object is WALLED by (`tenantField`); on an ordinary object those are the same column, and the entire protocol declared it exactly once — on `sys_api_key`, a better-auth-managed credential table this platform ships and no application authors. Its three readers were all platform-row writers, scope-pinned by name, so an application declaration was inert by construction while still forcing every future piece of organization logic to ask "what if somebody set this?". The divergence is NOT retired, only its authorability: it moves to `PLATFORM_STAMP_ORGANIZATION_COLUMNS` in `@objectstack/metadata-core`, keyed by object name and read by the stamp face alone, so audit stamping, the approval-row writer and the automation-run recorder keep their behaviour with no authorable input. The conversion is a lossless delete, and a lossless delete still leaves the author a judgment, which the family's D3 entry `object-tenancy-organization-field-retired` carries — an application whose tenant column genuinely is not `organization_id` declares `tenancy.tenantField`, which both walls the object and stamps its platform rows. It also retires `connector.connectionTimeoutMs` (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-22, letter A — the narrower SECOND decision the key was owed after the ruling that made its nine ledger siblings live deliberately left this one dead). Bounded, defaulted, `.describe()`d and served back by `/meta/connector`, so an author had every signal it worked — and no site ever applied it as a deadline. This retirement is NOT the zero-mention shape: five sites outside `packages/spec` read the key (the materialization fingerprint and the provider-context build in the automation service, `ctx.connectionTimeoutMs` in the `rest` and `openapi` provider factories, and the `?? 30000` fallbacks that put it back on the reported def), but every one is a pass-through whose only termini are the def `GET /connectors` echoes and the fingerprint that decides whether to re-materialize. The one mapping from authored policy onto the platform's outbound `fetch` was handed `retryConfig` and `requestTimeoutMs` only, so the key was carried and never honoured — the same parsed-unmarked-unenforced state ADR-0049 forbids, wearing a longer route. Nor was the `实现` arm available: a WHATWG `fetch` exposes one `AbortSignal` over the whole operation and never the connect phase, so bounding time-to-response with it would kill a slow-but-connected upstream the author meant to allow with a large `requestTimeoutMs`. `requestTimeoutMs` is the replacement and the bound the platform can keep. The carrier key is a retiredKey tombstone on the non-strict `ConnectorSchema` (a bare deletion would be a silent strip), registered under both def keys because `DeclarativeConnectorEntrySchema` carries it too, both carriers wrapping the same private `ConnectorBaseSchema`; the D2 conversion strips it from `connectors[]` as a pure lossless delete — it never had an effect to lose — because a stored connector row CAN carry it (the `PUT /meta/connector/:name` door persists the authored value and the stored-row rehydration seam is live for this type, both measured); and the withdrawn `ConnectorProviderContext` member, which is code and has no authored source to rewrite, leaves via the paired semantic entry instead. Finally it gives the one-filter-orthography convergence (ruled 2026-08-25: one filter spelling platform-wide, the rule array) its mechanical half at rest (ruled 2026-09-12): the D2 conversion `page-component-filter-record-to-rule-array` rewrites a record-form or single-level AST `filter` at the converged rule-array doors — `dataSource.filter`, the `object-*` / `element:number` / `element:record_picker` `filter` props and `object-grid.defaultFilters` — to the rule array wherever the mapping is lossless, and leaves a filter carrying `$and` / `$or` / `$not` (or any part with no lossless rule spelling) exactly as stored, because flattening a combinator changes which rows a page selects. It is retired from the load path, so authors are still refused at the door and taught the array; the stored-row seams and this chain replay it. It also retires the view item's `owner` and `hidden` (ADR-0049 enforce-or-remove). Both sat on the view-item identity layer, were accepted by the strict authoring door and by the wire member the `view` write door validates, and were stored verbatim — and nothing read either: both switcher read paths filter on `viewKind` + `object` and sort on `order`, so `hidden: true` hid nothing, and no per-user scope ever read `owner`, so a view marked as one user's was listed for everyone who can read the object. Per-user view scoping is a parked direction (ADR-0017, amended 2026-09-04), not a shipped mechanism. Both keys are `retiredKey()` tombstones on the SHARED shape, because that shape also feeds the `.strip()` wire member, where a bare deletion would be a silent strip. The D2 conversion `view-item-owner-hidden-removed` strips them from the view item RECORD spelling only, as a lossless delete, in both collections a record travels in — `views` (stack sources and stored rows) and the assembled-manifest `viewItems` channel (package export, environment artifacts), whose registration parse would otherwise refuse an artifact assembled before this release. It also retires a `joined` report's `chart` at both coordinates (ADR-0049 enforce-or-remove): the joined renderer draws each block as a table and returns before the one container `chart` read, and no renderer reads a block's `chart` at all, so a chart on a joined report parsed, passed the chart-bindings lint, and plotted nothing. The key leaves `JoinedReportBlockSchema`'s closed shape (its `guidance` table carries the prescription) and the joined arm of `ReportSchema`'s refinement refuses a container `chart`; `chart` stays live on every non-joined report. The D2 conversion `report-joined-chart-removed` strips both as a pure lossless delete — neither ever had an effect to lose — because a stored report row CAN carry them (the Studio report form offered a block `chart` input until this change); it is retired from the load path, so authors are refused at parse rather than rewritten. It retires the view item's `owner` / `hidden` pair on the flattened overlay door too (ADR-0049; the view item's disposition for the same key pair, followed here as triage directed): the lean personalization PUT with no `config` declared its own `owner` / `hidden`, accepted and stored them, and nothing read either. Both are `retiredKey()` tombstones on the two overlay members with the view item's own prescription texts, and the D2 conversion `view-overlay-owner-hidden-removed` strips them from the flattened spelling (no `config`, no container slot) in `views` and `viewItems`, so a stored overlay row is served without them. A row that held other view keys is then valid again and re-saves; a row that held nothing but its identity and the two keys is left identity-only, which the door refuses, so it is badged invalid, refused on a whole-row re-save and reported `failed` by `os migrate meta --stored --apply` until it is deleted or given the setting its author meant. Its D3 record is the semantic entry `view-overlay-owner-hidden-retired`. It also narrows form `layout` to `vertical` | `horizontal` on both surfaces that declared the four-arm enum — the `object-form` page component and the form view (ADR-0049 enforce-or-remove). No renderer ever gave `inline` or `grid` a behaviour of its own: every form presentation folded both to `vertical`, multi-column is `columns` (honoured under either layout), and `inline` is a toolbar / filter-row pattern rather than a record-form layout — redundant vocabulary under the maintainer's family criterion (a capability mainstream platforms have is served once, here by `columns`), retired with no alias window. Both enums refuse the two values with a per-value prescription naming `columns`; the D2 conversion `form-layout-inline-grid-to-vertical` rewrites them to `vertical` (behaviour-preserving, `columns` untouched) on `object-form` page components, on every form payload a view carries, and on the assembled-manifest `viewItems` channel. It also removes `currencyConfig.precision` (ADR-0049 enforce-or-remove): declared and validated against ISO 4217, read by no renderer or runtime — a currency amount's decimal places are its currency's ISO 4217 minor unit, derived from the currency itself. The D2 conversion `currency-config-precision-removed` strips it from every field's `currencyConfig` as a pure lossless delete, which matters most at rest: the schema used to bake `precision: 2` into parse output, so stored object rows and built artifacts carry it without anyone having written it. Retired from the load path; an authored key is refused with the prescription. It also retires the RLS policy's `tags` (ADR-0049 enforce-or-remove; graded RETIRE by the maintainer's criterion — no mainstream platform tags a row-level policy): the key promised categorization and reporting for governance and compliance, and nothing ever read it — the RLS compiler never consulted it and no preview rendered it. It is a `retiredKey()` tombstone on `RowLevelSecurityPolicySchema` (the `priority` posture one key over), and the D2 conversion `permission-rls-tags-removed` strips it from every policy in `permissions[].rowLevelSecurity` as a lossless delete, so a stored permission row that still carries it replays clean. It is retired from the load path, so authors are refused at parse rather than rewritten. Its D3 record is the semantic entry `permission-rls-tags-retired`. Finally, it removes `aria` from the action (ADR-0049 enforce-or-remove), the fourth member of the `aria` family after `dashboard.aria`, `dashboard.widgets[].aria` and the chart config's, and retired for the same measured reason: an ARIA block an author can declare and nothing lowers to the DOM. The liveness ledger had graded it `live` on an uncited "partial" note with no reader behind it; at the pinned renderer, none of the surfaces that render an action — button, icon, menu, group and bar, the row and bulk action menus, the record quick-actions toolbar — reads it. Remove rather than enforce, because every one of them already takes the accessible name from the action's required `label` (visible text, or `aria-label` on an icon-only action), and the node that places the actions carries the node-level `aria` block — a per-action block would be a second spelling of both. The D2 conversion `action-aria-removed` STRIPS the key from stack actions and object-nested actions as a pure lossless delete, retired from the load path so authors are refused at parse; its D3 record is the semantic entry `action-aria-retired`. It also retires the connector resilience family (ADR-0049 enforce-or-remove, one batch): `connector.health` — the `healthCheck` probe (eight keys) and the `circuitBreaker` (six) — `connector.status` and the connector-nested `webhooks`, sixteen authorable keys with no reader outside the spec package. No loop ever polled a connector endpoint or tripped a breaker; nothing read an authored `status` (the runtime publishes a computed `state`, and participation is `enabled`); and a webhook nested in a connector was never registered as a `webhook` item, so it was never materialized or delivered — the top-level `webhooks:` collection is the delivered one. The three carrier keys are retiredKey tombstones on `ConnectorBaseSchema`, registered under both carrier defs; `status`, defaulted `'inactive'`, joins `connectionTimeoutMs` in the retired-default residue stage, because every 17.x parse emitted it into every connector. Seven defs leave whole — `ConnectorHealth`, `HealthCheckConfig`, `CircuitBreakerConfig`, `ConnectorStatus`, `WebhookConfig`, `WebhookEvent`, `WebhookSignatureAlgorithm` — and the D2 conversion `connector-resilience-keys-removed` strips the three keys from `connectors[]` and stored rows as a pure lossless delete (the nested webhooks are stripped, never moved: moving them would start deliveries that never happened). It ABSORBS the breaker half of the duration rename above: `health.circuitBreaker.monitoringWindow` → `monitoringWindowMs` is no longer converted, because the whole block it lived in is now removed. Finally it makes edge-branched `decision` nodes EXCLUSIVE (maintainer ruling 2026-09-23, 「跟主流对齐」): the first conditioned out-edge that holds, in declaration order, is the branch, and taking every true branch is the declared `mode: 'inclusive'`. The D2 conversion `flow-decision-mode-inclusive-explicit` writes that key onto every decision with two or more conditioned out-edges and no `conditions` list, so a flow written while every true branch ran keeps its behaviour; it is a default flip, so it is retired from the load path AND refused by the flow rehydration seam and the artifact-ingestion door, and replays only here — the paired semantic entry carries the judgment the diff then asks for. BREAKING for flows stored in `sys_metadata`, by maintainer ruling: such a decision with no `mode` takes the first-match meaning on upgrade and nothing rewrites it; `os migrate meta --stored` lists each one for review, and `mode: 'inclusive'` is the one-line fix where a node meant every branch. It also retires the list view's own `tabs` (ADR-0049 enforce-or-remove). The key parsed and was stored at every list-view door and drew nothing: a list view's own `tabs` has no reader, the one component that would draw it has no production mount, and the tab strip above an object's records is the saved-view switcher, which renders one tab per `listViews` entry and reads no `tabs` key (`userFilters.tabs`, a different key of the same element type, is read and rendered, and stays). The key is a `retiredKey()` tombstone on the list-view shape (its prescription says how to move each tab to a named `listViews` entry); `ViewTabSchema` itself stays, because the page-only `userFilters.tabs` preset bar reuses it and renders. The D2 conversion `view-list-tabs-removed` strips the key from every list payload in `stack.views[]` as a lossless delete, and is retired from the load path, so authors are refused at parse rather than rewritten. It retires the inner `name` on cube members — `measures..name` and `dimensions..name` (ADR-0049 enforce-or-remove) — by the mainstream criterion: Cube.dev and LookML key a member by its declared name, with no second inner name that can disagree. Both member bags are records, and every consumer already resolved a member by its record KEY, publishing and querying it as `.`; the REQUIRED inner copy was read by nothing, and one that disagreed with its key was silently ignored. The keys are retiredKey tombstones on `MetricSchema` and `DimensionSchema`, and because the key was required, every stored or built cube carries it: the D2 conversion `cube-member-inner-name-removed` strips it from every member of every cube, retired from the load path, and its notice prints a disagreeing value beside the key that stays. Its D3 record is the semantic entry `cube-member-inner-name-retired`, which asks the author of a disagreeing name which spelling they meant. It also retires the connector `triggers` array (ADR-0049 enforce-or-remove; ADR-0041 keeps connector-event triggers in its third tier, as their own trigger package): the `ConnectorTrigger` shape — `key`, `label`, `description`, `type` (`polling` / `webhook`) and `intervalSeconds` — was read by nothing. The automation engine registered a connector's actions only, its trigger registry holds FLOW trigger kinds that no connector trigger ever entered, no polling loop read an interval and no receiver was driven by a `webhook` trigger, so a declared trigger never started a flow. `triggers` is a retiredKey tombstone on `ConnectorBaseSchema`, registered under both carrier defs; the provider-bound refusal of the key, whose reason (the provider derives triggers) was untrue, is gone with it, since the tombstone refuses every value on every carrier. `ConnectorTrigger` leaves whole, and the D2 conversion `connector-triggers-removed` strips the array from `connectors[]` and stored rows as a pure lossless delete — never turning a trigger into a flow, which is the author's decision (an `api` flow for an external event, a `schedule` flow for a scheduled pull, each calling the connector's action). It ABSORBS the trigger half of the connector duration rename (its breaker half went with `health` above), so `connector-health-and-trigger-durations-unit-in-key`, with neither half left, is no longer in this step. It also retires a cube's `refreshKey` whole — the refresh cadence `every` and the data-change probe `sql` (ADR-0049 enforce-or-remove). Nothing read either key, and no analytics result is cached, so a declared cadence refreshed nothing and every query was computed when it was asked, as it still is. The key is a retiredKey tombstone on `CubeSchema`, and the D2 conversion `cube-refresh-key-removed` strips the whole block from every cube as a pure lossless delete, retired from the load path. Its D3 record is the semantic entry `cube-refresh-key-retired`. A refresh cadence is declared again when a result cache exists. It also narrows the `time` stored form to the zone-less wall clock the record validator already enforces (ADR-0053 D-C1), so a field default or an action param default or value with a `Z` or a UTC offset is refused when it is authored or submitted rather than on every insert that falls back to it. The D2 conversion `time-default-utc-suffix-dropped` drops a `Z` or a zero offset, which names the same wall clock, and leaves a non-zero offset as stored for its author to rewrite; its D3 record is the semantic entry `time-default-zone-refused`. It also retires the page header's `breadcrumb` switch (ADR-0049 enforce-or-remove): no renderer ever drew a trail for it — objectui drew an empty slot that nothing filled — and the navigation trail is drawn once, by the app shell's header. The key is a retiredKey tombstone on `PageHeaderProps`, beside the `icon` that row lost at 17, and the D2 conversion `page-header-breadcrumb-removed` strips it from every `page:header`, `true` and `false` alike, retired from the load path. Its D3 record is the semantic entry `page-header-breadcrumb-retired`. The `nav:breadcrumb` component type is not part of it: the Studio page palette still offers it. It also retires connector-attached sync from the connector (ADR-0049, the ENFORCE route by ruling): `connector.syncConfig` — `strategy`, `direction`, `realtimeSync`, `timestampField`, `conflictResolution`, `batchSize`, `deleteMode`, `filters` — and `connector.fieldMappings` — `source`, `target`, `defaultValue`, `dataType`, `required`, `syncMode` — fourteen keys no engine ever executed, whose `latest_wins` and `soft_delete` defaults read as configured policy and did nothing. The capability is mainstream, so the definition moves rather than lapses: every mainstream platform binds a sync to its TARGET, so a `mapping` gains `connectorSource`, the `rest` / `openapi` connector it pulls from, the read action and an optional timestamp `watermark`, and a `job` sets the cadence (no schedule key returns to the connector). That binding is declared in this step and executed in a later one. Both connector keys are retiredKey tombstones on `ConnectorBaseSchema`, registered under both carrier defs; `DataSyncConfig`, `SyncStrategy`, `ConnectorConflictResolution` and `ConnectorFieldMapping` leave whole; and the D2 conversion `connector-sync-keys-removed` strips both keys from `connectors[]` and stored rows as a pure lossless delete — never writing a `mapping`, which would start writes that never happened. It also narrows an analytics cube member's `sql` — `measures..sql` and `dimensions..sql` — to a column reference: a field of the cube's object, a relationship path ending in one, or `'*'` (maintainer ruling D, ADR-0021 "zero raw SQL / zero raw expressions" carried from the dataset layer to the cube members it compiles to; ADR-0049 enforce-or-remove). A SQL expression there names no single field, so no platform check could judge which fields it reads, and the two analytics strategies never agreed on it: the raw-SQL path ran it verbatim, the ObjectQL path refused it. It is now refused at parse with a prescription naming the ADR-0021 dataset form — a measure with its own structured `filter` for a conditional count or sum, and `derived: { op, of: [...] }` over named measures for a ratio, sum, difference or product. No D2 conversion: an expression has no mechanical rewrite into a dataset, so the semantic entry `cube-member-sql-expression-retired` carries the move, including the scale change a ratio makes (a `derived` ratio is a 0–1 fraction). It also closes the form view's inline grid columns: `subforms[].columns`, on `view.form` and on `formViews` entries, was `z.array(z.any())` while a relationship field's `inlineColumns` was already the strict `InlineGridColumnSchema`, so a mis-keyed column published clean and drew a blank grid column, and `scale` on a currency column, which the other carrier refuses under the maintainer's rulings of 2026-09-23 (option B) and 2026-09-24 (option 乙), published green. The carrier now references that schema, so both carriers are judged by it, with its own prescriptions. The D2 conversion `form-view-subform-columns-canonicalized` respells a `{ field }` column as `{ name }`, the respelling `field-column-lists-canonicalized` makes on `inlineColumns`: it rewrites stored rows and assembled artifacts and lists the edit under `os migrate meta`, and it is retired from the load path, so an author writing `field` meets the refusal. A view saved with a failing column is refused with the column schema's prescription, and a stored row carrying one is diagnosed at rehydration; neither is stripped, because which column an unknown key or a mixed `field`/`name` entry meant is the author's call, and a conversion that dropped the key would accept at load what the parse now refuses. Its D3 record is the semantic entry `form-view-subform-columns-closed`. On both carriers, the reach of `inline-grid-column-currency-scale-refused` extends to a column that declares no `type`: such a column takes its type from the child field, which the column schema cannot see, when the console hydrates it, so `defineStack`'s cross-reference check re-parses a column whose `name` is a `currency` field of the child object as the type it renders as, and the refusal of its `scale` is the column schema's own. Reach: the child object must be declared in the same stack; a column naming no field of it, or a subform whose child object comes from another package, is not judged there. It has no D2 conversion, for the declared-type entry's reason: deleting the key is the migration, and a conversion that dropped it would accept it at load, the grace window ruling B refused. Its D3 record is the semantic entry `inline-grid-column-identity-only-currency-scale-refused`. It also gives the executor target of an action one spelling on the page blocks that run one. `ActionSchema` has always refused `endpoint` with the rename to `target`, while the `action:button` and `action:icon` component rows declared `endpoint` as a key of their own, and the console's `api` handler reads `target` only — so an `api` button authored with `endpoint` was accepted by the props gate and called nothing. The rows now refuse it with the same rename, read from the one alias table both share. The D2 conversion `action-block-endpoint-to-target` renames the key on an `api` action, where the rename is lossless, retired from the load path so authors are refused at the door while stored rows and `os migrate meta` replay it; an `endpoint` on a block with no `actionType` or another one is left as stored and reported as a TODO. Its D3 record is the semantic entry `action-block-endpoint-spelling-retired`. Finally, it retires the form field's `publicPicker` block (ADR-0087 D2, immediate — the maintainer's ruling E, which reverses the earlier ruling that had declared it): an anonymous public form no longer offers record search. The block opted a lookup, `master_detail` or `user` field on a public form into a picker served by an unauthenticated route; that route is deleted, and the public-form resolve route now leaves those three field types off the anonymous rendering unconditionally. The schema refuses the key with the prescription; the mechanical conversion `form-field-public-picker-removed` strips it from old sources and stored rows (lossless in effect — its only reader was the deleted route), and the semantic entry asks the author how a visitor should now choose: a `select` field with static `options`, or a form behind sign-in. It also closes the third carrier of the inline grid column: an `object-master-detail-form` page block's `details` was `z.array(z.unknown())`, so a key its renderer does not read and `scale` on a currency column, which the other two carriers refuse under the maintainer's rulings of 2026-09-23 (option B) and 2026-09-24 (option 乙), went through `objectstack validate` green. Each detail entry is now a strict shape of the twelve keys the renderer reads, and its `columns` references `InlineGridColumnSchema`. Page-component `properties` is read by the component-props gate, which reports a failing entry or column as an advisory finding, and is not parsed on the metadata save or load path, so a stored page still saves and loads and no conversion is registered; the authored census found nothing to respell. `defineStack`'s identity-only check reaches the block wherever a page carries it, with the reach `inline-grid-column-identity-only-currency-scale-refused` records for the other two carriers. Its D3 record is the semantic entry `ui-object-master-detail-form-details-closed`. It closes the fourth carrier the same way: `record:line_items` had no `ComponentPropsMap` row — it was the one entry on the string-arm registration ledger — so the component-props gate skipped its props, and the showcase project page's five `field`-keyed columns published green over a grid of empty cells. The row declares the fifteen keys the renderer reads, requires `relationshipField` and at least one column, and its `columns` references `InlineGridColumnSchema`; the showcase columns are respelled `name` in the same change. The panel draws its columns as authored, with no hydration from the child object's field, so `defineStack`'s identity-only check does not reach it. Its D3 record is the semantic entry `ui-record-line-items-props-closed`. It also holds an ADR-0021 dataset's `field` — `dimensions[].field` and `measures[].field` — to the accept set the cube members it compiles to already hold, from one shared declaration: a field of the dataset's object, a relationship path ending in one, and on a measure also `'*'` (ADR-0021 "zero raw SQL / zero raw expressions"; ADR-0049 enforce-or-remove). The slot was a bare string that parsed any expression, while the analytics dataset door already refused one on every query, so an expression could be saved and never answered. It is now refused at parse with a prescription naming the ADR-0021 form — a measure with its own structured `filter`, or `derived: { op, of: [...] }` over named measures — and so are an empty string (a count omits `field` instead) and `'*'` on a dimension, which names no axis. The one lossless repair is D2: `dataset-count-measure-empty-field-removed` drops a `count` measure's empty `field`, which still counts rows. An expression has no mechanical rewrite into a column, so the semantic entry `dataset-member-field-expression-refused` carries the rest. It also closes the export options of an `object-grid` page block. `exportOptions` was `z.unknown()`, so a bare format array — the list view's legacy spelling, which the list view lifts to `{ formats }` — was accepted on the grid, whose renderer reads `exportOptions.formats` and lifts nothing: the export menu offered its csv/json default and the author's list was dropped. The row now takes the list view's five-member export options object by identity, not the list view's union, and refuses a bare array with the object form named, a format outside the enum and an undeclared key. Page-component `properties` is read by the component-props gate, which reports these as advisory findings, and is not parsed on the metadata save or load path, so a stored page still saves and loads and no conversion is registered: the bare array never worked here, and lifting it would change the menu a deployed grid shows. The authored census found nothing to respell. Its D3 record is the semantic entry `ui-object-grid-export-options-closed`. It also makes an agent's structured output JSON-only (ADR-0049 enforce-or-remove). The cloud AI runtime, which executes agents, enforces `structuredOutput` on every final answer and refused four of its members before an agent's first turn: the `regex`, `grammar` and `xml` formats — no key ever carried a pattern or grammar to check against, and an answer is checked only as JSON — and the `coerce_types` step, for which no coercion engine exists. All four are refused at parse with a prescription, and the D2 conversion `agent-structured-output-refused-members-removed` deletes a block whose `format` was retired, deletes a retired `fallbackFormat` and drops `coerce_types` from the pipeline, retired from the load path. It also retires the metric sub-caption at both ends (maintainer ruling 2026-10-01, which reverses the 2026-08-06 ruling that gave it a translation key of its own; ADR-0049). The widget translation key `dashboards..widgets..subCaption` overlaid a widget's `options.description`, a key the dashboard schema never declared and no authored widget wrote, so the overlay in `translateDashboard` was its only writer. The overlay is removed, `subCaption` is a `retiredKey()` tombstone on the widget translation node, and its former `subtitle` alias now carries the retirement instead of a rename onto a key that accepts nothing. A widget keeps one authored description, `widget.description`, which renders as the card-header subtitle and is translated by the widget's `description` key. The D2 conversion `translation-widget-sub-caption-removed` strips the key from bundle entries and stored translation items as a lossless delete of what is served, retired from the load path so authors are refused at parse; its D3 record is the semantic entry `translation-widget-sub-caption-retired`. It also makes an agent's memory contract state exactly what the runtime honours (ADR-0049 enforce-or-remove). The cloud AI runtime, which executes agents, recalls the newest `maxEntries` long-term notes before the first round, writes one every `reflectionInterval` delivered interactions, and keeps them in its own database store; before an agent's first turn it refused the `vector` store (the old default) and `redis`, an enabled `longTerm` missing either number, and a `reflectionInterval` without one. So `longTerm.store` is retired as a whole key — the memory store is platform infrastructure, not agent metadata — and the D2 conversion `agent-memory-long-term-store-removed` deletes it, losslessly, retired from the load path; and with long-term memory enabled both numbers are required at authoring, with no default declared, so an upgrading author chooses them. It also retires an agent's conversation state machine, `agent.lifecycle` (ADR-0049 enforce-or-remove). It was parsed and never read: no runtime moved an agent through a declared state or refused an undeclared transition, and enforcing it would have meant a statechart interpreter beside Flow, the two-engine shape ADR-0020 rejected. What it reached for is served elsewhere — a conversation phase is a skill selected by its `triggerConditions`, a multi-step process is a Flow, a record's status transitions are the `state_machine` validation rule — so authoring refuses the key with that prescription, and the D2 conversion `agent-lifecycle-removed` deletes it, losslessly, retired from the load path. The XState `StateMachineSchema` family, kept by ADR-0020 only for this door, left the package with it. It also retires a cube measure's custom-SQL-expression types — `number`, `string` and `boolean` from `AggregationMetricType`, so from `measures..type` (ADR-0049 enforce-or-remove). They marked a measure whose `sql` was the whole computation, and with that `sql` now a column reference they had nothing left to compute: the raw-SQL path returned the column unaggregated and the ObjectQL path refused the measure. Each is refused at parse with a prescription naming the six aggregates. No D2 conversion: the column alone does not say which aggregate the author meant, so the semantic entry `cube-metric-expression-types-retired` carries the choice, and a stored cube that still carries one is refused rather than rewritten. It also retires `object-grid`'s `resizableColumns` (ADR-0049 enforce-or-remove; objectui's ruling that `resizable` is canonical, under the startup rule of immediate retirement): the legacy second spelling of `resizable`, read only as `schema.resizable ?? schema.resizableColumns` (measured at the `.objectui-sha` pin `89cad75d55`, `plugin-grid/src/ObjectGrid.tsx:5361`). One switch, two spellings, and zero writers in either repository, so there is no window. A retiredKey tombstone on `ObjectGridPropsSchema` with one D2 conversion that follows the renderer's precedence: the value moves to `resizable` when that is absent, and strips as a lossless delete when it is present (it was never read then). Its D3 record is the semantic entry `object-grid-resizable-columns-retired`. It also types seven members of an `object-grid` page block: `rowHeight`, `rowColor`, `navigation`, `conditionalFormatting`, `bulkActionDefs`, `aggregations` and `operations` were `z.unknown()` (an array of it for `bulkActionDefs`), although the grid reads each with one shape, so `rowHeight: 42` passed every door and rendered as `compact`. The five a list view also declares take the list view's own schemas by reference; `aggregations` takes the measured `[{ field, type }]` with the query AST's aggregation functions, and `operations` the four booleans a grid read point names (`create`, `update`, `delete`, `export`), refusing `read` and `import`, which nothing reads. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-grid-row-members-typed`. It also types `navigation` on the `object-map`, `object-gantt` and `object-tree` page blocks (the first stage of the `ComponentPropsMap` `z.unknown()` close-out): each renderer hands it to the shared navigation hook, which reads `navigation.mode` and falls back to `page`, so `navigation: 42` and a bare mode string passed every door and opened the record page. The three rows now take the list view's `NavigationConfigSchema` by reference, the carrier the grid, kanban, calendar and timeline blocks already take. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-map-gantt-tree-navigation-typed`. It also retires the `ai:chat_window` page element (ADR-0049 enforce-or-remove), the `user:profile` shape one namespace over: no renderer for it ever shipped, and none is wanted — the console leaves it unregistered on purpose, because the floating chat overlay it mounts on every page is the supported AI chat entry point — so a page that placed one validated clean and drew "Unknown component type", and its four props configured nothing. The name leaves `PageComponentType` and is refused by name at the node, its `ComponentPropsMap` row stays as a whole-bag refusal carrying the same prescription, and the props def `AIChatWindowProps` is unpublished. No conversion is registered: the only edit is deleting the node, a layout decision that is the author's. Its D3 record is the semantic entry `ui-ai-chat-window-retired`; `ai:suggestion` is unchanged. It also narrows page `requires` to the kinds whose source is compiled at save (ADR-0080 §5; maintainer ruling 2026-10-03, letter A): the plugin-namespace list is derived from an html page's source when the page is saved, while on `react`, `full` and `slotted` pages nothing derived it, the Studio page editor dropped it on every save, and a load-time warning was its one reader. `PageSchema` now accepts the key only when `kind` is `html` or its deprecated alias `jsx`, and refuses it at `requires` on every other kind, a page that omits `kind` included, naming the key, the page's kind and the compiled kinds. The key stays live on html pages, so there is no tombstone. The D2 conversion `page-requires-non-compiled-kind-removed` deletes the key from those pages, retired from the load path, so stored rows and artifacts replay clean while authored sources are refused until edited; the delete is lossless. Its D3 record is the semantic entry `page-requires-non-compiled-kind-refused`. It also types eight list members of the `object-grid`, `object-kanban` and `object-calendar` page blocks (the second stage of the `ComponentPropsMap` `z.unknown()` close-out): the grid's `fields`, `selection`, `selectable`, `rowActions`, `bulkActions` and `batchActions`, the kanban's `columns` and the calendar's `calendar` were `z.unknown()` (an array of it for the lists), although each renderer reads them with one shape, so a `{ name }` entry in `bulkActions` passed every door and was skipped. The members a list view declares take the list view's own by reference (`batchActions`, the spelling the grid reads first, takes `bulkActions`'s); the grid's `fields` and `selectable` and the kanban lane take the measured shape. The grid's `columns` stays open: its group headers draw an authored column's `options`, which the list view's column entry does not declare. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-grid-kanban-calendar-list-members-typed`. It also refuses, at parse, a hook whose `body` targets a table of stored metadata, `sys_metadata` or `sys_metadata_history` (maintainer ruling 2026-10-03, letter A: an app-authored body may not touch those tables, whose only writer for a body is the metadata protocol). The runtime already refused such a hook where a body becomes a handler, so it never ran, while the metadata save door answered 200 for it. `HookSchema` now refuses the same set at `object`, or at the list member, with the runtime's prescription to change metadata through the metadata API, judged by the one predicate the runtime uses: a hook with a `body` in any form whose target names either table. A code `handler` and the wildcard `'*'` stay outside it, as they are at registration. No key is removed, so there is no tombstone, and no D2 conversion exists: a refused hook carries no intent a rewrite could keep. Its D3 record is the semantic entry `hook-body-stored-metadata-target-refused`. It also types four members of the `object-form` page block (the third stage of the `ComponentPropsMap` `z.unknown()` close-out): `contentLayout`, `submitBehavior`, `navigateOnSuccess` and `mobile` were `z.unknown()`, although the form reads each with one shape, so a `submitBehavior` `kind` the form does not know passed every door and fell through to the thank-you panel. `submitBehavior` takes the form view's own block by reference; the other three take the measured shape. The form's `fields` and `sections` and the master-detail form's two stay open — the form draws a `{ name }` field entry and an inline runtime field inside a section, which the typed shapes would refuse — and `customFields` stays open until the spec declares the runtime form field its entries are. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-form-members-typed`. It also completes the `element:text` `variant` convergence (the second release of the ruled two-release split): the enum is the nine values `ui:text` publishes — `h1`-`h6`, `body`, `caption`, `overline` — and the pre-convergence spellings `heading` and `subheading`, which every release since the nine were added still accepted, are refused by name with a prescription naming the level to write. The D2 conversion `element-text-variant-heading-levels` rewrites `heading` to `h2` and `subheading` to `h3` on every `element:text` page component — the heading element each one always rendered, so the outline is unchanged and the heading takes that level's style. The `body` default for an absent `variant` is unchanged. It also types two members of the `object-metric` page block (the fourth stage of the `ComponentPropsMap` `z.unknown()` close-out): `aggregate` and `trend` were `z.unknown()`, although the tile reads each with one shape, so `aggregate: 'count'` and a trend with no `value` passed every door, and the tile asked the server for a measure it does not have, or painted a lone `%`. `aggregate` takes the query AST's aggregation functions and the chart aggregate's `groupBy` union by reference, with `groupBy` optional because a metric is one number; `trend` takes the badge's measured shape. `drillDown` and `compareTo` stay open: each by-reference candidate declares a key the tile never reads (the chart drill-down's `filter`, the dashboard comparison's `dimension`), and the chart drill-down refuses the `report` the tile draws, so each waits on a ruling. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-metric-aggregate-trend-typed`. It also retires an `object-master-detail-form` detail entry's `sortField` (ADR-0049 enforce-or-remove; the spec half of objectui's own retirement of the override). The console stopped reading the authored override: the field its line grid stamps with each line's position on drag-reorder is derived from the child object — its first field named `position`, `sort_order`, `sequence`, `line_no`, `line_number` or `sort` — and the pinned console had crossed that change while the spec still declared the key, so an authored value published green and was dropped. A retiredKey tombstone on the strict detail entry with one D2 conversion that is a pure lossless DELETE scoped by component `type` and by position (`properties.details[]`); its D3 entry `object-master-detail-form-detail-sort-field-retired` carries the one judgment left, whether the child object declares the field the line order is kept in. It also types the `object-metric` page block's `compareTo` (the fifth stage of the `ComponentPropsMap` `z.unknown()` close-out) to the tile's read, per the ruling between the reference and the read: `{ kind }`, with `kind` the dashboard widget comparison's own vocabulary by reference, and `dimension` refused by name, because this inline tile shifts the date macros in its own `filter` and never reads a dataset time dimension. A bare kind string, a kind outside the two and a `dimension` passed every door and compared the wrong window. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-metric-compare-to-typed`. It also types the `object-metric` page block's `drillDown` to the tile's read (the same stage and ruling): its five list members — `enabled`, `title`, `target`, `columns`, `maxRows` — are the chart drill-down's own by reference, and `filter` and `mode` are refused by name, because a metric tile has no click event for a drill filter to resolve against and no row for `mode` to open; both passed every door and were ignored. The drill `report` stays open: the tile draws a dataset-bound report, but the spec declares no drill report yet, and declares that contract first. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-metric-drill-down-typed`. It also types the `object-grid` page block's `columns` (the fifth stage of the `ComponentPropsMap` `z.unknown()` close-out), the list member the second stage held: the grid's group headers drew a column's `options`, which the list view's column entry does not declare, and objectui has since retired that read and takes the labels from the object field only. So the member takes the list view's own `columns` by reference — all field names or all column entries — and a column keyed `accessorKey` / `header` / `name`, a mixed list or an undeclared column key (`editable`, `options`, `reference`), which passed every door and drew no column or was ignored, is refused. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-grid-columns-typed`. It also refuses, at parse, a flow `create_record`, `update_record` or `delete_record` node whose `objectName` is the string `sys_metadata` or `sys_metadata_history` (the maintainer ruling of 2026-10-03, letter A, applied to flows: app-authored work may not write those tables, whose only writer is the metadata protocol). The runtime already refused such a node before any write, at its first run, while every authoring door accepted the flow. `FlowSchema` now refuses the same set at `nodes.N.config.objectName`, through the one judge `registerFlow` and `objectstack validate` share, with the runtime's prescription to change metadata through the metadata API: one of those three write nodes whose `objectName` names either table by exact name. A `get_record` node and a dynamic target stay outside it: the run judges the name it hands the data engine. No key is removed, so there is no tombstone, and no D2 conversion exists: a refused node carries no intent a rewrite could keep. Its D3 record is the semantic entry `flow-write-node-stored-metadata-target-refused`. It also types the top-level `fields` of the `object-form` and `object-master-detail-form` page blocks (the last stage of the `ComponentPropsMap` `z.unknown()` close-out), the two members the third stage held: the form drew a `{ name }` field entry its own page-builder guide taught, with a `label`, `type` and `required` it silently dropped, and objectui has since retired that entry from every authoring face, drawing only a stored one by its name. So both rows take field names, objectui's own declaration of the member, and refuse an object entry with what to write instead — a `{ name }` entry is its bare name, and a `{ field }` entry belongs in a section. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-form-fields-names-typed`. It also types the `object-gantt` page block's `markers` (the same stage): its entries were `z.unknown()` because the marker contract lived only in objectui, so a marker with no `date`, a numeric `date` or a misspelled member passed every door and the chart drew no line, or drew it unlabelled. The spec now declares objectui's own authoring declaration of a marker, `{ date, label?, color? }` with `date` a string, and the row takes it. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-gantt-markers-typed`. It also types the `object-timeline` page block's `mapping` (the same stage): the binding record — four optional field names for an entry's title, date, description and marker colour — was `z.unknown()` because its contract lived only in objectui, so a bare field name or a misspelled member passed every door and the rail drew the default field. The spec now declares objectui's own declaration of it, and the row takes it. The stage's other members — the metric drill-down's `report`, the form's `customFields` and both forms' `sections`, the timeline's `items` and the action containers' members — stay open: each contract has more than one viable shape that no ruling decides yet. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-timeline-mapping-typed`. It also types the `object-kanban` page block's `conditionalFormatting`, the one member the `ComponentPropsMap` `z.unknown()` close-out held for a ruling: it was `z.unknown()` while objectui's kanban also authored a native rule dialect the list view refuses, so `42` or a rule with no `style` passed every door and the board painted no card for it. objectui has since made the list view's `{ condition, style }` rule the member's only authoring dialect, and the board evaluates it with the grid's evaluator, so the row takes the list view's own member by reference, as `object-grid` does. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-kanban-conditional-formatting-typed`. It also requires every block of a `joined` report to bind a `dataset` (ADR-0021 single-form, enforced under ADR-0049 enforce-or-remove): the schema comment and the reports guide both said each block is dataset-bound, but the joined arm of `ReportSchema`'s refinement required only a non-empty `blocks`, so a block with no `dataset` parsed, passed `objectstack validate` and every save door, and drew nothing: the joined renderer issues no query for it, and a report whose blocks all lack one falls through to the pre-9.0 presentation bridge, which issues none either. The arm now refuses each such block at `blocks[i].dataset`, naming the block, with the prescription to bind it to a dataset; `dataset` stays optional on the block shape, which is read only on a `joined` report. No key is removed, so there is no tombstone, and no D2 conversion exists: only the author knows which dataset a block was meant to show. Its D3 record is the semantic entry `ui-report-joined-block-dataset-required`. It also types the `object-form` page block's `customFields`, one of the two contracts the `ComponentPropsMap` `z.unknown()` close-out held as forks and the maintainer has since ruled: each member is the runtime form field the form draws, which the spec did not declare, so a member with no `name` or a misspelled member passed every door and the form drew the field without it. The spec now declares a closed runtime form field of the members the form draws, in camelCase, keyed by `name` — the `grid` widget's snake_case keys stay out until the widget reads a camelCase spelling — and the row takes a list of it. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-form-custom-fields-typed`. It also types the `sections` of the `object-form` and `object-master-detail-form` page blocks, the other ruled fork: a section's `fields` draws an inline runtime form field beside a name and the form view's `{ field }` entry, which the stored form view's section refuses, so the sections stayed `z.unknown()` and a misspelled key passed every door. Both rows now take one page-block section shape of their own — the form view's section keys plus those three entry arms, the inline arm the runtime form field — in canonical spellings only: a page block's `properties` is never parsed on the way to the form, so a deprecated section `visibleOn` or a string `columns`, which a form view folds at parse, was dropped, and is refused with the canonical spelling. The stored form view is unchanged. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-form-sections-typed`. It then types the three members the stages above held open, as the maintainer ruled them on the decision card for those forks. The `object-metric` drill-down's `report` is `ReportSchema`, by reference (fork 1, letter B): it waited until a joined report refused a block that binds no dataset, and since then every report the member admits is one the drill drawer draws — a report with no `dataset`, a bare report name or a `{ name }` reference, which the drawer answered by listing the records, is refused. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-metric-drill-down-report-typed`. It types the `object-timeline` page block's `items` (fork 4, letter B): each entry is one of objectui's two ruled kinds, closed — a feed entry `{ time, title, description, variant, icon, content, className }` or a gantt row `{ label, items }` of bars `{ title, startDate, endDate, variant }`, each date a string or epoch milliseconds — and a row refinement pairs each entry with the kind the block's `variant` selects, so a feed entry with no `title`, or a gantt row on a feed timeline, is refused instead of drawn empty. A feed entry's `content` (child components) is held unjudged until a writer appears. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-timeline-items-typed`. And it types the members of the `action:group` and `action:menu` page blocks, the last of those forks (the same card, fork 5, letter A): each member was an open record the container draws and runs itself, so a misspelled key, a node-style `actionType` or an `endpoint` no `api` handler reads passed every door. A member now takes `action:button`'s keys with its executor spelled `type`, measured from the containers' reads — an `action:menu` item reads no `size` and declares none — with the rows' prescriptions; `outcomeMessages`, a member `className` and a member `properties.params` are refused, and `outcomeMessages` stays undeclared on all four action blocks as one decision. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-action-group-menu-members-typed`. It then closes the one static-values spelling those members still accepted and the containers drop: an `action:group` or `action:menu` member's `params` takes the input list, an `ActionParam[]` array, only, unless the member's `type` is `api`, whose object `params` keeps its request-payload window. `params` carries one shape and no second value-bag key is declared, so an object `params` on any other member, which parsed and then reached no action, is refused at `actions.N.params` with the prescription to author an action with static parameter values as its own `action:button` node. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-action-group-menu-member-params-array-only`. It also judges an `approval` flow node's `config` at parse against the contract the spec declares for it, `ApprovalNodeConfigSchema`, WHOLE. The approval executor fails the node on any issue of that contract, while `objectstack validate` and `objectstack compile` exited 0 on an undeclared `escalation.bogusKey` or a `timeoutHours: 0.5` and compile copied it into the artifact. The approval node now joins a declared contract map beside the builtin executor contracts, read by the one judge `registerFlow` and `objectstack validate` share, with no plugin loaded: an undeclared key or a refused value is refused at `nodes.N.config.` in the contract's own words, its did-you-mean included, and a key left out as before. The builtin arm stays presence-only. No key is removed, so there is no tombstone, and no D2 conversion exists: the platform cannot know what the author meant. Its D3 record is the semantic entry `flow-approval-node-config-contract-refused`. Then the builtin arm stops being presence-only: a present value a builtin node's executor contract refuses is refused at parse, at `nodes.N.config.`, with the same `node-config-refused-by-contract` code. Every builtin executor parses its config against that contract before it acts, so a `create_record` `outputVariable: 42` or a screen field `min: '1'` used to pass `objectstack validate` and `objectstack compile`, register, and fail every run that reached the node. The arm judges only what the build can know the run will parse: never a value carrying a `{token}`, whatever its slot's type (held back by ruling, not admitted: outside `http` such a token in a number or boolean slot still fails at its first run, so those slots take a literal); on `http`, which parses after interpolating, only token-free values and never the credential-held `signingSecret`; on a `loop`, only one with a `body`; on the region containers, never the region slots. Key membership is untouched. No key is removed, so there is no tombstone, and no D2 conversion exists: the platform cannot know the value the author meant. Its D3 record is the semantic entry `flow-builtin-node-config-values-refused`. It also retires the flat-list form of a package manifest's `permissions` (ADR-0049 enforce-or-remove): `ManifestPermissionsSchema` was a union of a list of permission strings and the structured ADR-0025 block `{ services, hooks, network, fs }`, and nothing ever acted on the list — the loader registers the consented grant set, never the manifest's request — so the block is now the only form. A list is refused at parse with its prescription, and the D2 conversion `manifest-permissions-string-list-removed` strips it from the stack's manifest and every `packages[].manifest` as a lossless delete, retired from the load path; translating what each dropped string meant into the four lists is the author's judgement, not a rewrite. It also makes a declared index state its uniqueness scope (ADR-0120 D1, staged to this protocol by D7). On `indexes[].unique`, bare `true` was the one spelling whose scope was positional: it built the index over exactly `fields`, one holder across the whole installation, while reading like "unique per organization" to an author who knew the field-level meaning. The parse now refuses it with a prescription naming both words — `'global'` (installation-wide, the index bare `true` built) and `'organization'` (one holder per organization). Field-level `unique: true` is untouched. The D2 conversion `declared-index-unique-scope` rewrites a declared index's bare `true` to `'global'`, which is lossless and drift-free by construction, retired from the load path so authors are refused at the door while stored rows, built artifacts and `os migrate meta` replay it. Its D3 record is the semantic entry `declared-index-bare-unique-true-retired`: whether each respelled index was really meant installation-wide is the author's call. It also takes the injected organization column off seven deployment-level platform tables — `sys_job`, `sys_job_run`, `sys_job_queue`, `sys_flow_dispatch`, `sys_migration`, `sys_migration_journal` and `sys_presence` (ADR-0131 D7). A writer census found no writer that attributes a row of any of them to an organization, so the column only ever held NULL, and under a walled posture the tenant wall hid every row from every reader. Each now declares `systemFields: { tenant: false }` and the object-level capability gate `requiredPermissions: ['manage_platform_settings']`: with no column there is no wall, so reads are governed by object permission, and the gate keeps one organization's administrator off another organization's rows. Nothing in stack metadata is rewritten; an existing database keeps the column as an orphan the boot drift report names, and `os migrate apply --allow-destructive` drops it. The D3 records are the seven `sys-*-organization-column-retired` semantic entries. It also refuses, at parse, a flow edge that does not resolve in its own graph or that repeats an earlier one. An edge's `source` and `target` must name nodes of the graph that declares it — the flow's own nodes, or the region body's for an edge inside a region — because the engine resolves them there alone, and a dangling edge carried the run nowhere, silently; and an edge with the same `source`, `target`, `type`, `condition` and branch `label` as an earlier edge of that graph is refused, because the engine runs a target once per out-edge it selects and a copy ran it again. Both are judged in the region walk the node-id rule uses, so `objectstack validate`, `registerFlow` and the metadata save door agree. No key is removed, so there is no tombstone, and no D2 conversion exists: a dangling endpoint carries no intent a rewrite could recover, and dropping a copy changes how often its target runs. Its D3 record is the semantic entry `flow-edge-unresolved-or-repeated-refused`. And the builtin arm judges key membership where no other door does: a key a `script` or `subflow` node's executor contract does not declare is refused at parse, at `nodes.N.config.`, with the same `node-config-refused-by-contract` code. Those two descriptors publish no `configSchema`, so `registerFlow`'s undeclared-key check skipped them, while their executors parse the strict contract and refuse the node on an undeclared key: a `script` `bogusKey` used to pass `objectstack validate`, `objectstack compile` and registration and fail every run that reached the node. Every other builtin keeps its undeclared keys at registration, against its descriptor; a retired `script` key keeps its tombstone. No key is removed, so there is no tombstone, and no D2 conversion exists: the platform cannot know what an undeclared key was meant to be. Its D3 record is the semantic entry `flow-script-subflow-config-undeclared-keys-refused`. It also moves the settings cascade's global rung out of the tenant-scoped `sys_setting` into the new tenant-less `sys_platform_setting` (ADR-0131 D7): one row per namespace and key for the deployment, no organization column, reads governed by the `manage_platform_settings` capability. The settings service writes a global-scope key there and reads the rung from there alone, and the `global` option of `sys_setting.scope` retires because no write reaches it. The cascade order and the `global` resolution source are unchanged. Nothing moves automatically: the v18 upgrade ceremony moves existing global rows, `sys_secret` handles included, and they open unchanged because the ADR-0128 AAD binds no holder object and no organization. The D3 record is the `sys-setting-global-rung-moved` semantic entry. It also retires the document family WHOLE (ADR-0049 enforce-or-remove; the ruling of record on PDF and print documents, letter B′, 2026-10-08: "A document is a page with a print declaration; no new template type"): the four defs of `data/document.zod.ts` — `data/DocumentTemplate` (a docx template with placeholders), `data/Document`, `data/ESignatureConfig` and the orphaned `data/DocumentVersion` — exported from `@objectstack/spec/data`, mounted by no stack key, registered as no metadata type and read by nothing in this repository, objectui or hotcrm, leave via RETIRED_DEFS_BY_MAJOR with one D3 semantic entry, so that "template" means one thing: a printable document is a page that declares `print`. The `ESignatureConfig` deadline-key tombstones leave with their def's source and their RETIRED_KEYS_BY_MAJOR[18] entries stay as history. It retires the single-brace `{…}` template dialect from the flow VALUE slots (the C half of the maintainer's ruling D on the flow expression dialects): the `assignment` node's values, in all three shapes, and the `fields` map of `create_record` and `update_record`, where a CEL value envelope is already the expression form. A string there is now the literal text it spells, and one carrying a `{…}` token is refused — by `FlowValueSlotSchema`, `registerFlow`, `objectstack validate` and the executor alike — with the CEL spelling of each token. No D2 conversion exists: every authored spelling was measured lossy (an absent key writes nothing under the template and fails under CEL; CEL divides two integers as integers), so which value an absent key should write is the author's judgment. The date macros and the `$User` paths keep their meaning until CEL can spell them. Its D3 record is the semantic entry `flow-value-slot-template-dialect-refused`. It also takes the injected organization column off the compliance ledger, `sys_audit_log` (ADR-0131 D7): some of its rows are about deployment-level actions no organization owns, so the organization a row is about stays in the attribution field `tenant_id`, which every writer already stamps, and never becomes the tenancy anchor. With no column there is no wall, so a platform administrator now reads the rows about no organization too; an organization reader is scoped to the rows about its active organization by the platform row policy `sys_audit_log_org`, stripped when no wall is enforced, and `organization_admin` names the ledger without the superuser bits so its wildcard bypass cannot skip that policy. Per-tenant retention partitions on `tenant_id`. Nothing moves automatically: an existing database keeps the column as an orphan the boot drift report names, for the v18 ceremony to drop once its values are confirmed in `tenant_id`. The D3 record is the `sys-audit-log-organization-column-retired` semantic entry. Then the builtin key arm covers every builtin whose contract registration could judge: a key the executor contract of a `get_record`, `create_record`, `update_record`, `delete_record`, `notify`, `http`, `screen`, `map`, `loop` or `parallel` node does not declare is refused at parse, at `nodes.N.config.`, with the same `node-config-refused-by-contract` code, closed with the rename-or-remove remedy. Registration's descriptor walk refused those keys already, after `objectstack validate` and `objectstack compile` had passed them, and it now stands aside for those types, so each has one judge; the declared key sets were measured equal first, so registration refuses what it refused before. `try_catch` waits for its contract's `retry` to close (below): it stripped an unknown key where its descriptor closes it. No key is removed, so there is no tombstone, and no D2 conversion exists. Its D3 record is the semantic entry `flow-builtin-node-config-undeclared-keys-refused`. It executes ADR-0032 Decision 3 in the flow TEXT slots — a `notify` node's `title` and `message`, a `screen` node's `title` and `description`, a refusing `end` node's `message`: they render through the formula template engine, so their placeholders are `{{ }}` holes, a variable path with an optional formatter (the engine's hole grammar now admits a `$`-named variable, so `{{ $error.message }}` is a hole). A single-brace `{…}` token there is refused — by the node contract, `registerFlow` and `objectstack validate` alike — with the hole spelling of each path token, or, for arithmetic, a function, a date macro or a run-user path, the `assignment` that computes it into a variable. No D2 conversion exists: the 17.x interpolator and the engine render a `Date` differently (JSON-quoted against ISO text), and a whole-slot object differently in a screen or `end` text, so the rewrite is the author's to check. Every other flow string keeps the single-brace dialect. Its D3 record is the semantic entry `flow-text-slot-single-brace-refused`. It also makes the deployment's platform-global declaration total (ADR-0131 D7): an object a deployment declares platform-global in its `org-scoping` service's `platformGlobalObjects` gets no organization column on that deployment, because the injected-columns plan reads the declaration, so the organization wall and the driver agree by having nothing to scope. The engine reads it at its plugin start, before the first schema sync, once every plugin init has run, and re-plans the objects registered before it; the security layer's stand-down for such an object retires with it. An absent declaration changes nothing, and a malformed one is refused and declares nothing. Nothing moves automatically: a declaring deployment's existing table keeps the column as an orphan the boot drift report names. The D3 record is the `platform-global-object-organization-column-retired` semantic entry. It also retires the `sys_view_definition` platform object as inert (ADR-0131 D13): no framework code wrote or read its rows, and runtime-authored views are `view` items in `sys_metadata`. The object, its two registrations, its `kernel:ready` active-row index migration and that migration's exports leave, and its name leaves the platform-object registry. Nothing in stack metadata is rewritten; an existing database keeps the table, which no platform path drops. The D3 record is the `sys-view-definition-retired` semantic entry. Then the retry policy closes, and `try_catch` joins the builtin key arm: `RetryPolicySchema`, the one declaration behind `job.retryPolicy` and a `try_catch` node's `retry`, refuses a key it does not declare, naming it with a did-you-mean, where it used to strip it — and with opt-in defaults a stripped `maxRetries` meant no retry at all. No writer relied on the strip. With `retry` closed to the five keys the descriptor declares, a key a `try_catch` node's contract does not declare is refused at parse, at `nodes.N.config.`, with the same `node-config-refused-by-contract` code, and the descriptor walk keeps plugin node types only. A `retryDelayMs` the conversion leaves beside a different `backoffMs` meets its tombstone there, as it met the walk. No key is removed, so there is no new tombstone, and no D2 conversion exists. Its D3 record is the semantic entry `try-catch-and-retry-policy-undeclared-keys-refused`. In those same text slots a `{{ }}` hole may root at a `$`-named variable only when the flow engine binds it (`$record`, `$runId`, `$flowName`, `$flowLabel`, `$error`, and a flat-graph `loop`'s `$loopItems` / `$loopIndex`): a hole such as `{{ $User.Id }}`, which the 17.x contracts accepted as a plain string and which renders nothing under the template engine, is refused by the node contract, `registerFlow` and `objectstack validate` with the remedy its single-brace spelling gets — compute the value with an `assignment` node, then write the variable as a hole. The template engine binds no new variable, and no D2 conversion exists: what the hole was meant to read is not in the flow. Its D3 record is the semantic entry `flow-text-slot-unbound-dollar-root-refused`. ### Mechanical (applied for you) @@ -949,9 +949,6 @@ AUTHOR-REACHABLE SURFACES: a saved report's `query.filter` (`sys_saved_report`) - **`flow-builtin-node-config-values-refused`** — `a builtin flow node (get_record, create_record, update_record, delete_record, notify, http, screen, script, subflow, map, loop, parallel, try_catch) whose config carries a value its executor contract refuses — a value of the wrong type (create_record outputVariable 42, a screen field min written as the string 1, get_record limit as a string, update_record multi as a string), a value outside the declared set or range (notify severity loud, screen mode view, loop maxIterations 0, try_catch retry.maxRetries above 10), an empty script function or subflow flowName, or a rule finding on present keys (a notify template beside an inline title). Never a value carrying a token in braces, an undeclared or retired key, a region slot, or an http signingSecret. Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, defineFlow(), an exported stack passed to objectstack validate or objectstack compile, a flow saved from the Studio flow designer, and a flow row already sitting in sys_metadata` → the value the contract declares, written at the key the refusal names: a string where it wants a string (`outputVariable: 'taskId'`), a number where it wants a number (`min: 1`, `limit: 10`, `maxIterations: 5`, `timeoutMs: 5000`), a boolean where it wants a boolean (`multi: true`, `durable: true`), one of the declared values (`severity: 'warning'`, `mode: 'edit'`), or a value inside the declared range. Outside `http`, a number or boolean slot takes a LITERAL only: those executors parse the config as authored, so a `{token}` template there (`limit: '{page.size}'`, `maxIterations: '{cap}'`) passes the build doors and still fails every run. Only `http` interpolates its config before it parses, so only an `http` slot may also take a sole-token template that resolves to the declared type (`timeoutMs: '{timeout}'`, `durable: '{durable}'`). For a rule finding, follow the rule's own sentence (keep `template` or the inline `title` / `message`, not both) - Why not automatic: Every builtin executor (`service-automation` `builtin/`) parses its node's `config` against the contract `getBuiltinNodeConfigContracts()` names before it acts, and refuses the node on any finding. The build doors judged only the keys that contract requires, left out, so a present value it refuses — `create_record` `outputVariable: 42`, a screen field `min: '1'` (the shape the Studio designer used to store for a field's Min / Max) — passed `FlowSchema.parse`, `objectstack validate` and `objectstack compile`, registered, and then failed every run that reached the node: the config is metadata, and no rerun could succeed. The one judge `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first) and `objectstack validate` share (`flowNodeConfigRefusals`) now refuses such a value as `node-config-refused-by-contract`, anchored at the key, in the contract's own words — the code the approval contract already uses. It judges only what the build can know the run will parse, and holds one more class back by ruling: a value carrying a `{token}` is never refused at the build doors for its pre-interpolation type — which is no promise it runs, since every builtin but `http` parses its config as authored and so still refuses a token in a number or boolean slot at its first run; `http` parses after interpolating its whole config, so only token-free values are judged there and never `signingSecret`, which the credential channel may supply; a `loop` with no `body` is not parsed by its executor and is judged for nothing; the region slots of `loop`, `parallel` and `try_catch` are judged as graphs of their own and by `validateControlFlow`. An undeclared or retired key, a `predicate` ledger slot (a screen field `visibleWhen`) and a `value` ledger slot (a CRUD `fields` value) keep the judges they had. ⚠️ No D2 conversion: the platform cannot know the value the author meant. ⚠️ Where such a node already sits the whole flow is refused: registered from the metadata registry or `sys_metadata` at boot it is skipped with a `warn` naming it, its trigger not armed, while the flows beside it register; a `defineStack({ flows })` source throws `StackSchemaInvalidError` for the whole stack; an artifact file is refused whole at load. ADR-0087, ADR-0031. - Done when: Run `objectstack validate` over every stack authored in config files, and boot every deployed stack. Each refusal names the node and the key: `FlowSchema.parse` anchors a `custom` issue at `nodes.N.config.` (`nodes.N.config.outputVariable`, `nodes.N.config.fields.0.min`, or the region path `nodes.N.config.body.nodes.M.config…`), `objectstack validate` prints the same path, and `validateStackExpressions` phrases it as `node 'mk' (create_record) config.outputVariable`. For each hit write the value the contract declares, per the replacement. Two proofs. (1) For a stack authored in config files, `objectstack validate` is clean. (2) Boot the stack and confirm each flow REGISTERS: no `failed to register flow` warn for it — that warn line is the locator for a row that exists only in `sys_metadata`. A node whose values its contract accepts parses and registers byte-identically to before. A `{token}` template parses and registers as before too, and runs only where the run parses it after interpolation (`http`) or where the slot takes a string; in a number or boolean slot of any other builtin it fails at its first run exactly as it did, so write a literal there. -- **`flow-cel-unbound-root-refused`** — `flows[].nodes[].config.condition, flows[].edges[].condition, a decision node's conditions[].expression, a screen node's fields[].visibleWhen, and the CEL value envelopes of an assignment node's assignments and of a create_record or update_record node's fields — a CEL source whose root identifier the flow does not bind, such as user.id, ctx.user.id or os.user.id` → current_user for the run's user: user.id, ctx.user.id and os.user.id become current_user.id, and a flow that can run without a user guards it as current_user != null ? current_user.id : null. Any other root becomes a name the flow binds — a declared variable, an outputVariable, an iterator, index or error variable, an assignment target, a node id or a screen field — or a field of the trigger record, read bare or as record.FIELD - - Why not automatic: A flow CEL expression evaluates in one scope per run: the flow's variables spread to top level, the trigger record's fields flattened beside them, and record, previous, vars and current_user bound by the engine. A root outside that scope fails the expression with an unknown-variable fault on every run that reaches it, and the build door, which judged a flow expression's syntax only, passed it. The run-user spellings are the reachable case: formulas, row-level security and the client bind the run's user under user, ctx.user and os.user as well, and flow CEL does not, because a top-level user would collide with a variable or a lookup field of that name. objectstack validate and the runtime publish gate now refuse such a root, naming current_user for the user spellings and the in-scope names for any other. A flow whose run can bind a name the reader cannot see is not judged: one with a script or connector_action node, a wait, subflow or map node a resume can fold a bag into, a screen with no field list, a plugin node type, or a trigger object the stack does not declare. No D2 conversion exists: none of these roots ever evaluated in a flow, so there is no behaviour to carry over, and a run without a user needs a guard only the author can choose. - - Done when: Run objectstack validate: it reports each refused root as expression-invalid at the flow, the node or edge and the slot, naming the root and its remedy. Replace each run-user spelling with current_user, guarded where the flow can run without a user, and bind or correct every other root. Re-run the flow paths that evaluate those expressions and confirm each one evaluates instead of failing with an unknown-variable fault. - **`flow-decision-branch-expression-absent-refused`** — `a decision node branch — an element of config.conditions[] — written without its expression key, or with expression: null, at any depth including an ADR-0031 region body. That includes a branch whose predicate sits under another key (condition is the edge spelling). Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, defineFlow(), an exported stack passed to objectstack validate, a flow saved from the Studio flow designer with a branch row whose expression cell is empty, and a flow row already sitting in sys_metadata` → the predicate the branch was meant to test, as non-blank bare CEL text under `expression` (`{ label: 'high', expression: 'record.amount > 10000' }`); a predicate written under `condition` moves to `expression`. To keep the branch and its label but never take it, write `expression: 'false'` — that is a CHANGE of behaviour, not a preserved one: a run that reached the branch used to fail there (`condition evaluation error`), and now routes on to the next branch or the declared fallback. ⚠️ Not by dropping a decision's only branch: with no `conditions` the node routes by its out-edges alone, so the out-edge that branch labelled is no longer held back - Why not automatic: `DecisionConditionSchema` declares a branch `{ label, expression }` with `expression` a required `z.string()`, but nothing parses a decision node's open config against it, and the expression-ledger resolver skipped an absent value as "not authored" — so a branch with no predicate passed `FlowSchema.parse`, `AutomationEngine.registerFlow` and `objectstack validate`, and the decision executor then handed `evaluateCondition` an envelope with no `source`, which it refuses: the build accepted what the run refused. The ledger now marks the slot `required` (reconciled against that schema's own `required` list), the resolver emits the absent value there, and all three doors refuse it through `predicateSlotRefusal`, leading with `PREDICATE_SLOT_STRING_REFUSAL` — the walk, function and sentence that already refuse the blank string. ⚠️ No D2 conversion: the platform cannot know the rule the author left out, and `'false'` would change what the flow does rather than keep it. ⚠️ Where such a branch already sits the whole flow is refused: registered from the metadata registry or `sys_metadata` at boot it is skipped with a `warn` naming it, its trigger not armed, while the flows beside it register; a `defineStack({ flows })` source throws `StackSchemaInvalidError` for the whole stack; an artifact file is refused whole at load. ADR-0087, ADR-0032. - Done when: Grep every flow node in `defineStack({ flows })` sources, exported stacks and every flow row in `sys_metadata` — including nodes inside a `loop` / `parallel` / `try_catch` region body — for a `decision` node whose `config.conditions[i]` has no `expression` key, or `expression: null`. Each refusal names the node and the branch: `FlowSchema.parse` anchors a `custom` issue at `nodes.N.config.conditions.I.expression` (or the region path `nodes.N.config.body.nodes.M.config…`), and `objectstack validate` prints the same path; `validateStackExpressions` phrases it as `node 'check' (decision) decision branch expression at config.conditions[0].expression`. For each hit write the predicate the branch was meant to test, or `expression: 'false'` where the branch should keep its label and never be taken. Two proofs. (1) For a stack authored in config files, `objectstack validate` is clean. (2) Boot the stack and confirm each flow REGISTERS: no `failed to register flow` warn for it (the three boot paths spell it `[Automation] failed to register flow`, `[Automation] flow re-sync: failed to register flow` and `[Automation] cold-boot flow bind: failed to register flow`) — that warn line is the locator for a row that exists only in `sys_metadata`. A branch carrying a non-blank predicate parses and registers byte-identically to before, a decision with no `conditions` still routes by its out-edges, and an absent screen field `visibleWhen` is still legal. @@ -973,18 +970,18 @@ AUTHOR-REACHABLE SURFACES: a saved report's `query.filter` (`sys_saved_report`) - **`flow-script-subflow-config-undeclared-keys-refused`** — `a script or subflow flow node whose config carries a key its executor contract does not declare — a typo (funtion), a key copied from another node type (a subflow timeoutMs written inside config, an approvers list on a script), or a key nothing reads (bogusKey). script declares function, inputs and outputVariable; subflow declares flowName, input and outputVariable. Never a retired script key (actionType, template, recipients, variables, script), which keeps its own path, and never a key on any other builtin node type, whose undeclared keys registration already judges against the node type descriptor. Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, defineFlow(), an exported stack passed to objectstack validate or objectstack compile, a flow saved from the Studio flow designer, and a flow row already sitting in sys_metadata` → the key the contract declares, or no key: rename a typo to the declared key it meant (`function`, `inputs`, `outputVariable` on a `script`; `flowName`, `input`, `outputVariable` on a `subflow`), move a value the function or the child flow should receive into `inputs` (script) or `input` (subflow), move a `subflow` timeout to the node itself (`{ id, type: 'subflow', timeoutMs: 30000, config: { … } }`), and delete a key nothing reads. The refusal carries the contract's own sentence, with its did-you-mean for a near miss - Why not automatic: The `script` and `subflow` executors (`service-automation` `builtin/screen-nodes.ts`, `builtin/subflow-node.ts`) parse the node's `config` against a strict contract (`ScriptConfigSchema`, `SubflowConfigSchema`) before they act, and refuse the node on an undeclared key. No door before the run judged one: `registerFlow`'s undeclared-key check derives the declared set from the node type descriptor's `configSchema`, and these two descriptors publish none (the schemaless class, `SCHEMALESS_NODE_CONFIG_SCHEMAS`), while the build doors' executor-contract arm judged required keys and present values but held key membership back on the premise that registration judges it. So a `script` node carrying `bogusKey` passed `FlowSchema.parse`, `objectstack validate` and `objectstack compile` (which copied the key into the artifact), registered, and then failed every run that reached the node: the config is metadata, and no rerun could succeed. The one judge `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first) and `objectstack validate` share (`flowNodeConfigRefusals`) now refuses such a key on these two types as `node-config-refused-by-contract`, anchored at the key, one refusal per key, in the contract's own words — the code the value half and the approval contract already use. Every other builtin keeps its undeclared keys where they were judged: at registration, against its descriptor, with that check's own prescriptions. `decision` is schemaless too, but its executor parses no contract, so an undeclared key there fails no run and stays unjudged. A retired `script` key keeps its tombstone path. ⚠️ A spelling the ADR-0087 D2 conversion `flow-node-script-config-aliases` or `flow-node-subflow-flow-alias` still rewrites at load (`functionName`, `input` on a `script`; `flow` on a `subflow`) is converted before the judge at every door that converts first; met by a direct `FlowSchema.parse` or `defineFlow()` it is refused like any other undeclared key, as the missing canonical key already was. ⚠️ No D2 conversion: the platform cannot know what an undeclared key was meant to be. ⚠️ Where such a node already sits the whole flow is refused: registered from the metadata registry or `sys_metadata` at boot it is skipped with a `warn` naming it, its trigger not armed, while the flows beside it register; a `defineStack({ flows })` source throws `StackSchemaInvalidError` for the whole stack; an artifact file is refused whole at load. ADR-0087, ADR-0031. - Done when: Run `objectstack validate` over every stack authored in config files, and boot every deployed stack. Each refusal names the node and the key: `FlowSchema.parse` anchors a `custom` issue at `nodes.N.config.` (`nodes.N.config.bogusKey`, or the region path `nodes.N.config.body.nodes.M.config…`), `objectstack validate` prints the same path, and `validateStackExpressions` phrases it as `node 'summarize' (script) config.bogusKey`. For each hit rename, move or delete the key per the replacement. Two proofs. (1) For a stack authored in config files, `objectstack validate` is clean. (2) Boot the stack and confirm each flow REGISTERS: no `failed to register flow` warn for it — that warn line is the locator for a row that exists only in `sys_metadata`. A `script` or `subflow` node whose keys its contract declares parses and registers byte-identically to before. -- **`flow-text-slot-single-brace-refused`** — `flows[].nodes[].config of a notify node (title, message), a screen node (title, description) and an end node (message) — a string, or the source of a template envelope, carrying a single-brace template token` → a double-brace template hole, rendered by the formula template engine over the flow's variables: a variable path with an optional formatter, {{ record.name }}, {{ $error.message }}, {{ rows.0.subject }}, {{ record.amount | currency }}. A token no hole can spell is computed into a variable first, with an assignment node — arithmetic and functions as a CEL value envelope, the date macros as the value-slot spelling that still reads them, the run user's id as the CEL value envelope current_user.id — and written as {{ variable }} +- **`flow-text-slot-single-brace-refused`** — `flows[].nodes[].config of a notify node (title, message), a screen node (title, description) and an end node (message) — a string, or the source of a template envelope, carrying a single-brace template token` → a double-brace template hole, rendered by the formula template engine over the flow's variables: a variable path with an optional formatter, {{ record.name }}, {{ $error.message }}, {{ rows.0.subject }}, {{ record.amount | currency }}. A token no hole can spell is computed into a variable first, with an assignment node — arithmetic and functions as a CEL value envelope, the date macros and the run-user paths as the value-slot spelling that still reads them — and written as {{ variable }} - Why not automatic: ADR-0032 Decision 3 fixes one template delimiter, double braces, and deletes the single brace: it collides with CEL map literals, and an author who meets both dialects in one flow mixes them. The 17.x interpolator and the template engine render the same text for a path holding a string, a number, a boolean, null, an absent key or variable, an ISO date string, an object or an array, but not for every value — a Date rendered JSON-quoted under the interpolator and as its ISO text under the engine, and a screen title, screen description or end message that was one token holding an object, an array or a Date rendered String(value) — so no conversion is lossless (ADR-0087 D2) and none is applied. Arithmetic, function calls, the date macros and the run-user paths have no hole spelling: a hole is a path with a formatter, never logic. A flow carrying a single-brace token in a text slot is refused at registration, by objectstack validate and by the node contract; a stored flow carrying one is skipped at boot with a warn naming it. - Done when: Run objectstack validate: it reports each refused text slot as expression-invalid at the node and the slot's key, with the double-brace spelling of every path token. Rewrite each slot as that spelling; for a token no hole can spell, add the assignment the refusal names and write its variable as a hole. Re-run the flow paths that send those notifications or show those screens and compare the text with the text the 17.x renderer produced — in particular any slot that renders a date value or a whole object. -- **`flow-text-slot-unbound-dollar-root-refused`** — `flows[].nodes[].config of a notify node (title, message), a screen node (title, description) and an end node (message) — a string, or the source of a template envelope, carrying a double-brace hole whose root is a dollar-named variable the flow engine does not bind, such as {{ $User.Id }}` → a variable the run has, written as a hole. The run user's id is computed first, with an assignment node whose CEL value envelope reads current_user, the run's user (assignments: { by: { dialect: 'cel', source: 'current_user.id' } }), then written as {{ by }}; every other run-user path never resolved in any shipped run, and an email or a name is read from the user record by current_user.id. A variable the flow binds itself (a declared variable, an assignment target, an outputVariable, a try_catch errorVariable) is named without the dollar sign and written as {{ caught.message }}. The engine's own variables stay holes: {{ $error.message }}, {{ $record.name }}, {{ $runId }}, {{ $flowName }}, {{ $flowLabel }}, and a flat-graph loop's {{ $loopItems }} / {{ $loopIndex }} +- **`flow-text-slot-unbound-dollar-root-refused`** — `flows[].nodes[].config of a notify node (title, message), a screen node (title, description) and an end node (message) — a string, or the source of a template envelope, carrying a double-brace hole whose root is a dollar-named variable the flow engine does not bind, such as {{ $User.Id }}` → a variable the run has, written as a hole. The run user is computed first, with an assignment node whose value slot still reads the run-user path (assignments: { by: '{$User.Id}' }), then written as {{ by }}. A variable the flow binds itself (a declared variable, an assignment target, an outputVariable, a try_catch errorVariable) is named without the dollar sign and written as {{ caught.message }}. The engine's own variables stay holes: {{ $error.message }}, {{ $record.name }}, {{ $runId }}, {{ $flowName }}, {{ $flowLabel }}, and a flat-graph loop's {{ $loopItems }} / {{ $loopIndex }} - Why not automatic: The dollar-named variables are the flow engine's own: it binds $record, $runId, $flowName, $flowLabel and $error, and a flat-graph loop binds $loopItems and $loopIndex. A hole over any other dollar name answers to no variable — {{ $User.Id }} looks like the run user and is not one, since the run user has no hole spelling. In 17.x the slot was a plain string read by the single-brace interpolator, which substituted the inner token and left a literal brace on each side; the 18 text slots render holes through the template engine, where such a hole renders nothing and the run reports success. It is now refused by the node contract, at registration and by objectstack validate, with the remedy its single-brace spelling gets; a stored flow carrying one is skipped at boot with a warn naming it. No D2 conversion exists: what the author meant the hole to read is not in the flow, and the template engine binds no new variable to answer it. - Done when: Run objectstack validate: it reports each refused text slot as expression-invalid at the node and the slot's key, naming the hole and its remedy. For a run-user hole, add the assignment the remedy names and write its variable as the hole; for a variable the flow binds under a dollar name, drop the dollar sign at the binding and in the hole. Re-run the flow paths that send those notifications or show those screens and confirm the text carries the value, with no stray brace and no missing fragment. - **`flow-trigger-record-credential-masked`** — `the record and previous roots a record-change flow receives — a password or secret field, and an internal field, of the triggering record, on every object` → read a credential through a privileged binder — the flow credential channel for an http node's signing secret, or a privileged server-side read such as the engine's resolveSecretField — never off `record` or `previous`; on those roots a set credential-class field now reads as the mask `SECRET_MASK`, an unset one as null, and an `internal: true` field is absent - Why not automatic: ADR-0100: a credential-class value leaves the engine only through a privileged dereference, and every generic channel serves the mask. The record-change trigger built a flow's record and previous from the engine's own write result, which keeps the stored row whole for privileged in-process callers, so a password field's plaintext, a secret field's stored handle and an internal field's value reached the flow — and from there its variables, a paused run's persisted state and that state's read doors. The trigger now projects both roots through the same helper every external write response uses: a credential-class field (secret, and password outside the exempt managedBy buckets) carries the mask, or null when unset, and an internal field is omitted. Every other field keeps its value, every other variable is untouched, and the engine's own write result, the stored row and the privileged read paths are unchanged. - Done when: No flow reads a password, secret or internal field off its trigger record or previous values expecting the stored value; a flow that needs a credential obtains it through a privileged binder; a start or edge condition that compared such a field against a literal is rewritten to test whether it is set (not null). -- **`flow-value-slot-template-dialect-refused`** — `flows[].nodes[].config of an assignment node (the assignments map, the legacy assignments array and the legacy bare config) and of create_record and update_record nodes (the fields map) — a string value, or a string anywhere inside an array or object value, carrying a single-brace template token, the run-user paths beginning $User. included` → a CEL value envelope, { dialect: "cel", source: "…" }, evaluated to the value: a path is the same path (record.owner; a numeric segment becomes an index, items[0]; a variable whose name starts with $ is read through vars, vars["$error"].message), arithmetic is the same arithmetic with every integer divisor written as a double (round(x * 100) / 100.0), and text with holes is one concatenation ('Hello ' + o.name). The run user's id, $User.Id, is current_user.id — current_user is the run's user, or null when the run has none — and in a flow that can run without a user it is current_user != null ? current_user.id : null, which writes null where the template wrote nothing, so on update_record it clears a stored value the template left alone. Every other run-user path ($User.Email, $User.Name, …) never resolved in any shipped run: current_user carries only what the run holds (id, positions, organizationId, isPlatformAdmin), and an email or a name is read from the user record by current_user.id (a get_record node on sys_user). A string with no token is the literal text it spells, and braces meant literally are a CEL string literal - - Why not automatic: The interpolator and the CEL engine answer differently for every token spelling authored in flows, so no conversion is lossless (ADR-0087 D2) and none is applied. A path, an absent variable, key or list index wrote nothing under the template and fails the run under CEL; text with a null hole rendered nothing and CEL refuses + null; CEL divides two integers as integers, so round(x * 100) / 100 truncates 123.46 to 123. Where a value may be absent, which of nothing, null or a default the field should take is the author's decision — the template decided it silently. The run user's id was the run's userId under the template, and nothing in a run with no user (a schedule, a record change made by a system write); the flow CEL scope binds current_user to the run's user and to null in such a run, never a pseudo-user, so current_user.id fails there and its guarded form writes null. The other run-user paths read a user object no run carries, so they wrote nothing in every run. One spelling is kept with its old meaning, because CEL cannot write it yet: the date macros NOW() and TODAY() with a day offset (CEL yields a Timestamp, not the ISO text, and has no string form for one). A flow carrying a refused value is refused at registration, by objectstack validate and by the executor; a stored flow carrying one is skipped at boot with a warn naming it. - - Done when: Run objectstack validate: it reports each refused value as expression-invalid at the node and the value's path, with the CEL spelling of its tokens. Rewrite each as that envelope; where a variable or key may be absent, guard it (has(record.owner) ? record.owner : null, has(vars.x) ? vars.x : null for a variable) or route around the node. For the run user, find which flows can run without one (a schedule, a record change a system write can make): there, guard current_user.id, or skip the node with a start condition or a decision on current_user != null where an update_record must leave the stored value alone. Re-run the flow paths that write those fields and compare the stored values with the ones the template wrote. +- **`flow-value-slot-template-dialect-refused`** — `flows[].nodes[].config of an assignment node (the assignments map, the legacy assignments array and the legacy bare config) and of create_record and update_record nodes (the fields map) — a string value, or a string anywhere inside an array or object value, carrying a single-brace template token` → a CEL value envelope, { dialect: "cel", source: "…" }, evaluated to the value: a path is the same path (record.owner; a numeric segment becomes an index, items[0]; a variable whose name starts with $ is read through vars, vars["$error"].message), arithmetic is the same arithmetic with every integer divisor written as a double (round(x * 100) / 100.0), and text with holes is one concatenation ('Hello ' + o.name). A string with no token is the literal text it spells, and braces meant literally are a CEL string literal + - Why not automatic: The interpolator and the CEL engine answer differently for every token spelling authored in flows, so no conversion is lossless (ADR-0087 D2) and none is applied. A path, an absent variable, key or list index wrote nothing under the template and fails the run under CEL; text with a null hole rendered nothing and CEL refuses + null; CEL divides two integers as integers, so round(x * 100) / 100 truncates 123.46 to 123. Where a value may be absent, which of nothing, null or a default the field should take is the author's decision — the template decided it silently. Two spellings are kept with their old meaning, because CEL cannot write them yet: the date macros NOW() and TODAY() with a day offset (CEL yields a Timestamp, not the ISO text, and has no string form for one) and the run-user paths beginning $User. (the flow CEL scope binds no user). A flow carrying a refused value is refused at registration, by objectstack validate and by the executor; a stored flow carrying one is skipped at boot with a warn naming it. + - Done when: Run objectstack validate: it reports each refused value as expression-invalid at the node and the value's path, with the CEL spelling of its tokens. Rewrite each as that envelope; where a variable or key may be absent, guard it (has(record.owner) ? record.owner : null, has(vars.x) ? vars.x : null for a variable) or route around the node. Re-run the flow paths that write those fields and compare the stored values with the ones the template wrote. - **`flow-write-node-stored-metadata-target-refused`** — `a create_record, update_record or delete_record flow node whose config.objectName is the string sys_metadata or sys_metadata_history, at any depth including an ADR-0031 region body` → Change metadata through the metadata API (`PUT /api/v1/meta/:type/:name`, the metadata protocol), where it is validated and its provenance is recorded. Delete the node, or point its `objectName` at the object the flow really means to write. Elevation (`runAs`, a system context) does not change this. - Why not automatic: `FlowSchema` accepted a `create_record`, `update_record` or `delete_record` node whose `objectName` names `sys_metadata` or `sys_metadata_history`, the tables that hold stored metadata. The maintainer ruled (2026-10-03) that app-authored work may not write those tables: the metadata protocol is their only writer, where a change is validated and its provenance recorded, and a flow is app-authored automation. The runtime enforces that at the node, refusing the write before it resolves a filter, computes a field or calls the data engine, under every run identity; but every authoring door still accepted such a flow, and the author learned otherwise only at its first run. The parse now refuses it too, through the one judge `FlowSchema.parse`, `AutomationEngine.registerFlow` and `objectstack validate` share (`flowNodeConfigRefusals`), with the runtime's prescription: `objectstack validate`, `defineStack`, compile, an artifact's parse, `registerFlow` and the metadata save door each name the node at `nodes.N.config.objectName`. The refused set is exactly the runtime's: one of those three write nodes, whose `objectName` is a string naming a stored-metadata table by exact name. A `get_record` node is outside it (a read is not a write), and so is a dynamic target, a `{token}` template or an expression envelope: the parse cannot read it as a name, and the run judges the name it hands the data engine. No authored flow writing either table was measured in this repository, its examples, its skills or its docs. There is no mechanical rewrite: retargeting the node or deleting it each changes what the author wrote, and the runtime already never ran it. Where such a node already sits, the whole flow is refused: registered from `sys_metadata` at boot it is skipped with a warn naming it, its trigger not armed, while the flows beside it register. - Done when: `objectstack validate` reports no issue at a flow node's `config.objectName`: no `create_record`, `update_record` or `delete_record` node names `sys_metadata` or `sys_metadata_history`. Every change those nodes made to metadata is made through the metadata API instead. Saving each formerly affected flow through the metadata API succeeds instead of answering a 422 that names `config.objectName`, and boot logs no `failed to register flow` warn for it. @@ -1315,8 +1312,8 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-regis - Why not automatic: ADR-0049 enforce-or-remove; maintainer ruling 2026-09-06, option 3: keep a startup-result contract re-declared as the shape the kernel ships, and retire the rest. The module declared an orchestration design that never landed, and the spec and the kernel had already drifted into disagreement about the one shape that did: PluginStartupResultSchema described a plugin object, a required durationMs and a health member, while @objectstack/core shipped pluginName, an optional durationMs and timedOut. The ruling keeps a startup-result contract that describes what the kernel actually produces, and retires the rest. Re-measured on this card: zero implementers and zero consumers of the four retired surfaces in this repository and in the pinned objectui checkout, with lit same-corpus controls (defineStack, ManifestSchema); every remaining reference was a generated artifact or a released CHANGELOG.md. healthCheck and HealthStatus are the sharpest of the four: they name a per-plugin health probe the runtime has never had, the shape of the plugin sandboxing / integrity / approval config that was never wired to anything, which an AI author (ADR-0033) reads as proof the capability exists. With no authored document carrying any of the three defs there is no seam for a D2 conversion and no author to tombstone for: route 3, the shape of the dynamic plugin-loading family's removal and the advanced plugin-lifecycle config's retirement — RETIRED_DEFS_BY_MAJOR plus this entry ARE the declaration. The two keys of the SURVIVING result schema that leave (plugin, health) are tombstoned instead, and registered in RETIRED_KEYS_BY_MAJOR, because that def keeps emitting and its type is imported by @objectstack/core. A third key arrives on the spec surface only to leave it: core deprecated startTime alias, which held the same elapsed milliseconds as durationMs under a name that promises an instant. The re-declaration had to either mirror it or tombstone it, and mirroring is refused by check:duration-unit-keys (ruling B on duration-shaped number keys: the unit lives in the key name) since it is an elapsed number whose key name carries no unit and matches neither of that rule two schema-declared exemptions. So the L1 window closes here and the kernel stops populating it in the same change. - Done when: No code imports any of the 8 retired names from @objectstack/spec, @objectstack/spec/kernel or @objectstack/spec/contracts — every one is TS2305 after upgrade, pinned by resolved symbol identity in kernel/startup-orchestrator-retirement.test.ts. No metadata document needs editing: none of the three defs was reachable from a metadata-type binding, a stack collection or a manifest embed, so no authored document could ever carry one. PluginStartupResult SURVIVES on both entries with the shape the kernel ships — pluginName, success, optional durationMs, the serializable error projection, timedOut — and @objectstack/core now imports that type instead of declaring a twin, so the drift cannot recur. Writing plugin, health or startTime on a PluginStartupResult is a tsc error and a parse error carrying the rename or the deletion; a reader of the removed startTime alias reads durationMs, which has always carried the same value. Runtime behaviour is unchanged except for that one alias: nothing ever read the retired ORCHESTRATION surfaces, the kernel boot loop is untouched, and the only observable difference is that a startup result no longer carries startTime beside durationMs. - **`storage-scope-public-retired`** — `the storage scope public — the scope of an upload request, presigned or chunked, and ObjectStorageConfig.scope (StorageScope)` → another scope, or none for the default (`user` on an upload, `global` on a storage configuration), and `acl: 'public_read'` on the stored file record of each file that must be readable before sign-in (ADR-0104) - - Why not automatic: A storage scope never made a file publicly readable. The download doors judge a file by its `acl`, the `attachments` scope and field ownership alone, so a file uploaded with scope public and the default acl was stored private and needs a signed-in caller, while its scope said otherwise. The value is retired rather than enforced: enforcing it would let any uploader make a file anonymous at upload, and ADR-0104 keeps `acl: 'public_read'` the one opt-in for anonymous download. Whether a given file must be readable before sign-in is the caller's call, so no rewrite can make it: an upload that meant public needs its stored file record marked, and one that did not needs only another scope. The upload request itself carries no acl, and every upload is stored private. The stored file record retires the value too: the scope select of sys_file no longer lists public, so a deployment that stored files with it runs the one-time operator sweep that @objectstack/service-storage exports (`planSysFilePublicScopeBackfill` for the dry run, then `applySysFilePublicScopeBackfill`), which rewrites each of those records to scope user. No access changes: no reader tells user apart from public, the storage key and the file bytes are not touched, and those files download exactly as before. Until the sweep has run, a record write that names such a file while another field already owns it is refused, because the copy it makes carries scope public. ADR-0049 - - Done when: No upload call names scope public and no ObjectStorageConfig declares it; each upload that did now names another scope or none and is answered 200. Each file that must render before sign-in has acl 'public_read' on its stored file record, and fetching it with no session serves it; fetching any other uploaded file with no session is answered 401. On each deployment, a dry run of the sweep scans zero sys_file records with scope public. + - Why not automatic: A storage scope never made a file publicly readable. The download doors judge a file by its `acl`, the `attachments` scope and field ownership alone, so a file uploaded with scope public and the default acl was stored private and needs a signed-in caller, while its scope said otherwise. The value is retired rather than enforced: enforcing it would let any uploader make a file anonymous at upload, and ADR-0104 keeps `acl: 'public_read'` the one opt-in for anonymous download. Whether a given file must be readable before sign-in is the caller's call, so no rewrite can make it: an upload that meant public needs its stored file record marked, and one that did not needs only another scope. The upload request itself carries no acl, and every upload is stored private. Files already stored with scope public are not touched and download exactly as before. ADR-0049 + - Done when: No upload call names scope public and no ObjectStorageConfig declares it; each upload that did now names another scope or none and is answered 200. Each file that must render before sign-in has acl 'public_read' on its stored file record, and fetching it with no session serves it; fetching any other uploaded file with no session is answered 401. - **`strategy-context-aggregation-method-narrowed`** — `StrategyContext.executeAggregate aggregations[].method (contracts/analytics-service.ts, exported from @objectstack/spec/contracts) - the parameter type, declared as bare string` → AggregationFunction (count | sum | avg | min | max | count_distinct, data/query.zod.ts) - the same closed vocabulary IDataEngine.aggregate already declares for the identical slot (AggregationNodeSchema.function; the analytics bridge renames method to function and forwards). A caller filling method from a string-typed value narrows the value to the enum - typing it AggregationFunction, or parsing with the spec's own AggregationFunction zod enum where the value enters from data. Values outside the six were never served: the bridge has parsed-and-refused them at runtime since it stopped declaring its own engine type and began parsing the method with the spec enum, and that refusal stays as defence in depth - Why not automatic: Maintainer ruling 2026-08-28 (option A, census-first): one slot, one declaration. Two spec-declared surfaces described the same value and disagreed about its type: IDataEngine.aggregate's aggregations[].function is the closed six-value AggregationFunction enum while StrategyContext.executeAggregate declared the same slot aggregations[].method: string, so nothing on the analytics side of that seam was compile-checked against the engine's vocabulary - an author, very often an AI (ADR-0033), writing an analytics strategy got no compile-time help and could carry any method name all the way to the bridge's runtime refusal. One slot now has one declaration. Bookkeeping: this is a TYPE narrowing on a runtime TS interface member - no authorable metadata key, no wire shape and no walked-shape def changed, so nothing lands in RETIRED_KEYS_BY_MAJOR / RETIRED_DEFS_BY_MAJOR and the surface ratchets are expected byte-identical. It is a SEMANTIC entry rather than a D2 conversion because there is no authored document or sys_metadata row for the chain to rewrite: the only consumers are TypeScript call sites, and the compile error is the channel that reaches them. In-repo census at the ruling (hard precondition, measured before the narrowing landed): every implementor and every call site filling method is legal under the enum - ObjectQLStrategy.resolveMeasureAggregation emits only the six once it refuses a custom-SQL measure up front, the two literal producers write count, and every test fixture is implementor-side and stays assignable by contravariance. - Done when: External implementors of StrategyContext stay source-compatible: a handler accepting method: string accepts a superset and remains assignable to the narrowed member. External callers filling method with a string-typed or out-of-vocabulary value fail tsc at the executeAggregate call site on upgrade; the fix is narrowing the value's type to AggregationFunction (parsing with the spec enum where it enters from data), never widening a local mirror of the contract. Runtime behaviour is unchanged: the bridge's parse-and-refuse accepts and rejects exactly the same sets before and after, and no stored metadata or document needs editing. diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 2e51716349b..73453680415 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -2150,13 +2150,6 @@ "toMajor": 18, "rationale": "Every builtin executor (`service-automation` `builtin/`) parses its node's `config` against the contract `getBuiltinNodeConfigContracts()` names before it acts, and refuses the node on any finding. The build doors judged only the keys that contract requires, left out, so a present value it refuses — `create_record` `outputVariable: 42`, a screen field `min: '1'` (the shape the Studio designer used to store for a field's Min / Max) — passed `FlowSchema.parse`, `objectstack validate` and `objectstack compile`, registered, and then failed every run that reached the node: the config is metadata, and no rerun could succeed. The one judge `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first) and `objectstack validate` share (`flowNodeConfigRefusals`) now refuses such a value as `node-config-refused-by-contract`, anchored at the key, in the contract's own words — the code the approval contract already uses. It judges only what the build can know the run will parse, and holds one more class back by ruling: a value carrying a `{token}` is never refused at the build doors for its pre-interpolation type — which is no promise it runs, since every builtin but `http` parses its config as authored and so still refuses a token in a number or boolean slot at its first run; `http` parses after interpolating its whole config, so only token-free values are judged there and never `signingSecret`, which the credential channel may supply; a `loop` with no `body` is not parsed by its executor and is judged for nothing; the region slots of `loop`, `parallel` and `try_catch` are judged as graphs of their own and by `validateControlFlow`. An undeclared or retired key, a `predicate` ledger slot (a screen field `visibleWhen`) and a `value` ledger slot (a CRUD `fields` value) keep the judges they had. ⚠️ No D2 conversion: the platform cannot know the value the author meant. ⚠️ Where such a node already sits the whole flow is refused: registered from the metadata registry or `sys_metadata` at boot it is skipped with a `warn` naming it, its trigger not armed, while the flows beside it register; a `defineStack({ flows })` source throws `StackSchemaInvalidError` for the whole stack; an artifact file is refused whole at load. ADR-0087, ADR-0031." }, - { - "surface": "flows[].nodes[].config.condition, flows[].edges[].condition, a decision node's conditions[].expression, a screen node's fields[].visibleWhen, and the CEL value envelopes of an assignment node's assignments and of a create_record or update_record node's fields — a CEL source whose root identifier the flow does not bind, such as user.id, ctx.user.id or os.user.id", - "replacement": "current_user for the run's user: user.id, ctx.user.id and os.user.id become current_user.id, and a flow that can run without a user guards it as current_user != null ? current_user.id : null. Any other root becomes a name the flow binds — a declared variable, an outputVariable, an iterator, index or error variable, an assignment target, a node id or a screen field — or a field of the trigger record, read bare or as record.FIELD", - "migrationId": "flow-cel-unbound-root-refused", - "toMajor": 18, - "rationale": "A flow CEL expression evaluates in one scope per run: the flow's variables spread to top level, the trigger record's fields flattened beside them, and record, previous, vars and current_user bound by the engine. A root outside that scope fails the expression with an unknown-variable fault on every run that reaches it, and the build door, which judged a flow expression's syntax only, passed it. The run-user spellings are the reachable case: formulas, row-level security and the client bind the run's user under user, ctx.user and os.user as well, and flow CEL does not, because a top-level user would collide with a variable or a lookup field of that name. objectstack validate and the runtime publish gate now refuse such a root, naming current_user for the user spellings and the in-scope names for any other. A flow whose run can bind a name the reader cannot see is not judged: one with a script or connector_action node, a wait, subflow or map node a resume can fold a bag into, a screen with no field list, a plugin node type, or a trigger object the stack does not declare. No D2 conversion exists: none of these roots ever evaluated in a flow, so there is no behaviour to carry over, and a run without a user needs a guard only the author can choose." - }, { "surface": "a decision node branch — an element of config.conditions[] — written without its expression key, or with expression: null, at any depth including an ADR-0031 region body. That includes a branch whose predicate sits under another key (condition is the edge spelling). Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, defineFlow(), an exported stack passed to objectstack validate, a flow saved from the Studio flow designer with a branch row whose expression cell is empty, and a flow row already sitting in sys_metadata", "replacement": "the predicate the branch was meant to test, as non-blank bare CEL text under `expression` (`{ label: 'high', expression: 'record.amount > 10000' }`); a predicate written under `condition` moves to `expression`. To keep the branch and its label but never take it, write `expression: 'false'` — that is a CHANGE of behaviour, not a preserved one: a run that reached the branch used to fail there (`condition evaluation error`), and now routes on to the next branch or the declared fallback. ⚠️ Not by dropping a decision's only branch: with no `conditions` the node routes by its out-edges alone, so the out-edge that branch labelled is no longer held back", @@ -2208,14 +2201,14 @@ }, { "surface": "flows[].nodes[].config of a notify node (title, message), a screen node (title, description) and an end node (message) — a string, or the source of a template envelope, carrying a single-brace template token", - "replacement": "a double-brace template hole, rendered by the formula template engine over the flow's variables: a variable path with an optional formatter, {{ record.name }}, {{ $error.message }}, {{ rows.0.subject }}, {{ record.amount | currency }}. A token no hole can spell is computed into a variable first, with an assignment node — arithmetic and functions as a CEL value envelope, the date macros as the value-slot spelling that still reads them, the run user's id as the CEL value envelope current_user.id — and written as {{ variable }}", + "replacement": "a double-brace template hole, rendered by the formula template engine over the flow's variables: a variable path with an optional formatter, {{ record.name }}, {{ $error.message }}, {{ rows.0.subject }}, {{ record.amount | currency }}. A token no hole can spell is computed into a variable first, with an assignment node — arithmetic and functions as a CEL value envelope, the date macros and the run-user paths as the value-slot spelling that still reads them — and written as {{ variable }}", "migrationId": "flow-text-slot-single-brace-refused", "toMajor": 18, "rationale": "ADR-0032 Decision 3 fixes one template delimiter, double braces, and deletes the single brace: it collides with CEL map literals, and an author who meets both dialects in one flow mixes them. The 17.x interpolator and the template engine render the same text for a path holding a string, a number, a boolean, null, an absent key or variable, an ISO date string, an object or an array, but not for every value — a Date rendered JSON-quoted under the interpolator and as its ISO text under the engine, and a screen title, screen description or end message that was one token holding an object, an array or a Date rendered String(value) — so no conversion is lossless (ADR-0087 D2) and none is applied. Arithmetic, function calls, the date macros and the run-user paths have no hole spelling: a hole is a path with a formatter, never logic. A flow carrying a single-brace token in a text slot is refused at registration, by objectstack validate and by the node contract; a stored flow carrying one is skipped at boot with a warn naming it." }, { "surface": "flows[].nodes[].config of a notify node (title, message), a screen node (title, description) and an end node (message) — a string, or the source of a template envelope, carrying a double-brace hole whose root is a dollar-named variable the flow engine does not bind, such as {{ $User.Id }}", - "replacement": "a variable the run has, written as a hole. The run user's id is computed first, with an assignment node whose CEL value envelope reads current_user, the run's user (assignments: { by: { dialect: 'cel', source: 'current_user.id' } }), then written as {{ by }}; every other run-user path never resolved in any shipped run, and an email or a name is read from the user record by current_user.id. A variable the flow binds itself (a declared variable, an assignment target, an outputVariable, a try_catch errorVariable) is named without the dollar sign and written as {{ caught.message }}. The engine's own variables stay holes: {{ $error.message }}, {{ $record.name }}, {{ $runId }}, {{ $flowName }}, {{ $flowLabel }}, and a flat-graph loop's {{ $loopItems }} / {{ $loopIndex }}", + "replacement": "a variable the run has, written as a hole. The run user is computed first, with an assignment node whose value slot still reads the run-user path (assignments: { by: '{$User.Id}' }), then written as {{ by }}. A variable the flow binds itself (a declared variable, an assignment target, an outputVariable, a try_catch errorVariable) is named without the dollar sign and written as {{ caught.message }}. The engine's own variables stay holes: {{ $error.message }}, {{ $record.name }}, {{ $runId }}, {{ $flowName }}, {{ $flowLabel }}, and a flat-graph loop's {{ $loopItems }} / {{ $loopIndex }}", "migrationId": "flow-text-slot-unbound-dollar-root-refused", "toMajor": 18, "rationale": "The dollar-named variables are the flow engine's own: it binds $record, $runId, $flowName, $flowLabel and $error, and a flat-graph loop binds $loopItems and $loopIndex. A hole over any other dollar name answers to no variable — {{ $User.Id }} looks like the run user and is not one, since the run user has no hole spelling. In 17.x the slot was a plain string read by the single-brace interpolator, which substituted the inner token and left a literal brace on each side; the 18 text slots render holes through the template engine, where such a hole renders nothing and the run reports success. It is now refused by the node contract, at registration and by objectstack validate, with the remedy its single-brace spelling gets; a stored flow carrying one is skipped at boot with a warn naming it. No D2 conversion exists: what the author meant the hole to read is not in the flow, and the template engine binds no new variable to answer it." @@ -2228,11 +2221,11 @@ "rationale": "ADR-0100: a credential-class value leaves the engine only through a privileged dereference, and every generic channel serves the mask. The record-change trigger built a flow's record and previous from the engine's own write result, which keeps the stored row whole for privileged in-process callers, so a password field's plaintext, a secret field's stored handle and an internal field's value reached the flow — and from there its variables, a paused run's persisted state and that state's read doors. The trigger now projects both roots through the same helper every external write response uses: a credential-class field (secret, and password outside the exempt managedBy buckets) carries the mask, or null when unset, and an internal field is omitted. Every other field keeps its value, every other variable is untouched, and the engine's own write result, the stored row and the privileged read paths are unchanged." }, { - "surface": "flows[].nodes[].config of an assignment node (the assignments map, the legacy assignments array and the legacy bare config) and of create_record and update_record nodes (the fields map) — a string value, or a string anywhere inside an array or object value, carrying a single-brace template token, the run-user paths beginning $User. included", - "replacement": "a CEL value envelope, { dialect: \"cel\", source: \"…\" }, evaluated to the value: a path is the same path (record.owner; a numeric segment becomes an index, items[0]; a variable whose name starts with $ is read through vars, vars[\"$error\"].message), arithmetic is the same arithmetic with every integer divisor written as a double (round(x * 100) / 100.0), and text with holes is one concatenation ('Hello ' + o.name). The run user's id, $User.Id, is current_user.id — current_user is the run's user, or null when the run has none — and in a flow that can run without a user it is current_user != null ? current_user.id : null, which writes null where the template wrote nothing, so on update_record it clears a stored value the template left alone. Every other run-user path ($User.Email, $User.Name, …) never resolved in any shipped run: current_user carries only what the run holds (id, positions, organizationId, isPlatformAdmin), and an email or a name is read from the user record by current_user.id (a get_record node on sys_user). A string with no token is the literal text it spells, and braces meant literally are a CEL string literal", + "surface": "flows[].nodes[].config of an assignment node (the assignments map, the legacy assignments array and the legacy bare config) and of create_record and update_record nodes (the fields map) — a string value, or a string anywhere inside an array or object value, carrying a single-brace template token", + "replacement": "a CEL value envelope, { dialect: \"cel\", source: \"…\" }, evaluated to the value: a path is the same path (record.owner; a numeric segment becomes an index, items[0]; a variable whose name starts with $ is read through vars, vars[\"$error\"].message), arithmetic is the same arithmetic with every integer divisor written as a double (round(x * 100) / 100.0), and text with holes is one concatenation ('Hello ' + o.name). A string with no token is the literal text it spells, and braces meant literally are a CEL string literal", "migrationId": "flow-value-slot-template-dialect-refused", "toMajor": 18, - "rationale": "The interpolator and the CEL engine answer differently for every token spelling authored in flows, so no conversion is lossless (ADR-0087 D2) and none is applied. A path, an absent variable, key or list index wrote nothing under the template and fails the run under CEL; text with a null hole rendered nothing and CEL refuses + null; CEL divides two integers as integers, so round(x * 100) / 100 truncates 123.46 to 123. Where a value may be absent, which of nothing, null or a default the field should take is the author's decision — the template decided it silently. The run user's id was the run's userId under the template, and nothing in a run with no user (a schedule, a record change made by a system write); the flow CEL scope binds current_user to the run's user and to null in such a run, never a pseudo-user, so current_user.id fails there and its guarded form writes null. The other run-user paths read a user object no run carries, so they wrote nothing in every run. One spelling is kept with its old meaning, because CEL cannot write it yet: the date macros NOW() and TODAY() with a day offset (CEL yields a Timestamp, not the ISO text, and has no string form for one). A flow carrying a refused value is refused at registration, by objectstack validate and by the executor; a stored flow carrying one is skipped at boot with a warn naming it." + "rationale": "The interpolator and the CEL engine answer differently for every token spelling authored in flows, so no conversion is lossless (ADR-0087 D2) and none is applied. A path, an absent variable, key or list index wrote nothing under the template and fails the run under CEL; text with a null hole rendered nothing and CEL refuses + null; CEL divides two integers as integers, so round(x * 100) / 100 truncates 123.46 to 123. Where a value may be absent, which of nothing, null or a default the field should take is the author's decision — the template decided it silently. Two spellings are kept with their old meaning, because CEL cannot write them yet: the date macros NOW() and TODAY() with a day offset (CEL yields a Timestamp, not the ISO text, and has no string form for one) and the run-user paths beginning $User. (the flow CEL scope binds no user). A flow carrying a refused value is refused at registration, by objectstack validate and by the executor; a stored flow carrying one is skipped at boot with a warn naming it." }, { "surface": "a create_record, update_record or delete_record flow node whose config.objectName is the string sys_metadata or sys_metadata_history, at any depth including an ADR-0031 region body", @@ -2988,7 +2981,7 @@ "replacement": "another scope, or none for the default (`user` on an upload, `global` on a storage configuration), and `acl: 'public_read'` on the stored file record of each file that must be readable before sign-in (ADR-0104)", "migrationId": "storage-scope-public-retired", "toMajor": 18, - "rationale": "A storage scope never made a file publicly readable. The download doors judge a file by its `acl`, the `attachments` scope and field ownership alone, so a file uploaded with scope public and the default acl was stored private and needs a signed-in caller, while its scope said otherwise. The value is retired rather than enforced: enforcing it would let any uploader make a file anonymous at upload, and ADR-0104 keeps `acl: 'public_read'` the one opt-in for anonymous download. Whether a given file must be readable before sign-in is the caller's call, so no rewrite can make it: an upload that meant public needs its stored file record marked, and one that did not needs only another scope. The upload request itself carries no acl, and every upload is stored private. The stored file record retires the value too: the scope select of sys_file no longer lists public, so a deployment that stored files with it runs the one-time operator sweep that @objectstack/service-storage exports (`planSysFilePublicScopeBackfill` for the dry run, then `applySysFilePublicScopeBackfill`), which rewrites each of those records to scope user. No access changes: no reader tells user apart from public, the storage key and the file bytes are not touched, and those files download exactly as before. Until the sweep has run, a record write that names such a file while another field already owns it is refused, because the copy it makes carries scope public. ADR-0049" + "rationale": "A storage scope never made a file publicly readable. The download doors judge a file by its `acl`, the `attachments` scope and field ownership alone, so a file uploaded with scope public and the default acl was stored private and needs a signed-in caller, while its scope said otherwise. The value is retired rather than enforced: enforcing it would let any uploader make a file anonymous at upload, and ADR-0104 keeps `acl: 'public_read'` the one opt-in for anonymous download. Whether a given file must be readable before sign-in is the caller's call, so no rewrite can make it: an upload that meant public needs its stored file record marked, and one that did not needs only another scope. The upload request itself carries no acl, and every upload is stored private. Files already stored with scope public are not touched and download exactly as before. ADR-0049" }, { "surface": "StrategyContext.executeAggregate aggregations[].method (contracts/analytics-service.ts, exported from @objectstack/spec/contracts) - the parameter type, declared as bare string", @@ -5788,13 +5781,6 @@ "toMajor": 18, "rationale": "Every builtin executor (`service-automation` `builtin/`) parses its node's `config` against the contract `getBuiltinNodeConfigContracts()` names before it acts, and refuses the node on any finding. The build doors judged only the keys that contract requires, left out, so a present value it refuses — `create_record` `outputVariable: 42`, a screen field `min: '1'` (the shape the Studio designer used to store for a field's Min / Max) — passed `FlowSchema.parse`, `objectstack validate` and `objectstack compile`, registered, and then failed every run that reached the node: the config is metadata, and no rerun could succeed. The one judge `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first) and `objectstack validate` share (`flowNodeConfigRefusals`) now refuses such a value as `node-config-refused-by-contract`, anchored at the key, in the contract's own words — the code the approval contract already uses. It judges only what the build can know the run will parse, and holds one more class back by ruling: a value carrying a `{token}` is never refused at the build doors for its pre-interpolation type — which is no promise it runs, since every builtin but `http` parses its config as authored and so still refuses a token in a number or boolean slot at its first run; `http` parses after interpolating its whole config, so only token-free values are judged there and never `signingSecret`, which the credential channel may supply; a `loop` with no `body` is not parsed by its executor and is judged for nothing; the region slots of `loop`, `parallel` and `try_catch` are judged as graphs of their own and by `validateControlFlow`. An undeclared or retired key, a `predicate` ledger slot (a screen field `visibleWhen`) and a `value` ledger slot (a CRUD `fields` value) keep the judges they had. ⚠️ No D2 conversion: the platform cannot know the value the author meant. ⚠️ Where such a node already sits the whole flow is refused: registered from the metadata registry or `sys_metadata` at boot it is skipped with a `warn` naming it, its trigger not armed, while the flows beside it register; a `defineStack({ flows })` source throws `StackSchemaInvalidError` for the whole stack; an artifact file is refused whole at load. ADR-0087, ADR-0031." }, - { - "surface": "flows[].nodes[].config.condition, flows[].edges[].condition, a decision node's conditions[].expression, a screen node's fields[].visibleWhen, and the CEL value envelopes of an assignment node's assignments and of a create_record or update_record node's fields — a CEL source whose root identifier the flow does not bind, such as user.id, ctx.user.id or os.user.id", - "replacement": "current_user for the run's user: user.id, ctx.user.id and os.user.id become current_user.id, and a flow that can run without a user guards it as current_user != null ? current_user.id : null. Any other root becomes a name the flow binds — a declared variable, an outputVariable, an iterator, index or error variable, an assignment target, a node id or a screen field — or a field of the trigger record, read bare or as record.FIELD", - "migrationId": "flow-cel-unbound-root-refused", - "toMajor": 18, - "rationale": "A flow CEL expression evaluates in one scope per run: the flow's variables spread to top level, the trigger record's fields flattened beside them, and record, previous, vars and current_user bound by the engine. A root outside that scope fails the expression with an unknown-variable fault on every run that reaches it, and the build door, which judged a flow expression's syntax only, passed it. The run-user spellings are the reachable case: formulas, row-level security and the client bind the run's user under user, ctx.user and os.user as well, and flow CEL does not, because a top-level user would collide with a variable or a lookup field of that name. objectstack validate and the runtime publish gate now refuse such a root, naming current_user for the user spellings and the in-scope names for any other. A flow whose run can bind a name the reader cannot see is not judged: one with a script or connector_action node, a wait, subflow or map node a resume can fold a bag into, a screen with no field list, a plugin node type, or a trigger object the stack does not declare. No D2 conversion exists: none of these roots ever evaluated in a flow, so there is no behaviour to carry over, and a run without a user needs a guard only the author can choose." - }, { "surface": "a decision node branch — an element of config.conditions[] — written without its expression key, or with expression: null, at any depth including an ADR-0031 region body. That includes a branch whose predicate sits under another key (condition is the edge spelling). Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, defineFlow(), an exported stack passed to objectstack validate, a flow saved from the Studio flow designer with a branch row whose expression cell is empty, and a flow row already sitting in sys_metadata", "replacement": "the predicate the branch was meant to test, as non-blank bare CEL text under `expression` (`{ label: 'high', expression: 'record.amount > 10000' }`); a predicate written under `condition` moves to `expression`. To keep the branch and its label but never take it, write `expression: 'false'` — that is a CHANGE of behaviour, not a preserved one: a run that reached the branch used to fail there (`condition evaluation error`), and now routes on to the next branch or the declared fallback. ⚠️ Not by dropping a decision's only branch: with no `conditions` the node routes by its out-edges alone, so the out-edge that branch labelled is no longer held back", @@ -5846,14 +5832,14 @@ }, { "surface": "flows[].nodes[].config of a notify node (title, message), a screen node (title, description) and an end node (message) — a string, or the source of a template envelope, carrying a single-brace template token", - "replacement": "a double-brace template hole, rendered by the formula template engine over the flow's variables: a variable path with an optional formatter, {{ record.name }}, {{ $error.message }}, {{ rows.0.subject }}, {{ record.amount | currency }}. A token no hole can spell is computed into a variable first, with an assignment node — arithmetic and functions as a CEL value envelope, the date macros as the value-slot spelling that still reads them, the run user's id as the CEL value envelope current_user.id — and written as {{ variable }}", + "replacement": "a double-brace template hole, rendered by the formula template engine over the flow's variables: a variable path with an optional formatter, {{ record.name }}, {{ $error.message }}, {{ rows.0.subject }}, {{ record.amount | currency }}. A token no hole can spell is computed into a variable first, with an assignment node — arithmetic and functions as a CEL value envelope, the date macros and the run-user paths as the value-slot spelling that still reads them — and written as {{ variable }}", "migrationId": "flow-text-slot-single-brace-refused", "toMajor": 18, "rationale": "ADR-0032 Decision 3 fixes one template delimiter, double braces, and deletes the single brace: it collides with CEL map literals, and an author who meets both dialects in one flow mixes them. The 17.x interpolator and the template engine render the same text for a path holding a string, a number, a boolean, null, an absent key or variable, an ISO date string, an object or an array, but not for every value — a Date rendered JSON-quoted under the interpolator and as its ISO text under the engine, and a screen title, screen description or end message that was one token holding an object, an array or a Date rendered String(value) — so no conversion is lossless (ADR-0087 D2) and none is applied. Arithmetic, function calls, the date macros and the run-user paths have no hole spelling: a hole is a path with a formatter, never logic. A flow carrying a single-brace token in a text slot is refused at registration, by objectstack validate and by the node contract; a stored flow carrying one is skipped at boot with a warn naming it." }, { "surface": "flows[].nodes[].config of a notify node (title, message), a screen node (title, description) and an end node (message) — a string, or the source of a template envelope, carrying a double-brace hole whose root is a dollar-named variable the flow engine does not bind, such as {{ $User.Id }}", - "replacement": "a variable the run has, written as a hole. The run user's id is computed first, with an assignment node whose CEL value envelope reads current_user, the run's user (assignments: { by: { dialect: 'cel', source: 'current_user.id' } }), then written as {{ by }}; every other run-user path never resolved in any shipped run, and an email or a name is read from the user record by current_user.id. A variable the flow binds itself (a declared variable, an assignment target, an outputVariable, a try_catch errorVariable) is named without the dollar sign and written as {{ caught.message }}. The engine's own variables stay holes: {{ $error.message }}, {{ $record.name }}, {{ $runId }}, {{ $flowName }}, {{ $flowLabel }}, and a flat-graph loop's {{ $loopItems }} / {{ $loopIndex }}", + "replacement": "a variable the run has, written as a hole. The run user is computed first, with an assignment node whose value slot still reads the run-user path (assignments: { by: '{$User.Id}' }), then written as {{ by }}. A variable the flow binds itself (a declared variable, an assignment target, an outputVariable, a try_catch errorVariable) is named without the dollar sign and written as {{ caught.message }}. The engine's own variables stay holes: {{ $error.message }}, {{ $record.name }}, {{ $runId }}, {{ $flowName }}, {{ $flowLabel }}, and a flat-graph loop's {{ $loopItems }} / {{ $loopIndex }}", "migrationId": "flow-text-slot-unbound-dollar-root-refused", "toMajor": 18, "rationale": "The dollar-named variables are the flow engine's own: it binds $record, $runId, $flowName, $flowLabel and $error, and a flat-graph loop binds $loopItems and $loopIndex. A hole over any other dollar name answers to no variable — {{ $User.Id }} looks like the run user and is not one, since the run user has no hole spelling. In 17.x the slot was a plain string read by the single-brace interpolator, which substituted the inner token and left a literal brace on each side; the 18 text slots render holes through the template engine, where such a hole renders nothing and the run reports success. It is now refused by the node contract, at registration and by objectstack validate, with the remedy its single-brace spelling gets; a stored flow carrying one is skipped at boot with a warn naming it. No D2 conversion exists: what the author meant the hole to read is not in the flow, and the template engine binds no new variable to answer it." @@ -5866,11 +5852,11 @@ "rationale": "ADR-0100: a credential-class value leaves the engine only through a privileged dereference, and every generic channel serves the mask. The record-change trigger built a flow's record and previous from the engine's own write result, which keeps the stored row whole for privileged in-process callers, so a password field's plaintext, a secret field's stored handle and an internal field's value reached the flow — and from there its variables, a paused run's persisted state and that state's read doors. The trigger now projects both roots through the same helper every external write response uses: a credential-class field (secret, and password outside the exempt managedBy buckets) carries the mask, or null when unset, and an internal field is omitted. Every other field keeps its value, every other variable is untouched, and the engine's own write result, the stored row and the privileged read paths are unchanged." }, { - "surface": "flows[].nodes[].config of an assignment node (the assignments map, the legacy assignments array and the legacy bare config) and of create_record and update_record nodes (the fields map) — a string value, or a string anywhere inside an array or object value, carrying a single-brace template token, the run-user paths beginning $User. included", - "replacement": "a CEL value envelope, { dialect: \"cel\", source: \"…\" }, evaluated to the value: a path is the same path (record.owner; a numeric segment becomes an index, items[0]; a variable whose name starts with $ is read through vars, vars[\"$error\"].message), arithmetic is the same arithmetic with every integer divisor written as a double (round(x * 100) / 100.0), and text with holes is one concatenation ('Hello ' + o.name). The run user's id, $User.Id, is current_user.id — current_user is the run's user, or null when the run has none — and in a flow that can run without a user it is current_user != null ? current_user.id : null, which writes null where the template wrote nothing, so on update_record it clears a stored value the template left alone. Every other run-user path ($User.Email, $User.Name, …) never resolved in any shipped run: current_user carries only what the run holds (id, positions, organizationId, isPlatformAdmin), and an email or a name is read from the user record by current_user.id (a get_record node on sys_user). A string with no token is the literal text it spells, and braces meant literally are a CEL string literal", + "surface": "flows[].nodes[].config of an assignment node (the assignments map, the legacy assignments array and the legacy bare config) and of create_record and update_record nodes (the fields map) — a string value, or a string anywhere inside an array or object value, carrying a single-brace template token", + "replacement": "a CEL value envelope, { dialect: \"cel\", source: \"…\" }, evaluated to the value: a path is the same path (record.owner; a numeric segment becomes an index, items[0]; a variable whose name starts with $ is read through vars, vars[\"$error\"].message), arithmetic is the same arithmetic with every integer divisor written as a double (round(x * 100) / 100.0), and text with holes is one concatenation ('Hello ' + o.name). A string with no token is the literal text it spells, and braces meant literally are a CEL string literal", "migrationId": "flow-value-slot-template-dialect-refused", "toMajor": 18, - "rationale": "The interpolator and the CEL engine answer differently for every token spelling authored in flows, so no conversion is lossless (ADR-0087 D2) and none is applied. A path, an absent variable, key or list index wrote nothing under the template and fails the run under CEL; text with a null hole rendered nothing and CEL refuses + null; CEL divides two integers as integers, so round(x * 100) / 100 truncates 123.46 to 123. Where a value may be absent, which of nothing, null or a default the field should take is the author's decision — the template decided it silently. The run user's id was the run's userId under the template, and nothing in a run with no user (a schedule, a record change made by a system write); the flow CEL scope binds current_user to the run's user and to null in such a run, never a pseudo-user, so current_user.id fails there and its guarded form writes null. The other run-user paths read a user object no run carries, so they wrote nothing in every run. One spelling is kept with its old meaning, because CEL cannot write it yet: the date macros NOW() and TODAY() with a day offset (CEL yields a Timestamp, not the ISO text, and has no string form for one). A flow carrying a refused value is refused at registration, by objectstack validate and by the executor; a stored flow carrying one is skipped at boot with a warn naming it." + "rationale": "The interpolator and the CEL engine answer differently for every token spelling authored in flows, so no conversion is lossless (ADR-0087 D2) and none is applied. A path, an absent variable, key or list index wrote nothing under the template and fails the run under CEL; text with a null hole rendered nothing and CEL refuses + null; CEL divides two integers as integers, so round(x * 100) / 100 truncates 123.46 to 123. Where a value may be absent, which of nothing, null or a default the field should take is the author's decision — the template decided it silently. Two spellings are kept with their old meaning, because CEL cannot write them yet: the date macros NOW() and TODAY() with a day offset (CEL yields a Timestamp, not the ISO text, and has no string form for one) and the run-user paths beginning $User. (the flow CEL scope binds no user). A flow carrying a refused value is refused at registration, by objectstack validate and by the executor; a stored flow carrying one is skipped at boot with a warn naming it." }, { "surface": "a create_record, update_record or delete_record flow node whose config.objectName is the string sys_metadata or sys_metadata_history, at any depth including an ADR-0031 region body", @@ -6626,7 +6612,7 @@ "replacement": "another scope, or none for the default (`user` on an upload, `global` on a storage configuration), and `acl: 'public_read'` on the stored file record of each file that must be readable before sign-in (ADR-0104)", "migrationId": "storage-scope-public-retired", "toMajor": 18, - "rationale": "A storage scope never made a file publicly readable. The download doors judge a file by its `acl`, the `attachments` scope and field ownership alone, so a file uploaded with scope public and the default acl was stored private and needs a signed-in caller, while its scope said otherwise. The value is retired rather than enforced: enforcing it would let any uploader make a file anonymous at upload, and ADR-0104 keeps `acl: 'public_read'` the one opt-in for anonymous download. Whether a given file must be readable before sign-in is the caller's call, so no rewrite can make it: an upload that meant public needs its stored file record marked, and one that did not needs only another scope. The upload request itself carries no acl, and every upload is stored private. The stored file record retires the value too: the scope select of sys_file no longer lists public, so a deployment that stored files with it runs the one-time operator sweep that @objectstack/service-storage exports (`planSysFilePublicScopeBackfill` for the dry run, then `applySysFilePublicScopeBackfill`), which rewrites each of those records to scope user. No access changes: no reader tells user apart from public, the storage key and the file bytes are not touched, and those files download exactly as before. Until the sweep has run, a record write that names such a file while another field already owns it is refused, because the copy it makes carries scope public. ADR-0049" + "rationale": "A storage scope never made a file publicly readable. The download doors judge a file by its `acl`, the `attachments` scope and field ownership alone, so a file uploaded with scope public and the default acl was stored private and needs a signed-in caller, while its scope said otherwise. The value is retired rather than enforced: enforcing it would let any uploader make a file anonymous at upload, and ADR-0104 keeps `acl: 'public_read'` the one opt-in for anonymous download. Whether a given file must be readable before sign-in is the caller's call, so no rewrite can make it: an upload that meant public needs its stored file record marked, and one that did not needs only another scope. The upload request itself carries no acl, and every upload is stored private. Files already stored with scope public are not touched and download exactly as before. ADR-0049" }, { "surface": "StrategyContext.executeAggregate aggregations[].method (contracts/analytics-service.ts, exported from @objectstack/spec/contracts) - the parameter type, declared as bare string", From bf1c3a05bd3523a112a8a36302a04c7bd9c4697c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 04:28:09 +0000 Subject: [PATCH 5/7] chore(changeset): the flow CEL unbound-root refusal (lint, spec major; narrowing) Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- .../22565-flow-cel-unbound-root-refused.md | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .changeset/22565-flow-cel-unbound-root-refused.md diff --git a/.changeset/22565-flow-cel-unbound-root-refused.md b/.changeset/22565-flow-cel-unbound-root-refused.md new file mode 100644 index 00000000000..f2b8239728e --- /dev/null +++ b/.changeset/22565-flow-cel-unbound-root-refused.md @@ -0,0 +1,42 @@ +--- +'@objectstack/lint': major +'@objectstack/spec': major +--- + +A flow CEL expression that reads a root the flow does not bind is refused at `objectstack validate` and at the runtime publish gate. The run fails such an expression with `Unknown variable: X` (X being the root) on every evaluation, and the build door used to pass it. The reachable case is the run user: `user.id == "u1"`, `ctx.user.id == "u1"` and `os.user.id == "u1"` — the spellings formulas, row-level security and the client accept — are refused, naming `current_user`, the one spelling flow CEL binds. + +Clause-②: no (narrowing) + + + +**BREAKING**: an accept-set narrowing on a published authoring surface (`objectstack validate`, and the runtime publish gate's `expression-invalid` rule for a flow write). + +**Why.** Flow CEL evaluates in one scope per run: the flow's variables spread to top level, the trigger record's fields flattened beside them, and `record`, `previous`, `vars` and `current_user` bound by the engine. Formulas, row-level security and the client also bind the run's user as `user`, `ctx.user` and `os.user`; flow CEL does not, because a top-level `user` would collide with a variable or a lookup field of that name. So an author who wrote the alias by habit shipped a flow that faulted at its first evaluation, and nothing warned. + +**What is refused.** At every flow CEL site the build door reads — a node's `config.condition` (the start node's trigger gate included), an edge `condition`, a `decision` branch `expression`, a screen field `visibleWhen`, and the CEL value envelopes of an `assignment` and of a `create_record` / `update_record` `fields` map — a root that is none of: + +- the engine's roots: `record`, `previous`, `vars`, `current_user`; +- a name the flow binds: a declared variable, an `outputVariable`, an `iteratorVariable` (a `loop`'s default `item` included), an `indexVariable`, an `errorVariable`, an `assignment` target, a node id, a screen field's `name`; +- a field (or a registry-injected column) of any object the stack declares — the run flattens whichever record its entrance hands it. + +The user spellings are told to write `current_user`, with the guard for a flow that can run without a user; any other root is named with what could have bound it, and the nearest in-scope name. The error is `expression-invalid`, `error`. Where the flattened-scope near-miss advisory ("`X` is not a field of … If `X` is a flow variable this is safe to ignore") named the same root, the refusal replaces it. + +**Not judged — the door stands down where the run can bind a name it cannot see.** A flow carrying a `script` or `connector_action` node (each hands its code the live variable map), a `wait`, `subflow` or `map` node (a resume folds the caller's `variables` bag in under their plain names), a screen with no field list, a node type outside the builtin set, or a trigger object the stack does not declare. A `$` name has no CEL spelling (`$runId == "x"` does not parse), so the `$` roots stay the text-slot rule's. + +## FROM → TO + +| you wrote | write instead | +|:--|:--| +| `condition: 'user.id == "u1"'` | `condition: 'current_user.id == "u1"'` (`current_user != null && current_user.id == "u1"` where the flow can run without a user) | +| `condition: 'ctx.user.id == record.owner'` | `condition: 'current_user.id == record.owner'` | +| `assignments: { who: { dialect: 'cel', source: 'os.user.id' } }` | `assignments: { who: { dialect: 'cel', source: 'current_user.id' } }` | +| `condition: 'deals.size() > 0'` with nothing binding `deals` | declare it (`variables: [{ name: 'deals', type: 'list' }]`) or bind it with a node (`outputVariable: 'deals'`) | + +**The one-line fix: write `current_user` for the run user, and bind or correct every other root the refusal names.** + +**Who is affected, measured.** The last published spec line, 17.x, judged a flow expression's syntax only, so it accepts every refused source. Measured with the judge over every flow CEL site: the example apps (`app-crm`, `app-todo`, `app-multi-package`, `app-showcase`: 35 flows, 45 sites), `packages/platform-objects` (no flows) and `objectstack-ai/hotcrm` at `f0afcbda07` (32 flows, 65 sites) — 0 refusals; every root those flows read is an engine root, a binding of the flow's own or a field of its trigger object. Deployed metadata and other repositories were not measured. + +### The kit + +- **The judge.** `flow-cel-root-scope.ts` in `@objectstack/lint`, called by `validateStackExpressions` at each flow CEL site. One bound set per flow, read once like the shadowing pass's variable set. +- **The ledger.** The D3 semantic entry `flow-cel-unbound-root-refused` (protocol 18). There is no D2 conversion: none of these roots ever evaluated in a flow, so there is no behaviour to carry over. From cbc00f361a26ce11047bb74bfb98ce9382b5a4d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 07:40:34 +0000 Subject: [PATCH 6/7] =?UTF-8?q?feat(lint):=20read=20every=20entrance=20the?= =?UTF-8?q?=20stack=20declares=20(A=E2=80=B2);=20stand=20down=20at=20the?= =?UTF-8?q?=20runtime=20gate=20(S)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- packages/lint/src/flow-cel-root-scope.ts | 233 ++++++++++++++++-- ...validate-expressions.flow-cel-root.test.ts | 153 +++++++++++- .../lint/src/validate-expressions.test.ts | 4 + packages/lint/src/validate-expressions.ts | 34 ++- 4 files changed, 401 insertions(+), 23 deletions(-) diff --git a/packages/lint/src/flow-cel-root-scope.ts b/packages/lint/src/flow-cel-root-scope.ts index 3d34a2be22c..7e68e337be1 100644 --- a/packages/lint/src/flow-cel-root-scope.ts +++ b/packages/lint/src/flow-cel-root-scope.ts @@ -42,10 +42,10 @@ * - **record fields**: the trigger record's fields are flattened to top-level * names. Every field of EVERY object the stack declares counts, with the * registry-injected columns — not only the trigger object's — because the run - * flattens whichever record its entrance hands it: a flow action passes the - * action's own record to any flow it starts, a `subflow` child inherits its - * parent's record, and a `map` child gets each record item. A field the stack - * declares anywhere is therefore a root this door cannot prove unbound. + * flattens whichever record its entrance hands it (the entrances below). A + * field the stack declares anywhere is therefore a root this door cannot + * prove unbound. An object-less action that launches the flow hands an empty + * record carrying at most the row `id` it was given, so `id` is bound there. * * ## When the door cannot prove a root unbound, it stands down * @@ -62,24 +62,68 @@ * - a `screen` with no input contract — an `object-form` screen, or one that * declares no fields — because `refuseInvalidScreenInput` checks a resume bag * only against a declared field list, so a contract-less screen folds any bag; - * - a trigger object the stack does not declare (a platform object, another - * package's): its row's keys are not in hand. + * - an ENTRANCE that hands the flow a record whose keys are not in hand + * ({@link flowCelEntrances}, the rule below). * - * ⚠️ The boundary that remains: a record of an object this stack does not - * declare, handed in by a flow action, a parent `subflow` or a `map` item, can - * carry a key the reader has never seen. Measured over the example apps and - * hotcrm, no flow reads a bare root that is not its own binding, an engine root - * or a field of its trigger object. + * ## The rule for records: every entrance the stack declares is read + * + * The run flattens whichever record its entrance hands it (`seedRunVariables` + * reads `context.record` and nothing else), so the record half of the bound set + * holds only while every entrance that can hand this flow a record names an + * object the stack declares. {@link flowCelEntrances} reads each entrance from + * the stack's own declarations and opens the flow when one does not: + * + * - **a record trigger** — the start node's `config.objectName` (the trigger + * object a record-change run's row belongs to) names an undeclared object; + * - **a time-relative sweep** — the start node's `config.timeRelative.object` + * (`TimeRelativeTriggerSchema`; its own key, independent of `objectName`, and + * the object whose rows the sweep hands each run) is undeclared; + * - **the inbound hook** — the flow's trigger kind is `api` + * (`resolveFlowTriggerKind`: `type: 'api'` or `config.triggerType: 'api'`), so + * the hook hands the request body in as the record (`trigger-api`), keys and + * all; + * - **an action** — an `ActionSchema` entry with `type: 'flow'` and + * `target: ` in `actions[]` (its `objectName`) or in + * `objects[].actions[]` (its `objectName`, else the owning object) names an + * undeclared object: `dispatchFlowAction` hands the action's own record to + * whatever flow it targets; + * - **a `map` node** — `MapConfigSchema`'s `flowName` names this flow and its + * `itemObject` is undeclared or absent: each id-bearing item becomes the + * child's record, and with no `itemObject` the items' object is not in hand; + * - **a parent that is itself open** — a `subflow` node (`SubflowConfigSchema`'s + * `flowName`) or a `map` node names this flow inside a flow that is open: the + * child spreads its parent's context, record included. Read to a fixpoint. + * + * The API run door hands no record (measured): both REST trigger routes and the + * declarative endpoint build their context with `buildAutomationContext`, which + * carries `params` and `recordId` but no `record`, and the engine never loads a + * record from `object` + `recordId`. A schedule run hands none either. + * + * ## The runtime publish gate stands down (#22636 is the carrier) + * + * The same pass runs at the runtime publish gate on a flow write, and there the + * per-write snapshot carries the written flow alone beside `objects` / + * `permissions` / `books` / `datasets` (`buildRuntimeWriteSnapshotSet`): no + * action and no other flow, so no entrance is visible and a parent's `subflow` or + * `map` node could hand this flow any record. A false 422 on the only door a + * Studio tenant has is worse than the gap, so this one judgment stands down there + * ({@link perWriteSnapshotEntrance}); every other expression verdict at that door + * is unchanged, and `objectstack validate` judges under the rule above. #22636 + * widens that snapshot to carry flows and actions; when it lands, this + * stand-down goes. */ import { firstUndeclaredReference, parseCelToAst, SCOPE_ROOTS } from '@objectstack/formula'; import { APPROVAL_NODE_TYPE, APPROVAL_REVISE_NODE_TYPE, + collectFlowGraphs, LOOP_NODE_TYPE, PARALLEL_NODE_TYPE, + resolveFlowTriggerKind, TRY_CATCH_NODE_TYPE, } from '@objectstack/spec/automation'; +import type { FlowEdgeParsed, FlowNodeParsed } from '@objectstack/spec/automation'; import { collectFlowVariableNames, type FlowGraphLike, type FlowVariableHost } from './flow-variable-scope.js'; import { nearestName, recordsOf } from './object-graph.js'; @@ -167,24 +211,42 @@ export interface UnboundFlowCelRoot { readonly members: readonly string[]; } +/** + * What the flow's entrances contribute to its scope ({@link flowCelEntrances}): + * the reason one of them hands it a record whose keys are not in hand, and any + * name an entrance binds that no object field covers. + */ +export interface FlowCelEntrance { + /** Set ⇒ the flow is not judged; names the entrance. */ + readonly openedBy?: string; + /** Names an entrance binds beyond the objects' fields (an object-less action's `id`). */ + readonly bound?: readonly string[]; +} + /** * The bound set of one flow, or the reason it cannot be proved. * * @param graphs every graph of the flow (`collectFlowGraphs`), so a region's * nodes count — one variable map serves the whole run. - * @param triggerObject the start node's `objectName`, when it names one. + * @param triggerObject the start node's `objectName`, when it names one — its + * fields lead the did-you-mean order. * @param fieldIndex object name → its field names, injected columns included. + * @param entrance what the flow's entrances contribute: from + * {@link flowCelEntrances} at the build door, {@link perWriteSnapshotEntrance} + * at the runtime publish gate. Its reason takes precedence over a node's. */ export function flowCelRootScope( flow: FlowCelRootHost, graphs: readonly FlowGraphLike[], triggerObject: string | undefined, fieldIndex: ReadonlyMap, + entrance: FlowCelEntrance = {}, ): FlowCelRootScope { const bound = new Set(triggerObject !== undefined ? fieldIndex.get(triggerObject) ?? [] : []); for (const name of collectFlowVariableNames(flow, graphs)) bound.add(name); for (const name of ENGINE_BOUND_ROOTS) bound.add(name); - let openedBy: string | undefined; + for (const name of entrance.bound ?? []) bound.add(name); + let openedBy: string | undefined = entrance.openedBy; const open = (reason: string): void => { openedBy ??= reason; }; for (const graph of graphs) { @@ -216,14 +278,153 @@ export function flowCelRootScope( } } - if (triggerObject !== undefined && !fieldIndex.has(triggerObject)) { - open(`trigger object '${triggerObject}' is not declared in this stack, so its fields are not in hand`); - } for (const names of fieldIndex.values()) for (const name of names) bound.add(name); return openedBy === undefined ? { bound, provable: true } : { bound, provable: false, openedBy }; } +/** The stack slice {@link flowCelEntrances} reads. */ +export interface FlowCelEntranceHost { + readonly flows?: unknown; + readonly actions?: unknown; + readonly objects?: unknown; +} + +function configOf(node: AnyRec): AnyRec { + return node.config && typeof node.config === 'object' && !Array.isArray(node.config) ? (node.config as AnyRec) : {}; +} + +function nameOf(value: unknown): string | undefined { + return typeof value === 'string' && value ? value : undefined; +} + +/** + * Every flow of the stack whose entrances open it, or bind a name beyond the + * objects' fields — read from the stack's own declarations (the module's rule + * for records). A flow absent from the map is judged as its own text reads. + * + * Built once per stack, because an entrance lives OUTSIDE the flow it feeds (an + * action, another flow's `subflow` / `map` node) and a parent's openness passes + * to its children: the parent edges are read to a fixpoint. A child named by a + * node but not declared in this stack is another package's flow and is not + * judged here. + * + * @param fieldIndex object name → field names; an object is "declared" when it + * is a key here, the same universe the bound set reads. + */ +export function flowCelEntrances( + stack: FlowCelEntranceHost, + fieldIndex: ReadonlyMap, +): ReadonlyMap { + const flows = recordsOf(stack.flows); + const flowNames = new Set(flows.map((flow) => nameOf(flow.name)).filter((name): name is string => !!name)); + const openedBy = new Map(); + const bound = new Map(); + const open = (flow: string, reason: string): void => { + if (flowNames.has(flow) && !openedBy.has(flow)) openedBy.set(flow, reason); + }; + const undeclared = (object: string): boolean => !fieldIndex.has(object); + + // The flow's own trigger: a record trigger, a time-relative sweep, the inbound hook. + for (const flow of flows) { + const name = nameOf(flow.name); + if (!name) continue; + const start = recordsOf(flow.nodes).find((node) => node.type === 'start'); + const config = start ? configOf(start) : {}; + const triggerObject = nameOf(config.objectName); + if (triggerObject && undeclared(triggerObject)) { + open(name, `its trigger object '${triggerObject}' (start config.objectName) is not declared in this stack, so the record's keys are not in hand`); + } + const sweep = config.timeRelative && typeof config.timeRelative === 'object' ? (config.timeRelative as AnyRec) : undefined; + const sweepObject = sweep ? nameOf(sweep.object) : undefined; + if (sweepObject && undeclared(sweepObject)) { + open(name, `its time-relative sweep's object '${sweepObject}' (start config.timeRelative.object) is not declared in this stack, so the record's keys are not in hand`); + } + if (resolveFlowTriggerKind(flow) === 'api') { + open(name, 'its trigger kind is api, so the inbound hook hands the request body in as the record, whatever its keys'); + } + } + + // Actions that launch a flow: `actions[]`, and each object's own `actions[]`. + const launches: Array<{ action: AnyRec; object: string | undefined }> = []; + for (const action of recordsOf(stack.actions)) launches.push({ action, object: nameOf(action.objectName) }); + for (const object of recordsOf(stack.objects)) { + for (const action of recordsOf(object.actions)) { + launches.push({ action, object: nameOf(action.objectName) ?? nameOf(object.name) }); + } + } + for (const { action, object } of launches) { + const target = action.type === 'flow' ? nameOf(action.target) : undefined; + if (!target || !flowNames.has(target)) continue; + if (object === undefined) { + // Object-less: the action hands an empty record, carrying at most the row id it was given. + bound.set(target, [...(bound.get(target) ?? []), 'id']); + } else if (undeclared(object)) { + open(target, `action '${nameOf(action.name) ?? '?'}' launches it on '${object}' (the action's objectName), which this stack does not declare`); + } + } + + // Parent edges: a `subflow` or `map` node naming a flow of this stack. + const edges: Array<{ parent: string; child: string; kind: 'subflow' | 'map'; node: string }> = []; + for (const flow of flows) { + const parent = nameOf(flow.name); + if (!parent) continue; + const graphs = collectFlowGraphs({ + ...flow, + nodes: recordsOf(flow.nodes) as unknown as FlowNodeParsed[], + edges: recordsOf(flow.edges) as unknown as FlowEdgeParsed[], + }); + for (const graph of graphs) { + for (const node of recordsOf(graph.nodes)) { + if (node.type !== 'subflow' && node.type !== 'map') continue; + const config = configOf(node); + const child = nameOf(config.flowName); + if (!child || !flowNames.has(child)) continue; + const nodeId = nameOf(node.id) ?? '?'; + edges.push({ parent, child, kind: node.type, node: nodeId }); + if (node.type === 'map') { + const itemObject = nameOf(config.itemObject); + if (!itemObject) { + open(child, `map node '${nodeId}' in flow '${parent}' feeds it items and declares no config.itemObject, so the items' object is not in hand`); + } else if (undeclared(itemObject)) { + open(child, `map node '${nodeId}' in flow '${parent}' feeds it items of '${itemObject}' (config.itemObject), which this stack does not declare`); + } + } + } + } + } + for (let changed = true; changed;) { + changed = false; + for (const edge of edges) { + if (!openedBy.has(edge.parent) || openedBy.has(edge.child)) continue; + open(edge.child, `${edge.kind} node '${edge.node}' in flow '${edge.parent}' hands it that flow's record, and '${edge.parent}' is itself open`); + changed = true; + } + } + + const out = new Map(); + for (const name of flowNames) { + const reason = openedBy.get(name); + const names = bound.get(name); + if (reason === undefined && names === undefined) continue; + out.set(name, { ...(reason !== undefined ? { openedBy: reason } : {}), ...(names !== undefined ? { bound: names } : {}) }); + } + return out; +} + +/** + * The runtime publish gate's entrance: none visible. Its per-write snapshot + * carries the written flow alone (module note), so this one judgment stands down + * there until #22636 widens the snapshot. + */ +export function perWriteSnapshotEntrance(): FlowCelEntrance { + return { + openedBy: + 'the runtime publish gate judges a flow write against a per-write snapshot that carries no action and no ' + + 'other flow, so no entrance that can hand this flow a record is visible', + }; +} + /** cel-js's comprehension macros: the receiver calls that bind their first argument in the rest. */ const COMPREHENSION_MACROS: ReadonlySet = new Set(['all', 'exists', 'exists_one', 'map', 'filter']); diff --git a/packages/lint/src/validate-expressions.flow-cel-root.test.ts b/packages/lint/src/validate-expressions.flow-cel-root.test.ts index 5978a7d2fbd..e783dcd73be 100644 --- a/packages/lint/src/validate-expressions.flow-cel-root.test.ts +++ b/packages/lint/src/validate-expressions.flow-cel-root.test.ts @@ -10,13 +10,16 @@ * for every one of them. The three user spellings are the ones formulas, RLS and * the client bind; flow CEL binds the run's user as `current_user` only. * - * The judge lives in `flow-cel-root-scope.ts`; these pins drive it through the - * door `objectstack validate` and the runtime publish gate both call. + * The judge lives in `flow-cel-root-scope.ts`; these pins drive it through + * `validateStackExpressions`, the pass `objectstack validate` runs. The runtime + * publish gate runs the same pass and this one judgment stands down there (the + * last block below). */ import { describe, expect, it } from 'vitest'; -import { flowCelRootScope, unboundFlowCelRoots } from './flow-cel-root-scope.js'; +import { flowCelEntrances, flowCelRootScope, perWriteSnapshotEntrance, unboundFlowCelRoots } from './flow-cel-root-scope.js'; +import { runRuntimeAuthoringRules } from './runtime-gate.js'; import { validateStackExpressions } from './validate-expressions.js'; type AnyRec = Record; @@ -253,3 +256,147 @@ describe('the wider family — any root nothing binds (rides the same judge)', ( expect(issues[0]!.message).not.toContain('is not bound'); }); }); + +/** + * A′ (triage `6094431558`): every entrance the stack declares is read, and a flow + * an entrance hands a record of an undeclared (or unknowable) object is not + * judged. One pin per entrance: the undeclared case stands down, and the same + * flow fed by a declared object is still judged. + */ +describe('the entrances the stack declares (A′)', () => { + const USER = 'user.id == "u1"'; + /** A second flow `g` that names `f` from a node of the given type. */ + const parentFlow = (opts: { type?: string; start?: AnyRec; node: AnyRec }): AnyRec => ({ + name: 'g', + label: 'G', + type: opts.type ?? 'autolaunched', + nodes: [ + { id: 'start', type: 'start', config: opts.start ?? {} }, + opts.node, + { id: 'done', type: 'end', config: {} }, + ], + edges: [{ id: 'g1', source: 'start', target: opts.node.id }, { id: 'g2', source: opts.node.id, target: 'done' }], + }); + const withFlows = (stack: AnyRec, ...flows: AnyRec[]): AnyRec => ({ ...stack, flows: [...(stack.flows as AnyRec[]), ...flows] }); + const userRefusals = (stack: AnyRec) => rootFindings(stack).filter((i) => i.where.startsWith("flow 'f'")); + + it('a record trigger: an undeclared start config.objectName stands down; a declared one is judged', () => { + expect(userRefusals(flowStack({ edgeCondition: USER, objectName: 'sys_user' }))).toEqual([]); + expect(userRefusals(flowStack({ edgeCondition: USER, objectName: 'acct' }))).toHaveLength(1); + }); + + it('a time-relative sweep: an undeclared config.timeRelative.object stands down; a declared one is judged', () => { + const sweep = (object: string): AnyRec => { + const stack = flowStack({ edgeCondition: USER }); + const flow = (stack.flows as AnyRec[])[0]!; + (flow.nodes as AnyRec[])[0] = { id: 'start', type: 'start', config: { timeRelative: { object, dateField: 'due', withinDays: 7 } } }; + return stack; + }; + expect(userRefusals(sweep('sys_task'))).toEqual([]); + expect(userRefusals(sweep('acct'))).toHaveLength(1); + }); + + it('the inbound hook: a flow whose trigger kind is api stands down; the same flow as autolaunched is judged', () => { + const asApi = flowStack({ edgeCondition: USER }); + (asApi.flows as AnyRec[])[0]!.type = 'api'; + expect(userRefusals(asApi)).toEqual([]); + const viaTriggerType = flowStack({ edgeCondition: USER }); + (((viaTriggerType.flows as AnyRec[])[0]!.nodes as AnyRec[])[0] as AnyRec).config = { triggerType: 'api' }; + expect(userRefusals(viaTriggerType)).toEqual([]); + expect(userRefusals(flowStack({ edgeCondition: USER }))).toHaveLength(1); + }); + + it('an action: one launching the flow on an undeclared object stands down; on a declared object it is judged', () => { + const launched = (objectName: string): AnyRec => ({ + ...flowStack({ edgeCondition: USER }), + actions: [{ name: 'go', label: 'Go', type: 'flow', target: 'f', objectName }], + }); + expect(userRefusals(launched('sys_user'))).toEqual([]); + expect(userRefusals(launched('acct'))).toHaveLength(1); + // An object's own actions[] carry the owning object. + const nested = flowStack({ + edgeCondition: USER, + objects: [{ ...OBJECTS[0], actions: [{ name: 'go', label: 'Go', type: 'flow', target: 'f' }] }], + }); + expect(userRefusals(nested)).toHaveLength(1); + // A non-flow action naming the flow's name is no entrance. + expect(userRefusals({ ...flowStack({ edgeCondition: USER }), actions: [{ name: 'go', label: 'Go', type: 'url', target: 'f', objectName: 'sys_user' }] })).toHaveLength(1); + }); + + it('an object-less action binds only the row id it is given', () => { + const bare = flowStack({ edgeCondition: 'id == "r1"', objects: [] }); + expect(rootFindings(bare).map((i) => i.message.slice(0, 4))).toEqual(['`id`']); + expect(rootFindings({ ...bare, actions: [{ name: 'go', label: 'Go', type: 'flow', target: 'f' }] })).toEqual([]); + }); + + it('a map node: an undeclared or absent itemObject stands down the flow it feeds; a declared one is judged', () => { + const fedBy = (itemObject?: string): AnyRec => withFlows( + flowStack({ edgeCondition: USER }), + parentFlow({ node: { id: 'each', type: 'map', config: { collection: '{rows}', flowName: 'f', ...(itemObject ? { itemObject } : {}) } } }), + ); + expect(userRefusals(fedBy('sys_task'))).toEqual([]); + expect(userRefusals(fedBy(undefined))).toEqual([]); + expect(userRefusals(fedBy('acct'))).toHaveLength(1); + }); + + it('a parent that is itself open: its subflow child stands down, to a fixpoint; a judged parent leaves the child judged', () => { + const subflowOf = (parent: AnyRec, child = 'f'): AnyRec => ({ ...parent, nodes: (parent.nodes as AnyRec[]).map((n) => (n.type === 'subflow' ? { ...n, config: { flowName: child } } : n)) }); + const call = { id: 'call', type: 'subflow', config: {} }; + const openParent = subflowOf(parentFlow({ type: 'api', node: call })); + expect(userRefusals(withFlows(flowStack({ edgeCondition: USER }), openParent))).toEqual([]); + // Two levels: g (the inbound hook) → h → f. + const middle = { ...subflowOf(parentFlow({ node: call })), name: 'h', label: 'H' }; + expect(userRefusals(withFlows(flowStack({ edgeCondition: USER }), subflowOf(openParent, 'h'), middle))).toEqual([]); + // A parent fed by a declared trigger object is judged, and so is its child. + const judgedParent = subflowOf(parentFlow({ start: { objectName: 'acct' }, node: call })); + expect(userRefusals(withFlows(flowStack({ edgeCondition: USER }), judgedParent))).toHaveLength(1); + }); + + it('the reader names the entrance', () => { + const entrances = flowCelEntrances( + { + flows: [{ name: 'f', type: 'autolaunched', nodes: [{ id: 'start', type: 'start' }] }], + actions: [{ name: 'go', type: 'flow', target: 'f', objectName: 'sys_user' }], + }, + new Map([['acct', ['status']]]), + ); + expect(entrances.get('f')?.openedBy).toContain("action 'go' launches it on 'sys_user'"); + }); +}); + +/** + * S (triage `6094850599`): at the runtime publish gate the per-write snapshot + * holds the written flow alone, so no entrance is visible and this one judgment + * stands down there. Every other expression verdict at that door is unchanged. + * #22636 widens the snapshot; when it lands, this stand-down goes. + */ +describe('the runtime publish gate (S)', () => { + const written = { + name: 'f', + label: 'F', + type: 'autolaunched', + nodes: [{ id: 'start', type: 'start', config: {} }, { id: 'done', type: 'end', config: {} }], + edges: [ + { id: 'e1', source: 'start', target: 'done', condition: 'user.id == "u1"' }, + { id: 'e2', source: 'start', target: 'done', condition: '{record.status} == "open"' }, + ], + }; + + it('a flow write is not refused for an unbound root, while another expression verdict still refuses there', () => { + const result = runRuntimeAuthoringRules({ type: 'flow', item: written, context: { objects: OBJECTS } }); + const expressionErrors = result.errors.filter((f) => f.rule === 'expression-invalid'); + expect(expressionErrors.some((f) => / is not bound in /.test(f.message))).toBe(false); + expect(expressionErrors.some((f) => f.message.includes('template brace') && f.where.includes("edge 'e2'"))).toBe(true); + }); + + it('control: the build door refuses the same root on the same flow', () => { + const found = rootFindings({ objects: OBJECTS, flows: [written] }); + expect(found.map((i) => i.where)).toEqual(["flow 'f' · edge 'e1' (start→done) condition"]); + }); + + it('the stand-down names the per-write snapshot', () => { + const scope = flowCelRootScope({ nodes: [] }, [], undefined, new Map(), perWriteSnapshotEntrance()); + expect(scope.provable).toBe(false); + expect(scope.openedBy).toContain('per-write snapshot'); + }); +}); diff --git a/packages/lint/src/validate-expressions.test.ts b/packages/lint/src/validate-expressions.test.ts index 00bb2af7a09..812605ae001 100644 --- a/packages/lint/src/validate-expressions.test.ts +++ b/packages/lint/src/validate-expressions.test.ts @@ -3536,6 +3536,10 @@ describe('validateStackExpressions — reads only keys the spec declares (meta-t // root at a time. Their keys are that module's own `{ root, members }` and // the array's own methods — never metadata keys. 'unbound', 'unboundRoot', + // [#22565] The flow name → entrance index the root judge reads. Its one + // "key" is `Map.prototype.get`; the metadata keys it is built from are read + // in `flow-cel-root-scope.ts`, not here. + 'flowEntrances', ]); expect(receivers.filter((r) => !tabled.has(r) && !PLUMBING.has(r))).toEqual([]); }); diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index 1a7324e7d29..62ad8d3a9fc 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -122,7 +122,14 @@ import { referenceCarrierOf, REFERENCE_VALUE_TYPES } from '@objectstack/spec/dat import { EvalUserSchema } from '@objectstack/spec/identity'; import { collectFlowVariableNames, shadowedFieldReads, shadowedFieldMessage } from './flow-variable-scope.js'; -import { flowCelRootScope, unboundFlowCelRoots, unboundFlowCelRootMessage } from './flow-cel-root-scope.js'; +import { + flowCelEntrances, + flowCelRootScope, + perWriteSnapshotEntrance, + unboundFlowCelRoots, + unboundFlowCelRootMessage, +} from './flow-cel-root-scope.js'; +import type { FlowCelEntrance } from './flow-cel-root-scope.js'; import { injectedColumnsFor, unprovisionedInjectedColumnsFor } from './system-fields.js'; import { findUnguardedNullableOperands, nullGuardMessage } from './validate-null-guards.js'; import type { NullGuardOutcome } from './validate-null-guards.js'; @@ -2157,6 +2164,16 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression }; // ── Flows ────────────────────────────────────────────────────────── + // [#22565] Which flows an entrance opens to the root judgment — read once per + // stack, because an entrance lives outside the flow it feeds (an action, + // another flow's `subflow` / `map` node). At the runtime publish gate + // (`runtimeWriteType` set) the per-write snapshot holds the written flow alone, + // so no entrance is visible there and the judgment stands down + // (`perWriteSnapshotEntrance`) until #22636 widens that snapshot; every other + // verdict this pass gives at that door is unchanged. + const runtimeGateWrite = options.runtimeWriteType !== undefined; + const flowEntrances: ReadonlyMap = + objectWrite || runtimeGateWrite ? new Map() : flowCelEntrances(stack, fieldIndex); for (const flow of objectWrite ? [] : recordsOf(stack.flows)) { const flowName = typeof flow.name === 'string' ? flow.name : '(unnamed flow)'; // `Array.isArray` proves the LIST, never its MEMBERS — the sentence #15742 @@ -2221,10 +2238,19 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression * the same reason (one variable map serves the whole run): the flow's own * bindings, the engine's roots and every object's fields — see * `flow-cel-root-scope.ts` for the set, and for the flows it stands down on - * because their run can bind a name the reader cannot see. `error`: the run - * faults on the root, every time the expression is evaluated. + * because their run can bind a name the reader cannot see: a node's, or an + * entrance's (`flowEntrances` above). `error`: the run faults on the root, + * every time the expression is evaluated. */ - const rootScope = flowCelRootScope(flow, graphs, objectName, fieldIndex); + const rootScope = flowCelRootScope( + flow, + graphs, + objectName, + fieldIndex, + runtimeGateWrite + ? perWriteSnapshotEntrance() + : flowEntrances.get(typeof flow.name === 'string' ? flow.name : '') ?? {}, + ); /** * [#22565] Refuse each root `raw` reads that the flow does not bind. From e99141ce2af26323c48c69eff359b5d45b3da706 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 07:41:29 +0000 Subject: [PATCH 7/7] chore(spec,changeset): the refusal is objectstack validate's; name the entrances and the runtime gate's stand-down Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- .../22565-flow-cel-unbound-root-refused.md | 23 +++++++++---- .../18.flow-cel-unbound-root-refused.ts | 17 ++++++---- packages/spec/src/migrations/registry.ts | 34 +++++++++++-------- 3 files changed, 48 insertions(+), 26 deletions(-) diff --git a/.changeset/22565-flow-cel-unbound-root-refused.md b/.changeset/22565-flow-cel-unbound-root-refused.md index f2b8239728e..077dee9a07f 100644 --- a/.changeset/22565-flow-cel-unbound-root-refused.md +++ b/.changeset/22565-flow-cel-unbound-root-refused.md @@ -3,13 +3,13 @@ '@objectstack/spec': major --- -A flow CEL expression that reads a root the flow does not bind is refused at `objectstack validate` and at the runtime publish gate. The run fails such an expression with `Unknown variable: X` (X being the root) on every evaluation, and the build door used to pass it. The reachable case is the run user: `user.id == "u1"`, `ctx.user.id == "u1"` and `os.user.id == "u1"` — the spellings formulas, row-level security and the client accept — are refused, naming `current_user`, the one spelling flow CEL binds. +A flow CEL expression that reads a root the flow does not bind is refused at `objectstack validate`. The run fails such an expression with `Unknown variable: X` (X being the root) on every evaluation, and the build door used to pass it. The reachable case is the run user: `user.id == "u1"`, `ctx.user.id == "u1"` and `os.user.id == "u1"` — the spellings formulas, row-level security and the client accept — are refused, naming `current_user`, the one spelling flow CEL binds. Clause-②: no (narrowing) -**BREAKING**: an accept-set narrowing on a published authoring surface (`objectstack validate`, and the runtime publish gate's `expression-invalid` rule for a flow write). +**BREAKING**: an accept-set narrowing on a published authoring surface (`objectstack validate`'s `expression-invalid` rule). **Why.** Flow CEL evaluates in one scope per run: the flow's variables spread to top level, the trigger record's fields flattened beside them, and `record`, `previous`, `vars` and `current_user` bound by the engine. Formulas, row-level security and the client also bind the run's user as `user`, `ctx.user` and `os.user`; flow CEL does not, because a top-level `user` would collide with a variable or a lookup field of that name. So an author who wrote the alias by habit shipped a flow that faulted at its first evaluation, and nothing warned. @@ -17,11 +17,22 @@ Clause-②: no (narrowing) - the engine's roots: `record`, `previous`, `vars`, `current_user`; - a name the flow binds: a declared variable, an `outputVariable`, an `iteratorVariable` (a `loop`'s default `item` included), an `indexVariable`, an `errorVariable`, an `assignment` target, a node id, a screen field's `name`; -- a field (or a registry-injected column) of any object the stack declares — the run flattens whichever record its entrance hands it. +- a field (or a registry-injected column) of any object the stack declares — the run flattens whichever record its entrance hands it (an object-less flow action hands only the row `id`). The user spellings are told to write `current_user`, with the guard for a flow that can run without a user; any other root is named with what could have bound it, and the nearest in-scope name. The error is `expression-invalid`, `error`. Where the flattened-scope near-miss advisory ("`X` is not a field of … If `X` is a flow variable this is safe to ignore") named the same root, the refusal replaces it. -**Not judged — the door stands down where the run can bind a name it cannot see.** A flow carrying a `script` or `connector_action` node (each hands its code the live variable map), a `wait`, `subflow` or `map` node (a resume folds the caller's `variables` bag in under their plain names), a screen with no field list, a node type outside the builtin set, or a trigger object the stack does not declare. A `$` name has no CEL spelling (`$runId == "x"` does not parse), so the `$` roots stay the text-slot rule's. +**Not judged — the door stands down where the run can bind a name it cannot see.** + +- **A node:** a `script` or `connector_action` node (each hands its code the live variable map), a `wait`, `subflow` or `map` node (a resume folds the caller's `variables` bag in under their plain names), a screen with no field list, or a node type outside the builtin set. +- **An entrance the stack declares that hands the flow a record whose keys are not in hand:** + - a record trigger (`config.objectName`) or a time-relative sweep (`config.timeRelative.object`) on an object the stack does not declare; + - an `api` trigger, whose inbound hook hands the request body in as the record; + - a `type: 'flow'` action launching the flow on an undeclared object; + - a `map` node feeding the flow whose `itemObject` is undeclared or absent; + - a `subflow` or `map` parent that is itself such a flow. +- **The runtime publish gate:** a flow write there is not judged for unbound roots yet. Its per-write snapshot carries no actions or other flows, so it cannot see those entrances. Every other expression verdict at that door is unchanged. + +A `$` name has no CEL spelling (`$runId == "x"` does not parse), so the `$` roots stay the text-slot rule's. ## FROM → TO @@ -34,9 +45,9 @@ The user spellings are told to write `current_user`, with the guard for a flow t **The one-line fix: write `current_user` for the run user, and bind or correct every other root the refusal names.** -**Who is affected, measured.** The last published spec line, 17.x, judged a flow expression's syntax only, so it accepts every refused source. Measured with the judge over every flow CEL site: the example apps (`app-crm`, `app-todo`, `app-multi-package`, `app-showcase`: 35 flows, 45 sites), `packages/platform-objects` (no flows) and `objectstack-ai/hotcrm` at `f0afcbda07` (32 flows, 65 sites) — 0 refusals; every root those flows read is an engine root, a binding of the flow's own or a field of its trigger object. Deployed metadata and other repositories were not measured. +**Who is affected, measured.** The last published spec line, 17.x, judged a flow expression's syntax only, so it accepts every refused source. Measured with the judge over every flow CEL site: the example apps (`app-crm`, `app-todo`, `app-multi-package`, `app-showcase`: 35 flows, 45 sites), `packages/platform-objects` (no flows) and `objectstack-ai/hotcrm` at `f0afcbda07` (32 flows, 65 sites) — 0 refusals; every root those flows read is an engine root, a binding of the flow's own or a field of its trigger object. One flow of the 67 is left unjudged by an entrance (`showcase_inbound_task_webhook`, an `api` trigger). Deployed metadata and other repositories were not measured. ### The kit -- **The judge.** `flow-cel-root-scope.ts` in `@objectstack/lint`, called by `validateStackExpressions` at each flow CEL site. One bound set per flow, read once like the shadowing pass's variable set. +- **The judge.** `flow-cel-root-scope.ts` in `@objectstack/lint`, called by `validateStackExpressions` at each flow CEL site. One bound set per flow, read once like the shadowing pass's variable set, and one entrance index per stack (`flowCelEntrances`). - **The ledger.** The D3 semantic entry `flow-cel-unbound-root-refused` (protocol 18). There is no D2 conversion: none of these roots ever evaluated in a flow, so there is no behaviour to carry over. diff --git a/packages/spec/src/migrations/entries/semantic/18.flow-cel-unbound-root-refused.ts b/packages/spec/src/migrations/entries/semantic/18.flow-cel-unbound-root-refused.ts index 500b47ad250..eecfbcc5705 100644 --- a/packages/spec/src/migrations/entries/semantic/18.flow-cel-unbound-root-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.flow-cel-unbound-root-refused.ts @@ -30,12 +30,17 @@ export const entry: SemanticMigration = { + 'and the build door, which judged a flow expression\'s syntax only, passed it. The run-user spellings are ' + 'the reachable case: formulas, row-level security and the client bind the run\'s user under user, ctx.user ' + 'and os.user as well, and flow CEL does not, because a top-level user would collide with a variable or a ' - + 'lookup field of that name. objectstack validate and the runtime publish gate now refuse such a root, naming ' - + 'current_user for the user spellings and the in-scope names for any other. A flow whose run can bind a name ' - + 'the reader cannot see is not judged: one with a script or connector_action node, a wait, subflow or map node ' - + 'a resume can fold a bag into, a screen with no field list, a plugin node type, or a trigger object the stack ' - + 'does not declare. No D2 conversion exists: none of these roots ever evaluated in a flow, so there is no ' - + 'behaviour to carry over, and a run without a user needs a guard only the author can choose.', + + 'lookup field of that name. objectstack validate now refuses such a root, naming current_user for the user ' + + 'spellings and the in-scope names for any other. A flow whose run can bind a name the reader cannot see is not ' + + 'judged: one with a script or connector_action node, a wait, subflow or map node a resume can fold a bag into, ' + + 'a screen with no field list, or a plugin node type; and one an entrance the stack declares hands a record ' + + 'whose keys are not in hand: a record trigger or time-relative sweep on an object the stack does not declare, ' + + 'an api trigger (the inbound hook hands the request body in as the record), a flow action on an undeclared ' + + 'object, a map node whose itemObject is undeclared or absent, or a subflow or map parent that is itself such a ' + + 'flow. The runtime publish gate does not give this verdict yet: it judges a flow write without the stack\'s ' + + 'actions and other flows, so it cannot see those entrances. No D2 conversion exists: none of these roots ever ' + + 'evaluated in a flow, so there is no behaviour to carry over, and a run without a user needs a guard only the ' + + 'author can choose.', acceptanceCriteria: 'Run objectstack validate: it reports each refused root as expression-invalid at the flow, the node or edge and ' + 'the slot, naming the root and its remedy. Replace each run-user spelling with current_user, guarded where the ' diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 6bf61c582f6..830c1cbb569 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5701,14 +5701,15 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ id: 'flow-cel-unbound-root-refused', order: 92, text: - 'It also refuses, at `objectstack validate` and the runtime publish gate, a flow CEL root the flow does not ' - + 'bind — in a trigger gate, an edge, a `decision` branch, a screen field `visibleWhen` or a CEL value ' - + 'envelope. Such a root fails every run that reaches it with an unknown-variable fault, and the build used ' - + 'to pass it. The run-user spellings formulas, row-level security and the client accept (`user`, ' - + '`ctx.user`, `os.user`) are the reachable case: flow CEL binds the run\'s user as `current_user` only, and ' - + 'the refusal names it. A flow whose run can bind a name the reader cannot see is left unjudged, and no D2 ' - + 'conversion exists: none of these roots ever evaluated in a flow. Its D3 record is the semantic entry ' - + '`flow-cel-unbound-root-refused`.', + 'It also refuses, at `objectstack validate`, a flow CEL root the flow does not bind — in a trigger gate, an ' + + 'edge, a `decision` branch, a screen field `visibleWhen` or a CEL value envelope. Such a root fails every run ' + + 'that reaches it with an unknown-variable fault, and the build used to pass it. The run-user spellings ' + + 'formulas, row-level security and the client accept (`user`, `ctx.user`, `os.user`) are the reachable case: ' + + 'flow CEL binds the run\'s user as `current_user` only, and the refusal names it. A flow whose run can bind a ' + + 'name the reader cannot see is left unjudged — through a node, or through an entrance the stack declares that ' + + 'hands it a record whose keys are not in hand — and so, for now, is every flow write at the runtime publish ' + + 'gate, whose snapshot carries no actions or other flows. No D2 conversion exists: none of these roots ever ' + + 'evaluated in a flow. Its D3 record is the semantic entry `flow-cel-unbound-root-refused`.', }, { id: 'flow-decision-edge-branching-first-match', @@ -13818,12 +13819,17 @@ const step18: MigrationStep = { + 'and the build door, which judged a flow expression\'s syntax only, passed it. The run-user spellings are ' + 'the reachable case: formulas, row-level security and the client bind the run\'s user under user, ctx.user ' + 'and os.user as well, and flow CEL does not, because a top-level user would collide with a variable or a ' - + 'lookup field of that name. objectstack validate and the runtime publish gate now refuse such a root, naming ' - + 'current_user for the user spellings and the in-scope names for any other. A flow whose run can bind a name ' - + 'the reader cannot see is not judged: one with a script or connector_action node, a wait, subflow or map node ' - + 'a resume can fold a bag into, a screen with no field list, a plugin node type, or a trigger object the stack ' - + 'does not declare. No D2 conversion exists: none of these roots ever evaluated in a flow, so there is no ' - + 'behaviour to carry over, and a run without a user needs a guard only the author can choose.', + + 'lookup field of that name. objectstack validate now refuses such a root, naming current_user for the user ' + + 'spellings and the in-scope names for any other. A flow whose run can bind a name the reader cannot see is not ' + + 'judged: one with a script or connector_action node, a wait, subflow or map node a resume can fold a bag into, ' + + 'a screen with no field list, or a plugin node type; and one an entrance the stack declares hands a record ' + + 'whose keys are not in hand: a record trigger or time-relative sweep on an object the stack does not declare, ' + + 'an api trigger (the inbound hook hands the request body in as the record), a flow action on an undeclared ' + + 'object, a map node whose itemObject is undeclared or absent, or a subflow or map parent that is itself such a ' + + 'flow. The runtime publish gate does not give this verdict yet: it judges a flow write without the stack\'s ' + + 'actions and other flows, so it cannot see those entrances. No D2 conversion exists: none of these roots ever ' + + 'evaluated in a flow, so there is no behaviour to carry over, and a run without a user needs a guard only the ' + + 'author can choose.', acceptanceCriteria: 'Run objectstack validate: it reports each refused root as expression-invalid at the flow, the node or edge and ' + 'the slot, naming the root and its remedy. Replace each run-user spelling with current_user, guarded where the '