From 34e78b04a890b69f4c5aa18dcccfaa09cd4e301b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 13:00:02 +0000 Subject: [PATCH 1/8] fix(spec/automation): refuse a text-slot hole over a $ root the flow engine does not bind A `{{ $User.Id }}` hole in a flow text slot compiled (the hole grammar admits `$` so the engine's own `$error` has a spelling) and rendered a blank fragment with the run reporting success, while `{$User.Id}` was refused at the same door with its remedy. The text-slot judge now reads one enumerated list of the `$` variables the engine binds and refuses a hole over any other `$` root: `{{ $User. }}` gets the remedy its single-brace spelling gets, any other root a remedy naming the engine's variables. A single-brace path token over such a root is no longer rewritten to a hole the judge would refuse in turn. Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude --- .../flow-text-slot-template.test.ts | 136 +++++++++++- .../src/automation/flow-text-slot-template.ts | 204 ++++++++++++++++-- 2 files changed, 325 insertions(+), 15 deletions(-) diff --git a/packages/spec/src/automation/flow-text-slot-template.test.ts b/packages/spec/src/automation/flow-text-slot-template.test.ts index dc2480d404..729022270f 100644 --- a/packages/spec/src/automation/flow-text-slot-template.test.ts +++ b/packages/spec/src/automation/flow-text-slot-template.test.ts @@ -19,7 +19,8 @@ import { textSlotTemplateRefusal, } from './flow-text-slot-template'; import { valueSlotTemplateRefusals } from './flow-value-slot-template'; -import { ScreenConfigSchema } from './builtin-node-config.zod'; +import { EndConfigSchema, ScreenConfigSchema } from './builtin-node-config.zod'; +import { NotifyConfigSchema } from './io-node-config.zod'; import { tmpl } from '../shared/expression.zod'; describe('FLOW_NODE_TEXT_SLOTS — where the `{{ }}` text slots are', () => { @@ -35,7 +36,7 @@ describe('FLOW_NODE_TEXT_SLOTS — where the `{{ }}` text slots are', () => { }); describe('textSlotTemplateRefusal — the one judge of the single brace in a text slot', () => { - it('passes text with no single-brace token: plain text, `{{ }}` holes, a `$`-named hole, a formatter', () => { + it('passes text with no single-brace token: plain text, `{{ }}` holes, a hole over an engine-bound `$` variable, a formatter', () => { for (const text of [ 'Health dropped to red — please review.', 'Deal won: {{ record.name }}', @@ -109,6 +110,98 @@ describe('textSlotTemplateRefusal — the one judge of the single brace in a tex }); }); +/** + * #22477 — a `{{ $… }}` hole over a root the flow engine does not bind is + * refused at the same door as the single brace, with the remedy `{$User.Id}` + * already gets; the `$` roots the engine binds stay admitted. The list itself + * is pinned against the engine's sources in `service-automation`'s + * `text-slot-template.test.ts`. + */ +describe('textSlotTemplateRefusal — a `{{ }}` hole over a `$` root the engine does not bind', () => { + /** The remedy part of a single-brace refusal — what follows the shared lead sentence. */ + const singleBraceRemedy = (text: string) => textSlotTemplateRefusal(text)!.slice(TEXT_SLOT_TEMPLATE_REFUSAL.length + 1); + + it('refuses `By {{ $User.Id }}` with the very remedy `{$User.Id}` gets — the two spellings answer alike', () => { + const message = textSlotTemplateRefusal('By {{ $User.Id }}'); + expect(message).toBeDefined(); + // Not the single-brace rule: the author wrote a hole. + expect(message!.startsWith(TEXT_SLOT_TEMPLATE_REFUSAL)).toBe(false); + const remedy = singleBraceRemedy('By {$User.Id}'); + expect(remedy).toContain("assignments: { v: '{$User.Id}' }"); + expect(remedy).toContain('`{{ v }}`'); + expect(message!.endsWith(remedy)).toBe(true); + // …and the value-slot spelling that remedy names is one the value slot keeps. + expect(valueSlotTemplateRefusals('{$User.Id}')).toEqual([]); + }); + + it('gives every `$User.` hole its single-brace remedy, formatter or not', () => { + for (const [text, single] of [ + ['{{$User.Email}}', '{$User.Email}'], + ['Owner: {{ $User.Name | upper }}', '{$User.Name}'], + ] as const) { + expect(textSlotTemplateRefusal(text), text).toContain(`assignments: { v: '${single}' }`); + } + }); + + it('admits a hole over each `$` variable the engine binds, a formatter and an index included', () => { + for (const text of [ + '{{ $error.message }}', + 'Failed: {{ $error.code | upper }}', + '{{ $record.name }}', + 'Run {{ $runId }} of {{ $flowName }} ({{ $flowLabel }})', + '{{ $loopItems[0] }} at {{ $loopIndex }}', + ]) { + expect(textSlotTemplateRefusal(text), text).toBeUndefined(); + } + }); + + it('control: an ordinary hole and a node output are unchanged', () => { + expect(textSlotTemplateRefusal('Deal won: {{ record.name }}')).toBeUndefined(); + expect(textSlotTemplateRefusal('{{ lookup.result }} / {{ rows[0].subject }}')).toBeUndefined(); + }); + + it('refuses any other `$` root — a bare `$User`, a case slip, an invented name — naming the root and the engine\'s variables', () => { + for (const [text, root] of [ + ['{{ $User }}', '$User'], + ['{{ $Error.message }}', '$Error'], + ['{{ $org.id | upper }}', '$org'], + ['{{ $caught[0] }}', '$caught'], + ] as const) { + const message = textSlotTemplateRefusal(text)!; + expect(message, text).toBeDefined(); + expect(message, text).toContain(`\`${text}\` names \`${root}\``); + expect(message, text).toContain('`$error`'); + expect(message, text).toContain('`{{ v }}`'); + expect(message, text).not.toContain("assignments: { v: '"); + } + }); + + it('names each such hole once, and judges a text holding a single-brace token too — single brace first', () => { + const message = textSlotTemplateRefusal('{{ $User.Id }} and {{ $User.Id }} for {owner}')!; + expect(message.startsWith(TEXT_SLOT_TEMPLATE_REFUSAL)).toBe(true); + expect(message).toContain('`{{ $User.Id }} and {{ $User.Id }} for {{ owner }}`'); + expect(message.split("assignments: { v: '{$User.Id}' }")).toHaveLength(2); + }); + + it('leaves a hole that does not compile as a path to the compile step', () => { + for (const text of ['{{ $User.Id + 1 }}', '{{ $User. Id }}', '{{{ $User.Id }}']) { + expect(textSlotTemplateRefusal(text), text).toBeUndefined(); + } + }); + + // The single-brace rewrite must never prescribe a hole this judge refuses. + it('prescribes no `{{ }}` rewrite for a single-brace path over such a root — its remedy instead', () => { + const message = textSlotTemplateRefusal('Failed: {$caught.message}')!; + expect(message.startsWith(TEXT_SLOT_TEMPLATE_REFUSAL)).toBe(true); + expect(message).not.toContain('{{ $caught.message }}'); + expect(message).not.toContain('Write `'); + expect(message).toContain('`{$caught.message}` names `$caught`'); + // …while a path beside it is still rewritten, and an engine-bound one is doubled as before. + expect(textSlotTemplateRefusal('{name}: {$Foo}')).toContain('`{{ name }}: {$Foo}`'); + expect(textSlotTemplateRefusal('Failed: {$error.message}')).toContain('`Failed: {{ $error.message }}`'); + }); +}); + describe('flowNodeTextSlotSources — the text slots one node config carries', () => { it('reads the notify pair as a bare string or a template envelope, and nothing else on the node', () => { expect(flowNodeTextSlotSources('notify', { @@ -143,8 +236,47 @@ describe('ScreenConfigSchema — its two text slots compose the judge', () => { } }); + it('refuses a `{{ $User.Id }}` hole in `title` or `description` at the key, with its remedy', () => { + for (const key of ['title', 'description'] as const) { + const refused = ScreenConfigSchema.safeParse({ [key]: 'By {{ $User.Id }}' }); + expect(refused.success, key).toBe(false); + expect(refused.error?.issues.map((i) => [i.code, i.path.join('.')]), key).toEqual([['custom', key]]); + expect(refused.error?.issues[0]!.message, key).toContain("assignments: { v: '{$User.Id}' }"); + } + }); + it('keeps the single-brace dialect on the value-like keys — a `recordId` names a record, not text', () => { expect(ScreenConfigSchema.safeParse({ objectName: 'account', mode: 'edit', recordId: '{account_id}', defaults: { name: '{lead.company}' } }).success) .toBe(true); }); }); + +describe('the node contracts — `By {{ $User.Id }}` is refused at the schema (#22477)', () => { + it('refuses it in a notify `title` / `message` (a bare string or a template envelope), at the key, with its remedy', () => { + for (const config of [ + { recipients: ['u1'], title: 'By {{ $User.Id }}' }, + { recipients: ['u1'], title: 'Closed', message: tmpl`By {{ $User.Id }}` }, + ]) { + const key = 'message' in config ? 'message' : 'title'; + const refused = NotifyConfigSchema.safeParse(config); + expect(refused.success, key).toBe(false); + expect(refused.error?.issues.map((i) => [i.code, i.path.join('.')]), key).toEqual([['custom', key]]); + expect(refused.error?.issues[0]!.message, key).toContain("assignments: { v: '{$User.Id}' }"); + } + }); + + it('refuses it in a refusing `end` node\'s `message`', () => { + const refused = EndConfigSchema.safeParse({ outcome: 'refused', message: 'Refused by {{ $User.Id }}' }); + expect(refused.success).toBe(false); + expect(refused.error?.issues.map((i) => [i.code, i.path.join('.')])).toEqual([['custom', 'message']]); + expect(refused.error?.issues[0]!.message).toContain("assignments: { v: '{$User.Id}' }"); + }); + + it('control: `{{ $error.message }}` and `{{ record.name }}` still parse in every text slot', () => { + for (const text of ['Failed: {{ $error.message }}', 'Deal won: {{ record.name }}']) { + expect(NotifyConfigSchema.safeParse({ recipients: ['u1'], title: text, message: tmpl`${text}` }).success, text).toBe(true); + expect(ScreenConfigSchema.safeParse({ title: text, description: text }).success, text).toBe(true); + expect(EndConfigSchema.safeParse({ outcome: 'refused', message: text }).success, text).toBe(true); + } + }); +}); diff --git a/packages/spec/src/automation/flow-text-slot-template.ts b/packages/spec/src/automation/flow-text-slot-template.ts index a6ae3872df..b022c9d7bc 100644 --- a/packages/spec/src/automation/flow-text-slot-template.ts +++ b/packages/spec/src/automation/flow-text-slot-template.ts @@ -62,9 +62,26 @@ * `assignment` node, whose value slot still reads that spelling, and write the * variable as a hole. So the single brace is deleted from the text slots * whole, and the two dialects never share one string. + * + * ## A `$` root is the engine's (#22477) + * + * The hole grammar admits `$` in a name so that the engine's own variables + * have a spelling (`{{ $error.message }}`), which also makes `{{ $User.Id }}` + * a well-formed hole — over a root no flow variable answers to, so it rendered + * a blank fragment with the run reporting success, while `{$User.Id}` was + * refused here with its remedy. The `$` names are reserved for the engine (a + * resume signal may not write one — `IAutomationService.resume`'s + * `INVALID_SIGNAL`), so this judge also refuses a hole whose root is a `$` + * name the engine does not bind ({@link FLOW_ENGINE_VARIABLES}), and a + * single-brace path token over one gets the same remedy instead of a + * `{{ }}` rewrite that would be refused in turn. `{{ $User. }}` gets + * the very sentence `{$User.}` gets: compute it with an `assignment` + * node, then write `{{ v }}`. ⛔ The template engine does not learn `$User` + * (or any new `$` root) instead — that would widen the flow's variable set + * with no declaration behind it. */ -import { celExpression, templateTokensOf, type TemplateToken } from './flow-template-token'; +import { celExpression, templateTokenKind, templateTokensOf, type TemplateToken } from './flow-template-token'; /** * The one sentence every refusal of a `{…}` token in a text slot leads with — @@ -76,6 +93,48 @@ export const TEXT_SLOT_TEMPLATE_REFUSAL = 'A flow text slot reads `{{ }}` template holes (ADR-0032 §3), not the single-brace `{…}` dialect: a `{…}` token ' + 'here is no placeholder any more and would be sent as literal text, so it is refused.'; +/** + * The `$`-named variables the flow engine binds — the only `$` roots a text + * slot's hole may name. One enumerated list, measured from where + * `service-automation` binds them, because the spec cannot import a runtime: + * + * - `$record`, `$runId`, `$flowName`, `$flowLabel` — seeded at the start of + * every run attempt (`AutomationEngine.seedRunVariables`; `$record` when + * the run has a trigger record); + * - `$error` — published by the engine when a node fails, the value a + * `fault` edge's handler and a `try_catch` region read; + * - `$loopItems`, `$loopIndex` — bound by a flat-graph `loop` with no `body` + * (`loop-node.ts`'s legacy branch). + * + * `text-slot-template.test.ts` in that package scans its sources for every + * `$`-named variable they bind by name and asserts this judge admits a hole + * over each, so a root the engine starts binding without a line here reddens + * there. A node output is not a `$` root: it is addressed by its node id + * (`{{ lookup.result }}`). ⛔ Package-internal on purpose — the refusal names + * the list, and a published copy would be a second public answer to a + * question the engine owns. + */ +const FLOW_ENGINE_VARIABLES: readonly string[] = [ + '$record', + '$runId', + '$flowName', + '$flowLabel', + '$error', + '$loopItems', + '$loopIndex', +]; + +const ENGINE_VARIABLE_SET: ReadonlySet = new Set(FLOW_ENGINE_VARIABLES); + +/** + * The sentence every refusal of a `{{ }}` hole over an unbound `$` root leads + * with — the same words at every door, as {@link TEXT_SLOT_TEMPLATE_REFUSAL} + * is for the single brace. + */ +const ENGINE_VARIABLE_HOLE_REFUSAL = + 'A `{{ }}` hole in a flow text slot may name a `$` variable only when the flow engine binds it — the `$` names ' + + 'are reserved for the engine — so a hole over any other `$` name is refused rather than sent as a blank fragment.'; + /** One text slot of a builtin flow node — a config key whose value renders to text. */ export interface FlowNodeTextSlot { /** Registry node type the slot belongs to (`node.type`). */ @@ -138,12 +197,87 @@ function singleBraceTokens(text: string): TemplateToken[] { text[token.index - 1] !== '{' && text[token.index + token.text.length] !== '}'); } -/** `text` with each single-brace PATH token written as a hole — the spelling a refusal prescribes for it. */ +/** + * A `{{ … }}` hole — the template engine's `HOLE_RE` (`@objectstack/formula` + * `template-engine.ts`), verbatim: the inner content, no `}` inside. + */ +const HOLE = /\{\{([^}]*)\}\}/g; + +/** + * A hole's path — the engine's `PATH_ONLY_RE`, verbatim. A hole whose path + * fails it does not compile, and the compile step every door runs next names + * it; this judge reads only the holes that do. + */ +const HOLE_PATH = /^[\w$.[\]]+$/; + +/** + * The `$` root of a variable path when the engine does not bind it, else + * `undefined`. The root is the path's first segment as the engine resolves it + * (`[i]` read as `.i`), so `$User.Id`, `$User[0]` and `$User` all root at + * `$User`. + */ +function unboundEngineRoot(path: string): string | undefined { + const root = path.replace(/\[(\w+)\]/g, '.$1').split('.').find((segment) => segment !== ''); + if (root === undefined || !root.startsWith('$') || ENGINE_VARIABLE_SET.has(root)) return undefined; + return root; +} + +/** One `{{ }}` hole of a text slot whose root is a `$` name the engine does not bind. */ +interface UnboundRootHole { + /** The hole as written (`{{ $User.Id }}`). */ + readonly text: string; + /** Its variable path, trimmed (`$User.Id`). */ + readonly path: string; + /** The path's `$` root (`$User`). */ + readonly root: string; +} + +/** Every hole of `text` that compiles as a path and roots at a `$` name the engine does not bind, in order. */ +function unboundRootHoles(text: string): UnboundRootHole[] { + const out: UnboundRootHole[] = []; + for (const match of text.matchAll(HOLE)) { + const inner = match[1]!; + const pipe = inner.indexOf('|'); + const path = (pipe === -1 ? inner : inner.slice(0, pipe)).trim(); + if (!HOLE_PATH.test(path)) continue; + const root = unboundEngineRoot(path); + if (root !== undefined) out.push({ text: match[0], path, root }); + } + return out; +} + +/** + * The remedy for a `$` root the engine does not bind, written as `written` — + * a hole or a single-brace token. Names the engine's variables, since that is + * the list the author's `$` name was read against. + */ +function unboundRootRemedy(written: string, root: string): string { + return ( + `\`${written}\` names \`${root}\`, which is not one of the flow engine's own variables ` + + `(${FLOW_ENGINE_VARIABLES.map((name) => `\`${name}\``).join(', ')}), so no hole spells it: write the variable ` + + 'that holds the value — one the flow binds itself (a declared variable, an `assignment` target, an ' + + '`outputVariable`, a `try_catch` `errorVariable`) is named without the `$` — or compute the value into a ' + + 'variable with an `assignment` node and write `{{ v }}` here.' + ); +} + +/** A single-brace PATH token over a `$` root the engine does not bind — no hole spells it either. */ +function unboundRootOf(token: TemplateToken): string | undefined { + return token.kind === 'path' ? unboundEngineRoot(token.inner) : undefined; +} + +/** + * `text` with each single-brace PATH token written as a hole — the spelling a + * refusal prescribes for it. A path over a `$` root the engine does not bind + * stays as written: its hole would be refused too, so its remedy is + * {@link unboundRootRemedy}, never a rewrite. + */ function doubled(text: string, tokens: readonly TemplateToken[]): string { let out = ''; let at = 0; for (const token of tokens) { - out += text.slice(at, token.index) + (token.kind === 'path' ? `{{ ${token.inner} }}` : token.text); + const spellable = token.kind === 'path' && unboundRootOf(token) === undefined; + out += text.slice(at, token.index) + (spellable ? `{{ ${token.inner} }}` : token.text); at = token.index + token.text.length; } return out + text.slice(at); @@ -173,29 +307,73 @@ function unspellableRemedy(token: TemplateToken): string { } } -/** - * Why `text` — a text slot's template — is refused, or `undefined` when it - * carries no single-brace token. The message leads with - * {@link TEXT_SLOT_TEMPLATE_REFUSAL}, then names the `{{ }}` spelling of every - * path token (the whole text rewritten) and the remedy for every token no hole - * can spell. - */ -export function textSlotTemplateRefusal(text: string): string | undefined { +/** The refusal of `text`'s single-brace tokens, or `undefined` when it carries none. */ +function singleBraceRefusal(text: string): string | undefined { const tokens = singleBraceTokens(text); if (tokens.length === 0) return undefined; const parts: string[] = []; - if (tokens.some((token) => token.kind === 'path')) { + if (tokens.some((token) => token.kind === 'path' && unboundRootOf(token) === undefined)) { parts.push(`Write \`${text}\` as \`${doubled(text, tokens)}\`.`); } const seen = new Set(); for (const token of tokens) { - if (token.kind === 'path' || seen.has(token.text)) continue; + if (seen.has(token.text)) continue; + if (token.kind === 'path') { + const root = unboundRootOf(token); + if (root === undefined) continue; + seen.add(token.text); + parts.push(unboundRootRemedy(token.text, root)); + continue; + } seen.add(token.text); parts.push(unspellableRemedy(token)); } return `${TEXT_SLOT_TEMPLATE_REFUSAL} ${parts.join(' ')}`; } +/** + * The refusal of `text`'s holes over a `$` root the engine does not bind, or + * `undefined` when it has none. A `$User.` hole gets the remedy its + * single-brace spelling gets, word for word, so the two spellings answer + * alike; any other root gets {@link unboundRootRemedy}. + */ +function unboundRootHoleRefusal(text: string): string | undefined { + const holes = unboundRootHoles(text); + if (holes.length === 0) return undefined; + const parts: string[] = []; + const seen = new Set(); + for (const hole of holes) { + if (seen.has(hole.text)) continue; + seen.add(hole.text); + const single = `{${hole.path}}`; + parts.push( + templateTokenKind(hole.path) === 'user' + ? unspellableRemedy({ text: single, inner: hole.path, kind: 'user', index: 0 }) + : unboundRootRemedy(hole.text, hole.root), + ); + } + return `${ENGINE_VARIABLE_HOLE_REFUSAL} ${parts.join(' ')}`; +} + +/** + * Why `text` — a text slot's template — is refused, or `undefined` when it + * carries neither a single-brace token nor a hole over a `$` root the engine + * does not bind. + * + * A single-brace token leads with {@link TEXT_SLOT_TEMPLATE_REFUSAL}, then the + * `{{ }}` spelling of every path token (the whole text rewritten) and the + * remedy for every token no hole can spell. A hole such as `{{ $User.Id }}` + * leads with its own sentence (the `$` names are the engine's), then the + * remedy for each such hole — `{{ $User.Id }}` gets the one `{$User.Id}` + * gets. A text carrying both gets both, single brace first. + */ +export function textSlotTemplateRefusal(text: string): string | undefined { + const refusals = [singleBraceRefusal(text), unboundRootHoleRefusal(text)].filter( + (refusal): refusal is string => refusal !== undefined, + ); + return refusals.length === 0 ? undefined : refusals.join(' '); +} + /** One text slot present in a node's config, with the text it carries. */ export interface FlowNodeTextSlotSource { /** Path into `node.config` — the slot's key. */ From 6fafd2a148c31556ad79031092fa33e5eb966159 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 13:01:06 +0000 Subject: [PATCH 2/8] test(automation,lint): pin the $-root hole refusal at registerFlow and validate, and the engine-bound list The registerFlow and `objectstack validate` doors refuse `By {{ $User.Id }}` in a text slot through the spec judge, with the remedy `{$User.Id}` gets; `{{ $error.message }}` and `{{ record.name }}` stay clean. In service-automation, a scan of the package's sources for every `$`-named variable it binds by literal name asserts the spec judge admits a hole over each, so a root the engine starts binding without a line in the spec list reddens here. Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude --- .../validate-expressions.text-slot.test.ts | 20 +++++ .../src/builtin/text-slot-template.test.ts | 76 ++++++++++++++++++- 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/packages/lint/src/validate-expressions.text-slot.test.ts b/packages/lint/src/validate-expressions.text-slot.test.ts index 9e26f905f4..cadca1bf7c 100644 --- a/packages/lint/src/validate-expressions.text-slot.test.ts +++ b/packages/lint/src/validate-expressions.text-slot.test.ts @@ -16,6 +16,10 @@ * 2. a slot with none is compiled as a `template` — a hole holding logic or an * unknown formatter is an `error` too. * + * And #22477: a `{{ }}` hole whose root is a `$` name the flow engine does not + * bind (`{{ $User.Id }}`, which rendered a blank fragment) is refused by the + * same judge — same door, same finding — with the remedy `{$User.Id}` gets. + * * Every other notify / screen string keeps the single-brace dialect and gets * nothing here. The `end` message is refused one door earlier, at the flow * parse (`EndConfigSchema`), so it is pinned on `validateStackExpressions` @@ -117,6 +121,22 @@ describe('`objectstack validate` — a flow text slot reads `{{ }}` holes (#2211 expect(validate('screen', { objectName: 'deal', mode: 'edit', recordId: '{record.id}', title: 'Edit {{ record.name }}' })).toEqual([]); }); + it('refuses `By {{ $User.Id }}` in a text slot at `error`, with the remedy `{$User.Id}` gets (#22477)', () => { + for (const [nodeType, config, where] of [ + ['notify', { recipients: ['u1'], title: 'Closed', message: 'By {{ $User.Id }}' }, 'notify message at config.message'], + ['screen', { waitForInput: true, title: 'By {{ $User.Id }}' }, 'screen title at config.title'], + ] as const) { + const findings = validate(nodeType, config); + expect(findings, JSON.stringify(config)).toHaveLength(1); + expect(findings[0]!.severity).toBe('error'); + expect(findings[0]!.where).toContain(where); + expect(findings[0]!.message).toContain("assignments: { v: '{$User.Id}' }"); + expect(findings[0]!.message.startsWith(TEXT_SLOT_TEMPLATE_REFUSAL)).toBe(false); + } + // Control: the engine-bound `$error` and an ordinary hole stay clean at the same door. + expect(validate('notify', { recipients: ['u1'], title: 'Deal {{ record.name }}', message: 'Failed: {{ $error.message }}' })).toEqual([]); + }); + it('judges an `end` message too, for a stack handed to `validateStackExpressions` with no parse in front of it', () => { const issues = validateStackExpressions(stackWith('end', { outcome: 'refused', message: 'No: {record.name}' }) as never) .filter((i) => i.where.includes("node 'w'")); diff --git a/packages/services/service-automation/src/builtin/text-slot-template.test.ts b/packages/services/service-automation/src/builtin/text-slot-template.test.ts index ebc7eaef76..c446e6672e 100644 --- a/packages/services/service-automation/src/builtin/text-slot-template.test.ts +++ b/packages/services/service-automation/src/builtin/text-slot-template.test.ts @@ -12,12 +12,17 @@ * `Date` rendered JSON-quoted, a whole-slot object `[object Object]`), which * the renderer pins below hold. * - * The card's three pins are the first describe block. + * The card's three pins are the first describe block. #22477's — a hole over + * a `$` root the engine does not bind is refused, and the spec judge's list of + * the roots it does bind misses none of them — are the last. */ +import { readFileSync, readdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import type { AutomationContext } from '@objectstack/spec/contracts'; -import { TEXT_SLOT_TEMPLATE_REFUSAL } from '@objectstack/spec/automation'; +import { TEXT_SLOT_TEMPLATE_REFUSAL, textSlotTemplateRefusal } from '@objectstack/spec/automation'; import { AutomationEngine } from '../engine.js'; import { InMemorySuspendedRunStore } from '../suspended-run-store.js'; @@ -262,3 +267,70 @@ describe('#22110 — renderTextSlot, the one text renderer', () => { expect(renderTextSlot('Hello {record.name}', vars({ record: ACME }))).toBe('Hello {record.name}'); }); }); + +/** This package's `src` — the runtime whose `$` variables the spec judge lists. */ +const SRC = join(dirname(fileURLToPath(import.meta.url)), '..'); + +/** A `$`-named variable bound by its literal name: `variables.set('$error', …)`. */ +const DOLLAR_BINDING = /\.set\(\s*(['"`])(\$[A-Za-z_][\w$]*)\1/g; + +/** Every `$`-named variable this package's runtime sources bind by literal name, with the file binding it. */ +function engineBoundDollarVariables(): Map { + const out = new Map(); + const walk = (dir: string) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) walk(path); + else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts')) { + for (const match of readFileSync(path, 'utf8').matchAll(DOLLAR_BINDING)) { + if (!out.has(match[2]!)) out.set(match[2]!, path.slice(SRC.length + 1)); + } + } + } + }; + walk(SRC); + return out; +} + +describe('#22477 — a text-slot hole may root only at a `$` variable the engine binds', () => { + const bound = engineBoundDollarVariables(); + + // A `$`-named variable this runtime starts binding must be admitted by the + // spec's one list (`FLOW_ENGINE_VARIABLES` in `@objectstack/spec`'s + // `flow-text-slot-template.ts`), or a text slot could not name it — add it + // THERE. And one it stops binding must leave that list too, or a hole over + // it would be admitted and render blank: that is what the floor below is + // for — it fails on a removal, so delete the name from both places. + it('the scan is not vacuous: it finds every `$` variable bound today', () => { + expect([...bound.keys()].sort()).toEqual( + expect.arrayContaining(['$error', '$flowLabel', '$flowName', '$loopIndex', '$loopItems', '$record', '$runId']), + ); + }); + + it('the spec judge admits a hole over every `$` variable this runtime binds — its list misses none', () => { + for (const [name, file] of bound) { + expect(textSlotTemplateRefusal(`{{ ${name} }}`), `${name}, bound in ${file}`).toBeUndefined(); + } + // Control: the judge is not admitting every `$` hole. + expect(bound.has('$User')).toBe(false); + expect(textSlotTemplateRefusal('{{ $User.Id }}')).toBeDefined(); + }); + + it('an admitted root renders its value — `$flowName`, `$flowLabel`, `$record` are bound for every run', async () => { + const { engine, emitted } = harness(); + engine.registerFlow('roots', notifyFlow('roots', { title: '{{ $flowName }} / {{ $flowLabel }} / {{ $record.name }}' }) as never); + const result = await engine.execute('roots', ctx()); + expect(result.success, JSON.stringify(result)).toBe(true); + expect(emitted[0]!.payload).toMatchObject({ title: 'roots / roots / Acme Corp' }); + }); + + it('registerFlow refuses `By {{ $User.Id }}` in a text slot with the remedy `{$User.Id}` gets — it would render `By `', () => { + const { engine } = harness(); + const refusal = registrationRefusal(engine, 'by_user', notifyFlow('by_user', { title: 'Closed', message: 'By {{ $User.Id }}' })); + expect(refusal).toBeDefined(); + expect(refusal).toContain("node 'notify' (notify) notify message at config.message"); + expect(refusal).toContain("assignments: { v: '{$User.Id}' }"); + // The renderer it no longer reaches: the hole resolves to nothing. + expect(renderTextSlot('By {{ $User.Id }}', new Map([['userId', 'usr_7']]))).toBe('By '); + }); +}); From f0b39b02dea23a83c4a02838a3ded4e3e7eb54d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 13:25:44 +0000 Subject: [PATCH 3/8] chore(changeset): spec patch for the text-slot $-root hole refusal Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude --- ...22477-flow-text-slot-dollar-root-refused.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .changeset/22477-flow-text-slot-dollar-root-refused.md diff --git a/.changeset/22477-flow-text-slot-dollar-root-refused.md b/.changeset/22477-flow-text-slot-dollar-root-refused.md new file mode 100644 index 0000000000..dc2d2e6a68 --- /dev/null +++ b/.changeset/22477-flow-text-slot-dollar-root-refused.md @@ -0,0 +1,18 @@ +--- +'@objectstack/spec': patch +--- + +A `{{ }}` hole in a flow text slot — a `notify` node's `title` and `message`, a `screen` node's `title` and `description`, a refusing `end` node's `message` — whose root is a `$` name the flow engine does not bind is refused, at the same doors and by the same judge (`textSlotTemplateRefusal`) as a single-brace token: the node contracts, `registerFlow` and `objectstack validate`. Such a hole compiled and rendered a blank fragment with the run reporting success — `'By {{ $User.Id }}'` sent `'By '` — while its single-brace spelling `'By {$User.Id}'` was already refused with a remedy. + +Clause-②: no + +**The remedy.** `{{ $User. }}` gets the sentence `{$User.}` gets: compute the value into a variable with an `assignment` node, whose value slot still reads that spelling, then write the variable as a hole. Any other `$` root is named in the refusal beside the variables the engine does bind — `$record`, `$runId`, `$flowName`, `$flowLabel`, `$error`, and a flat-graph `loop`'s `$loopItems` / `$loopIndex` — which stay admitted. The `$` names are reserved for the engine, so a variable a flow binds itself (a declared variable, an `assignment` target, an `outputVariable`, a `try_catch` `errorVariable`) is read in a hole when named without the `$`. + +| you wrote | write instead | +|:--|:--| +| `message: 'By {{ $User.Id }}'` | an `assignment` node first — `assignments: { by: '{$User.Id}' }` — then `message: 'By {{ by }}'` | +| `errorVariable: '$caught'` with `message: 'Failed: {{ $caught.message }}'` | `errorVariable: 'caught'` with `'Failed: {{ caught.message }}'`, or keep the default `$error` and write `{{ $error.message }}` | + +A single-brace path token over such a root (`'Failed: {$caught.message}'`) is no longer prescribed the `{{ }}` spelling, which would be refused in turn; it gets the same remedy. `{{ $error.message }}`, `{{ record.name }}` and every other hole are unchanged, and the template engine binds no new variable. + +**Who is affected, measured.** The acceptance this tightens arrived with the text slots' `{{ }}` delimiter on the same protocol-18 line and has not been released. `git grep` over `examples`, `packages`, `skills`, `apps` and `content` finds no flow text slot outside tests carrying a `{{ $… }}` hole other than `{{ $error.… }}`. From d9a5ebbb6f8a6394ad26bd508686d6f4091f48ee Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 15:22:20 +0000 Subject: [PATCH 4/8] fix(lint): flow-bare-dollar-reference gives the text-slot judge's remedy for a $ root the engine does not bind In a flow text slot, the hint for a bare `$X.y` written outside the holes prescribed the hole `{{ $X.y }}` for every root. For a root the flow engine does not bind (`$User.Id`) that hole is now refused by the spec's text-slot judge, so the hint asked the author for a refused spelling. The hint now asks the judge per reference: an admitted hole is prescribed as before, a refused one gets the judge's own refusal and remedy. The roots stay listed in one place, the judge. Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude --- packages/lint/src/lint-flow-patterns.test.ts | 30 +++++++++++++++- packages/lint/src/lint-flow-patterns.ts | 36 +++++++++++++++++--- 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/packages/lint/src/lint-flow-patterns.test.ts b/packages/lint/src/lint-flow-patterns.test.ts index 3118084f7a..43678697f5 100644 --- a/packages/lint/src/lint-flow-patterns.test.ts +++ b/packages/lint/src/lint-flow-patterns.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; -import { TimeRelativeTriggerSchema, LoopConfigSchema, ParallelConfigSchema, TryCatchConfigSchema, HttpConfigSchema, FlowSchema, NotifyConfigSchema } from '@objectstack/spec/automation'; +import { TimeRelativeTriggerSchema, LoopConfigSchema, ParallelConfigSchema, TryCatchConfigSchema, HttpConfigSchema, FlowSchema, NotifyConfigSchema, textSlotTemplateRefusal } from '@objectstack/spec/automation'; import { TYPED_EXPRESSION_DIALECT_ONLY, TYPED_EXPRESSION_SOURCE_REQUIRED } from '@objectstack/spec/shared'; // [#5659] The shared identity reduction, asserted beside the rule that consumes // it — the rule's verdict and the drivers' verdict are one object now. @@ -2533,6 +2533,34 @@ describe('#16405 — an `http` node payload is not a region, and both #1315 rule expect(fnds[0].message).toContain('notify title'); expect(fnds[0].hint).toContain('{{ $error.message }}'); }); + + // [#22477] The hole a bare `$User.Id` would become is refused by the + // spec's text-slot judge (the engine binds no `$User`), so the hint gives + // the judge's own remedy — asked of the judge, not re-listed here. + it('gives the judge\'s remedy, not a refused hole, for a bare `$User.Id`', () => { + const fnds = notifyText('Closed by $User.Id'); + expect(fnds.map((f) => f.rule)).toEqual([FLOW_BARE_DOLLAR_REF]); + const refusal = textSlotTemplateRefusal('{{ $User.Id }}'); + expect(refusal).toBeDefined(); + expect(fnds[0].hint).toContain(refusal!); + expect(fnds[0].hint).toContain("assignments: { v: '{$User.Id}' }"); + expect(fnds[0].hint).not.toContain('{{ $User.Id }}'); + }); + + it('control: a bare engine-bound `$error.message` keeps the hole prescription and no remedy', () => { + const fnds = notifyText('Failed: $error.message'); + expect(fnds.map((f) => f.rule)).toEqual([FLOW_BARE_DOLLAR_REF]); + expect(fnds[0].hint).toContain('`{{ $error.message }}`'); + expect(fnds[0].hint).not.toContain('assignments:'); + }); + + it('answers each bare reference on its own when one slot carries both kinds', () => { + const fnds = notifyText('Failed: $error.message, closed by $User.Id'); + expect(fnds).toHaveLength(1); + expect(fnds[0].hint).toContain('`{{ $error.message }}`'); + expect(fnds[0].hint).toContain(textSlotTemplateRefusal('{{ $User.Id }}')!); + expect(fnds[0].hint).not.toContain('{{ $User.Id }}'); + }); }); }); diff --git a/packages/lint/src/lint-flow-patterns.ts b/packages/lint/src/lint-flow-patterns.ts index 6a7ccaca59..5e57ef82a9 100644 --- a/packages/lint/src/lint-flow-patterns.ts +++ b/packages/lint/src/lint-flow-patterns.ts @@ -159,6 +159,7 @@ import { collectFlowGraphs, FLOW_NODE_TEXT_SLOTS, flowNodeTextSlotSources, + textSlotTemplateRefusal, } from '@objectstack/spec/automation'; import type { FlowNodeParsed, FlowEdgeParsed } from '@objectstack/spec/automation'; // [#15429] The decision's `mode` contract, parsed here so `os validate` and @@ -669,6 +670,33 @@ const DOUBLE_BRACE = /\{\{\s*[\w$][\w$.\s]*\}\}/; // A `$Ident.field` not immediately inside a `{` (so `{$User.Id}` is NOT flagged). // Require a letter/_ after `$` so currency like `$5.00` is never matched. const BARE_DOLLAR_REF = /(?:^|[^{])\$[A-Za-z_]\w*\.[A-Za-z_]/; +// Every such reference, whole (`$error.message`), at the same anchor — what a +// text slot's hint names, one hole or one remedy per reference. +const BARE_DOLLAR_REFS = /(?:^|[^{])(\$[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)+)/g; + +/** + * [#22477] The hint for bare `$name.path` references in a text slot's text + * outside its holes. A reference whose hole the spec's text-slot judge admits + * is prescribed that hole; one whose `$` root the flow engine does not bind + * (`$User.Id`) would be refused as a hole too, so it gets the judge's own + * refusal and remedy instead. The judge is ASKED, never re-listed here: which + * `$` roots the engine binds is answered in one place + * (`flow-text-slot-template.ts`), and a second list would drift from it. + */ +function textSlotBareDollarHint(outsideHoles: string): string { + const refs = [...new Set([...outsideHoles.matchAll(BARE_DOLLAR_REFS)].map((m) => m[1]!))]; + const holes: string[] = []; + const refusals: string[] = []; + for (const ref of refs) { + const refusal = textSlotTemplateRefusal(`{{ ${ref} }}`); + if (refusal === undefined) holes.push(`\`{{ ${ref} }}\``); + else refusals.push(`\`${ref}\` has no hole either: ${refusal}`); + } + const parts = ['A text slot renders only `{{ }}` holes, and all other text is literal.']; + if (holes.length > 0) parts.push(`Write it as a hole: ${holes.join(', ')}.`); + parts.push(...refusals); + return parts.join(' '); +} /** Config keys whose string values are CEL predicates, not interpolated templates. */ const CEL_KEYS = new Set(['condition', 'expression', 'conditions']); @@ -1759,16 +1787,16 @@ export function lintFlowPatterns(stack: AnyRec): FlowLintFinding[] { } } // [#22110] The text slots' own bare-`$` check: read OUTSIDE their - // `{{ }}` holes, where a `$name.path` is the hole's correct content. + // `{{ }}` holes, where a `$name.path` is the hole's correct content — + // [#22477] when the engine binds its root; otherwise the hint carries + // the judge's remedy, never a hole the judge refuses. for (const slot of flowNodeTextSlotSources(String(node.type), node.config)) { const outsideHoles = slot.source.replace(/\{\{[^}]*\}\}/g, ''); if (BARE_DOLLAR_REF.test(outsideHoles)) { findings.push({ where: nodeWhere, message: `\`${slot.source.trim().slice(0, 80)}\` looks like a reference written as a literal — a bare \`$ref.field\` in the ${slot.label} is NOT rendered.`, - hint: - `Write it as a hole: \`{{ $ref.field }}\` (e.g. \`{{ $error.message }}\`) — a text slot renders only ` + - `\`{{ }}\` holes, and all other text is literal.`, + hint: textSlotBareDollarHint(outsideHoles), rule: FLOW_BARE_DOLLAR_REF, }); } From 504b61ad21f4946689297da188284d86c1679450 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 15:23:45 +0000 Subject: [PATCH 5/8] feat(spec/migrations): step-18 D3 entry flow-text-slot-unbound-dollar-root-refused The 17.x node contracts typed the flow text slots as plain strings, so a `{{ $User.Id }}` hole in a notify, screen or end text slot was accepted by the last published spec; the text-slot judge now refuses it. The D3 semantic entry records the narrowing with its remedy (compute the value with an assignment node, then write the variable as a hole; a variable the flow binds itself is named without the `$`), and step 18's rationale gains its fragment. The registry region is regenerated by gen:migration-registry; spec-changes.json and the upgrade guide do not move on this base, where step 18 is not projected yet. Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude --- ...w-text-slot-unbound-dollar-root-refused.ts | 41 +++++++++++++++ packages/spec/src/migrations/registry.ts | 50 +++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 packages/spec/src/migrations/entries/semantic/18.flow-text-slot-unbound-dollar-root-refused.ts diff --git a/packages/spec/src/migrations/entries/semantic/18.flow-text-slot-unbound-dollar-root-refused.ts b/packages/spec/src/migrations/entries/semantic/18.flow-text-slot-unbound-dollar-root-refused.ts new file mode 100644 index 0000000000..7cbd85d397 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.flow-text-slot-unbound-dollar-root-refused.ts @@ -0,0 +1,41 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// A flow text slot refuses a double-brace hole whose root is a dollar-named +// variable the flow engine does not bind. The 17.x node contracts typed these +// slots as plain strings, so such a hole was accepted there; under the 18 text +// slots, before this refusal, it rendered nothing. Semantic-only — nothing maps +// the hole to a value the run has, so no D2 conversion exists. +export const entry: SemanticMigration = { + id: 'flow-text-slot-unbound-dollar-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 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 }}', + reason: + '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.', + acceptanceCriteria: + '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.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index ed7eda14f4..2c9235f069 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5748,6 +5748,19 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + '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`.', }, + { + id: 'flow-text-slot-unbound-dollar-root-refused', + order: 91, + text: + '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`.', + }, { id: 'flow-value-slot-template-dialect-refused', order: 88, @@ -14230,6 +14243,43 @@ const step18: MigrationStep = { + '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.', }, + // A flow text slot refuses a double-brace hole whose root is a dollar-named + // variable the flow engine does not bind. The 17.x node contracts typed these + // slots as plain strings, so such a hole was accepted there; under the 18 text + // slots, before this refusal, it rendered nothing. Semantic-only — nothing maps + // the hole to a value the run has, so no D2 conversion exists. + { + id: 'flow-text-slot-unbound-dollar-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 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 }}', + reason: + '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.', + acceptanceCriteria: + '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.', + }, // A value a flow reads, not an authorable key: there is no D2 conversion and // nothing for `objectstack migrate meta` to rewrite. The sibling of // `18.by-id-write-unreadable-row-not-found` in kind — the entry carries the From f1b21e98057453c2da2c630e010047f4d220eaf8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 15:24:24 +0000 Subject: [PATCH 6/8] chore(changeset): the text-slot $-root refusal is a breaking narrowing of the published spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@objectstack/spec@17.7.0` (npm latest) types the flow text slots as plain strings and accepts `{{ $User.Id }}` there, so the refusal narrows a published accept set: `minor`, a BREAKING banner, `Clause-②: no (narrowing)` and the ADR-0087 disposition `registered flow-text-slot-unbound-dollar-root-refused`. `@objectstack/lint` takes a patch for the flow-bare-dollar-reference hint. Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude --- ...2477-flow-text-slot-dollar-root-refused.md | 39 +++++++++++++++---- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/.changeset/22477-flow-text-slot-dollar-root-refused.md b/.changeset/22477-flow-text-slot-dollar-root-refused.md index dc2d2e6a68..892bf6926d 100644 --- a/.changeset/22477-flow-text-slot-dollar-root-refused.md +++ b/.changeset/22477-flow-text-slot-dollar-root-refused.md @@ -1,18 +1,43 @@ --- -'@objectstack/spec': patch +'@objectstack/spec': minor +'@objectstack/lint': patch --- -A `{{ }}` hole in a flow text slot — a `notify` node's `title` and `message`, a `screen` node's `title` and `description`, a refusing `end` node's `message` — whose root is a `$` name the flow engine does not bind is refused, at the same doors and by the same judge (`textSlotTemplateRefusal`) as a single-brace token: the node contracts, `registerFlow` and `objectstack validate`. Such a hole compiled and rendered a blank fragment with the run reporting success — `'By {{ $User.Id }}'` sent `'By '` — while its single-brace spelling `'By {$User.Id}'` was already refused with a remedy. +A `{{ }}` hole in a flow text slot (a `notify` node's `title` and `message`, a `screen` node's `title` and `description`, a refusing `end` node's `message`) whose root is a `$` name the flow engine does not bind is refused. It is refused at the same doors, and by the same judge (`textSlotTemplateRefusal`), as a single-brace token: the node contracts, `registerFlow` and `objectstack validate`. `'By {{ $User.Id }}'` used to pass all three and send `'By '`. -Clause-②: no +Clause-②: no (narrowing) -**The remedy.** `{{ $User. }}` gets the sentence `{$User.}` gets: compute the value into a variable with an `assignment` node, whose value slot still reads that spelling, then write the variable as a hole. Any other `$` root is named in the refusal beside the variables the engine does bind — `$record`, `$runId`, `$flowName`, `$flowLabel`, `$error`, and a flat-graph `loop`'s `$loopItems` / `$loopIndex` — which stay admitted. The `$` names are reserved for the engine, so a variable a flow binds itself (a declared variable, an `assignment` target, an `outputVariable`, a `try_catch` `errorVariable`) is read in a hole when named without the `$`. + + +**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings. + +**Why.** The hole grammar admits `$` in a name so that the engine's own variables have a spelling (`{{ $error.message }}`). That also makes `{{ $User.Id }}` a well-formed hole, but over a root no flow variable answers to. The text went out with the fragment missing, the run reported success, and nothing warned. Its single-brace spelling, `{$User.Id}`, was already refused with a remedy. The `$` names are reserved for the engine: a resume signal may not write one. + +**What is refused.** + +- A hole whose root is a `$` name other than the variables the engine binds: `$record`, `$runId`, `$flowName`, `$flowLabel`, `$error`, and a flat-graph `loop`'s `$loopItems` / `$loopIndex`. + - `NotifyConfigSchema`, `ScreenConfigSchema` and `EndConfigSchema` raise a `custom` issue at the slot's key. + - `registerFlow` refuses the flow, and a stored flow carrying such a hole is skipped at boot with a warn naming it. + - `objectstack validate` reports `expression-invalid` at `error`. +- The remedy for `{{ $User. }}` is the sentence `{$User.}` gets: compute the value into a variable with an `assignment` node, whose value slot still reads that spelling, then write the variable as a hole. Any other root is named in the refusal, beside the variables the engine does bind. +- A single-brace path token over such a root (`'Failed: {$caught.message}'`) is no longer prescribed the `{{ }}` spelling, which would be refused in turn; it gets the same remedy. + +**Unchanged.** `{{ $error.message }}`, `{{ record.name }}`, a node output `{{ lookup.result }}` and every hole over an engine-bound `$` variable. The template engine binds no new variable. + +**`@objectstack/lint`.** In a text slot, `flow-bare-dollar-reference` prescribes the hole for a bare `$X.y` written outside the holes only when the judge admits that hole. A bare `$User.Id` gets the judge's refusal and remedy instead of a `{{ $User.Id }}` the judge refuses. + +## FROM → TO | you wrote | write instead | |:--|:--| -| `message: 'By {{ $User.Id }}'` | an `assignment` node first — `assignments: { by: '{$User.Id}' }` — then `message: 'By {{ by }}'` | +| `message: 'By {{ $User.Id }}'` | an `assignment` node first, `assignments: { by: '{$User.Id}' }`, then `message: 'By {{ by }}'` | | `errorVariable: '$caught'` with `message: 'Failed: {{ $caught.message }}'` | `errorVariable: 'caught'` with `'Failed: {{ caught.message }}'`, or keep the default `$error` and write `{{ $error.message }}` | -A single-brace path token over such a root (`'Failed: {$caught.message}'`) is no longer prescribed the `{{ }}` spelling, which would be refused in turn; it gets the same remedy. `{{ $error.message }}`, `{{ record.name }}` and every other hole are unchanged, and the template engine binds no new variable. +**The one-line fix: compute a run-user value into a variable first, and name a variable the flow binds itself without the `$`.** + +**Who is affected, measured.** The last published spec, `@objectstack/spec@17.7.0` (npm `latest`), has no text-slot judge. Its `NotifyConfigSchema.title` / `.message`, `ScreenConfigSchema.title` / `.description` and `EndConfigSchema.message` are plain strings, so it accepts `'By {{ $User.Id }}'` in every one of these slots. Its single-brace interpolator substituted the inner `{ $User.Id }` token and left a literal brace on each side. This repository was measured with `git grep` over `examples`, `packages`, `skills`, `apps` and `content`: no flow text slot outside tests carries a `{{ $… }}` hole other than `{{ $error.… }}`. Deployed metadata and other repositories were not measured. + +### The kit -**Who is affected, measured.** The acceptance this tightens arrived with the text slots' `{{ }}` delimiter on the same protocol-18 line and has not been released. `git grep` over `examples`, `packages`, `skills`, `apps` and `content` finds no flow text slot outside tests carrying a `{{ $… }}` hole other than `{{ $error.… }}`. +- **The refusal.** `textSlotTemplateRefusal` in `automation/flow-text-slot-template.ts` reads one package-internal list of the `$` variables the engine binds. `@objectstack/service-automation`'s `text-slot-template.test.ts` scans that package's sources for every `$` variable they bind by name, and fails when the list misses one. +- **The ledger.** The D3 semantic entry `flow-text-slot-unbound-dollar-root-refused` (protocol 18). There is no D2 conversion: what the hole was meant to read is not in the flow. From 52c005e2b173d01b128e03112a1bdf6efcae114b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 17:20:10 +0000 Subject: [PATCH 7/8] chore(spec): regenerate spec-changes.json and the upgrade guide for flow-text-slot-unbound-dollar-root-refused Pure regeneration after the merge of origin/main 4e9fe9ff6a, which projects protocol step 18: gen:spec-changes and gen:upgrade-guide add the D3 entry flow-text-slot-unbound-dollar-root-refused (step 17 -> 18 semantic count 329 -> 330 in both) and its rationale fragment. No hand edit. Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude --- docs/protocol-upgrade-guide.md | 5 ++++- packages/spec/spec-changes.json | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index b5a2c6cb72..0ccc599ea7 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`. +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) @@ -973,6 +973,9 @@ AUTHOR-REACHABLE SURFACES: a saved report's `query.filter` (`sys_saved_report`) - **`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 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). diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 92f6134c2f..a87d18ad01 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -2206,6 +2206,13 @@ "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 }}", + "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." + }, { "surface": "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", "replacement": "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", @@ -5795,6 +5802,13 @@ "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 }}", + "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." + }, { "surface": "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", "replacement": "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", From c36b06966d2a5cb97faff4a3cf52a38d0a30ba1e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 20:06:07 +0000 Subject: [PATCH 8/8] chore(spec): regenerate spec-changes.json and the upgrade guide on the merged tree Pure regeneration after the merge of origin/main c76edeb8c6, which carries the step-18 D3 entry storage-scope-public-retired (ee8751d41e). The os-regen driver kept one side of both artifacts in the merge; gen:spec-changes and gen:upgrade-guide re-derive them from the merged registry, holding both storage-scope-public-retired and flow-text-slot-unbound-dollar-root-refused: step 17 -> 18 semantic count 331 in both documents. No hand edit. Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude --- docs/protocol-upgrade-guide.md | 3 +++ packages/spec/spec-changes.json | 14 ++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 0ccc599ea7..e7bd9a8f56 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -1302,6 +1302,9 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-regis - **`startup-orchestrator-retired`** — `the startup-ORCHESTRATION surface of kernel/startup-orchestrator.zod.ts and contracts/startup-orchestrator.ts — 3 emitted defs and 8 exported names: StartupOptionsSchema / StartupOptions / StartupOptionsParsed, HealthStatusSchema / HealthStatus, StartupOrchestrationResultSchema / StartupOrchestrationResult, and the IStartupOrchestrator interface (orchestrateStartup / rollback / checkHealth / startWithTimeout). The startup RESULT survives, re-declared: PluginStartupResultSchema and PluginStartupResult stay on both entries` → (removed — there is no declarative replacement, because nothing ever implemented the interface or parsed the schemas. Plugin startup is the kernel own boot loop: ObjectKernel.start() calls startPluginWithTimeout() per plugin, which races that plugin start() against PluginMetadata.startupTimeout and, when KernelConfig.rollbackOnFailure is set, destroys the already-started plugins and rethrows the original error as the new error cause. So: instead of StartupOptions.timeoutMs declare startupTimeout on the plugin; instead of StartupOptions.rollbackOnFailure set rollbackOnFailure on the kernel config; instead of StartupOrchestrationResult.results read the per-plugin durations through ObjectKernel.getPluginStartupDurations(). StartupOptions.healthCheck and HealthStatus have NO replacement at all — no startup probe system exists, and one returns only through the enforce route of ADR-0049 with a new ADR, the probe first and the vocabulary second. StartupOptions.parallel and StartupOptions.context likewise: the kernel starts plugins sequentially and passes its own PluginContext) - 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. - **`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 a87d18ad01..3c0d0c99fc 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -2955,6 +2955,13 @@ "toMajor": 18, "rationale": "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." }, + { + "surface": "the storage scope public — the scope of an upload request, presigned or chunked, and ObjectStorageConfig.scope (StorageScope)", + "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" + }, { "surface": "StrategyContext.executeAggregate aggregations[].method (contracts/analytics-service.ts, exported from @objectstack/spec/contracts) - the parameter type, declared as bare string", "replacement": "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", @@ -6551,6 +6558,13 @@ "toMajor": 18, "rationale": "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." }, + { + "surface": "the storage scope public — the scope of an upload request, presigned or chunked, and ObjectStorageConfig.scope (StorageScope)", + "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" + }, { "surface": "StrategyContext.executeAggregate aggregations[].method (contracts/analytics-service.ts, exported from @objectstack/spec/contracts) - the parameter type, declared as bare string", "replacement": "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",