diff --git a/.changeset/21982-flow-script-subflow-config-undeclared-keys-refused.md b/.changeset/21982-flow-script-subflow-config-undeclared-keys-refused.md new file mode 100644 index 00000000000..cde61369ac2 --- /dev/null +++ b/.changeset/21982-flow-script-subflow-config-undeclared-keys-refused.md @@ -0,0 +1,43 @@ +--- +'@objectstack/spec': minor +'@objectstack/lint': patch +--- + +A `script` or `subflow` flow node whose `config` carries a key its executor contract does not declare is refused at parse, with a location, in the contract's own words: a `script` `bogusKey`, a `subflow` `timeoutMs` written inside `config`, and the like no longer pass the build doors and registration and then fail every run. + +Clause-②: no (narrowing) + + + +**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings. + +**Why.** The `script` and `subflow` executors parse the node's `config` against a strict contract (`ScriptConfigSchema`, `SubflowConfigSchema`) before they act, and refuse the node on an undeclared key. No door before the run judged one: `registerFlow`'s undeclared-key check reads the node type descriptor's `configSchema`, and these two descriptors publish none, while the build doors' executor-contract arm judged required keys and present values but not key membership. So a `script` node carrying `bogusKey` passed `FlowSchema.parse`, `objectstack validate` and `objectstack compile` (compile copied it into `dist/objectstack.json`), registered, and failed every run that reached the node: ``script 'n': config does not satisfy the script contract — config: Unrecognized key(s) on this script node config: `bogusKey` ``. + +**What is refused.** A `script` node, at any depth, whose config carries a key other than `function`, `inputs` and `outputVariable`, or a `subflow` node whose config carries a key other than `flowName`, `input` and `outputVariable`. The refusal is the existing closed-set code `node-config-refused-by-contract`, `params: { nodeType, key }`, one per undeclared key, anchored at the key (`nodes.N.config.bogusKey`), from the one judge `flowNodeConfigRefusals` that `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first) and `objectstack validate` share. The issue's `code` is `custom`. That covers `FlowSchema`, `defineFlow()`, `defineStack` (`STACK_SCHEMA_INVALID`, 422, at `flows.N.nodes.M.config.`), `os validate`, `os compile`, an artifact's parse, `registerFlow` and the metadata save door. + +**What stays as it was.** + +- Every other builtin node type: its undeclared keys are judged at registration against its descriptor's `configSchema`, with that check's own prescriptions, and the build doors do not judge them. +- `decision`: it publishes no descriptor `configSchema` either, but its executor parses no contract, so an undeclared key fails no run and stays unjudged. +- A retired `script` key (`actionType`, `template`, `recipients`, `variables`, `script`) keeps its tombstone path. +- A spelling an ADR-0087 D2 conversion still rewrites at load (`functionName` and `input` on a `script`, `flow` on a `subflow`) is converted before the judge at every door that converts first (`defineStack`, `os validate`, `os compile`, `registerFlow`). Met by a direct `FlowSchema.parse` or `defineFlow()`, it is refused like any other undeclared key, as its missing canonical key already was. + +## FROM → TO + +| you wrote | write instead | +|:--|:--| +| a typo of a declared key (`funtion`, `outputVariabel`) | the declared key: `function`, `inputs`, `outputVariable` on a `script`; `flowName`, `input`, `outputVariable` on a `subflow` | +| a value the function or child flow should receive, as its own config key (`config: { function: 'f', taskId: '{record.id}' }`) | inside the input map: `config: { function: 'f', inputs: { taskId: '{record.id}' } }` (`input` on a `subflow`) | +| a `subflow` `config.timeoutMs` | on the node: `{ id, type: 'subflow', timeoutMs: 30000, config: { … } }` | +| a key nothing reads | delete it | + +**The one-line fix: rename, move or delete the key the refusal names.** The runtime never ran such a node, so the fix changes nothing a working flow does. + +**Who is affected, measured.** At `15ec50e528`, every `script` and `subflow` node authored in this repository's examples, platform objects, apps, scaffolding templates, skills and docs (8 nodes: 6 `script`, 2 `subflow`) carries only declared keys, and so does every one in hotcrm at `c9678036d9` (5 `subflow`, no `script`). The Studio flow designer at the pinned objectui `a58626c88d` writes only declared keys for both types (its `timeoutMs` field writes the node, not `config`), and seeds a new node with an empty `config`. Deployed metadata, and other repositories, were not measured. Where such a node already sits in a stored flow, the whole flow is refused at registration: at boot it is skipped with a warn naming it, its trigger not armed, while the flows beside it register. + +**`@objectstack/lint`.** `validateStackExpressions` keeps the pre-conversion tolerance it declares: on a raw source, a `script` node's `functionName` alias stays the callable check's to read, not an undeclared-key error, while every other undeclared `script` key is refused there as at the build doors. + +### The kit + +- **The refusal.** The key half of the executor-contract arm of `flowNodeConfigRefusals` in `automation/flow-node-config-refusals.ts`, judged for the builtins in the spec's schemaless class (`SCHEMALESS_NODE_CONFIG_SCHEMAS`) that have an executor contract; no new code joins `FLOW_SLOT_REFUSAL_CODES`, and `getBuiltinNodeConfigContracts()` keeps its 13 entries. +- **The ledger.** The D3 semantic entry `flow-script-subflow-config-undeclared-keys-refused` (protocol 18). No key is removed, so there is no tombstone, and there is no D2 conversion: the platform cannot know what an undeclared key was meant to be. diff --git a/packages/lint/src/validate-expressions.test.ts b/packages/lint/src/validate-expressions.test.ts index e3bedb044b0..87d6a5d0c63 100644 --- a/packages/lint/src/validate-expressions.test.ts +++ b/packages/lint/src/validate-expressions.test.ts @@ -4535,6 +4535,21 @@ describe('node config an executor requires (#20316)', () => { expect(found.map((i) => i.where)).toEqual(["flow 'config_flow' · node 'n' (script) callable"]); expect(errorsOf(stackWith({ type: 'script', config: { functionName: 'recalc_totals' } }))).toHaveLength(0); }); + + it('a `script` key its contract does not declare is still refused — only the `functionName` alias is the callable check\'s', () => { + const config = { function: 'recalc_totals', bogusKey: 1 }; + expect(errorsOf(stackWith({ type: 'script', config })).map((i) => [i.where, i.message, i.source])).toEqual([ + ["flow 'config_flow' · node 'n' (script) config.bogusKey", flowNodeConfigRefusals('script', config)[0].message, ''], + ]); + // On a raw alias source the judge names `function`, `functionName` and + // `bogusKey`; the pass hands the first two to the callable check and + // keeps the third. + const aliased = { functionName: 'recalc_totals', bogusKey: 1 }; + expect(flowNodeConfigRefusals('script', aliased).map((r) => r.path).sort()).toEqual(['bogusKey', 'function', 'functionName']); + expect(errorsOf(stackWith({ type: 'script', config: aliased })).map((i) => i.where)).toEqual([ + "flow 'config_flow' · node 'n' (script) config.bogusKey", + ]); + }); }); /** diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index c9e828b47ce..323aff5b3e7 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -1714,8 +1714,15 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression // #4343): this pass may be handed a pre-conversion source, and that // check reads what such a source spells — the `functionName` alias, // the retired dispatch keys — and names each, where the judge would - // only see `function` absent. - .filter((configRefusal) => !(nodeType === 'script' && configRefusal.path === 'function')); + // only see `function` absent. Since the judge also refuses a key a + // `script`'s contract does not declare (#21982), it would name that + // same `functionName` alias undeclared too, so that one refusal is + // the callable check's as well: the pass keeps the pre-conversion + // tolerance it declares. Every other undeclared key stays refused. + .filter((configRefusal) => !(nodeType === 'script' && ( + configRefusal.path === 'function' + || (configRefusal.code === 'node-config-refused-by-contract' && configRefusal.path === 'functionName') + ))); for (const configRefusal of configRefusals) { issues.push({ where: `${at} · node '${node.id}' (${nodeType}) config.${configRefusal.path}`, diff --git a/packages/services/service-automation/src/engine.test.ts b/packages/services/service-automation/src/engine.test.ts index 462c0ae7246..7bd396a968f 100644 --- a/packages/services/service-automation/src/engine.test.ts +++ b/packages/services/service-automation/src/engine.test.ts @@ -2337,8 +2337,11 @@ describe('AutomationEngine - Parallel Branch Execution', () => { // sequential engine satisfies just as well. const trace: string[] = []; + // A test double under a type of its own, never the builtin `script`: + // `delay` is this double's key, and the `script` contract refuses an + // undeclared key at the flow parse that `registerFlow` runs first. engine.registerNodeExecutor({ - type: 'script', + type: 'probe_step', async execute(node) { trace.push(`enter:${node.id}`); const delay = (node.config as any)?.delay ?? 0; @@ -2355,8 +2358,8 @@ describe('AutomationEngine - Parallel Branch Execution', () => { type: 'autolaunched', nodes: [ { id: 'start', type: 'start', label: 'Start' }, - { id: 'branch_a', type: 'script', label: 'Branch A', config: { function: 'noop', delay: 10 } }, - { id: 'branch_b', type: 'script', label: 'Branch B', config: { function: 'noop', delay: 10 } }, + { id: 'branch_a', type: 'probe_step', label: 'Branch A', config: { delay: 10 } }, + { id: 'branch_b', type: 'probe_step', label: 'Branch B', config: { delay: 10 } }, { id: 'end', type: 'end', label: 'End' }, ], edges: [ @@ -2442,8 +2445,11 @@ describe('AutomationEngine - Node Input Schema Validation', () => { }); it('should fail when parameter type is wrong', async () => { + // A test double under a type of its own: `inputSchema` reads TOP-LEVEL + // config keys, and the builtin `script` contract refuses an undeclared + // `count` at the flow parse before this check could run. engine.registerNodeExecutor({ - type: 'script', + type: 'probe_step', async execute() { return { success: true }; }, @@ -2457,9 +2463,9 @@ describe('AutomationEngine - Node Input Schema Validation', () => { { id: 'start', type: 'start', label: 'Start' }, { id: 'validated', - type: 'script', + type: 'probe_step', label: 'Validated', - config: { function: 'noop', count: 'not_a_number' }, + config: { count: 'not_a_number' }, inputSchema: { count: { type: 'number', required: true }, }, diff --git a/packages/services/service-automation/src/input-schema-retry-parity.test.ts b/packages/services/service-automation/src/input-schema-retry-parity.test.ts index 56c26e326ea..0cb5551ff8d 100644 --- a/packages/services/service-automation/src/input-schema-retry-parity.test.ts +++ b/packages/services/service-automation/src/input-schema-retry-parity.test.ts @@ -55,8 +55,11 @@ function countingFlowEngine(opts: { const engine = new AutomationEngine(createTestLogger()); const runs = { count: 0 }; + // A test double under a type of its own, never the builtin `script`: + // `inputSchema` reads TOP-LEVEL config keys, and the `script` contract + // refuses an undeclared key at the flow parse `registerFlow` runs first. engine.registerNodeExecutor({ - type: 'script', + type: 'probe_step', async execute() { runs.count++; return opts.executeResult ? opts.executeResult(runs.count) : { success: true }; @@ -72,11 +75,9 @@ function countingFlowEngine(opts: { { id: 'start', type: 'start', label: 'Start' }, { id: 'work', - type: 'script' as any, + type: 'probe_step', label: 'Work', - // `function` is the key the script executor contract requires; - // the flow parse refuses a script node without it (#20316). - config: { function: 'noop', ...opts.config }, + config: { ...opts.config }, inputSchema: opts.inputSchema, }, { id: 'end', type: 'end', label: 'End' }, diff --git a/packages/spec/src/automation/flow-builtin-node-config-keys.test.ts b/packages/spec/src/automation/flow-builtin-node-config-keys.test.ts new file mode 100644 index 00000000000..74c6f18cda5 --- /dev/null +++ b/packages/spec/src/automation/flow-builtin-node-config-keys.test.ts @@ -0,0 +1,237 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21982] The build doors refuse a KEY a builtin node's executor contract does + * not declare, where no other door before the run judges it — the key half of + * the executor-contract arm of `flowNodeConfigRefusals`, beside the value half + * (#21898) and the presence half (#20316). + * + * `script` and `subflow` publish no descriptor `configSchema`, so + * `registerFlow`'s undeclared-key walk skips them, while their executors parse + * the strict contract and refuse the node on an undeclared key at every run. A + * `script` node carrying `bogusKey` passed `FlowSchema`, `objectstack validate`, + * `objectstack compile` and `registerFlow`, and then failed every run. The pins + * below hold the refusal at every door that parses a flow; the controls hold + * what stays as it was: + * + * - a valid `script` / `subflow` node, and the measured node without its extra key; + * - a builtin WITH a descriptor `configSchema`: its undeclared key stays + * registration's, judged against the descriptor; + * - `decision`, schemaless but with no executor contract; + * - a tombstoned retired `script` key, which keeps the path it had. + */ + +import { describe, expect, it } from 'vitest'; + +import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; +import { MIGRATIONS_BY_MAJOR } from '../migrations/registry'; +import { ArtifactStagePackageBodySchema, ObjectStackDefinitionSchema, defineStack } from '../stack.zod'; +import { flowNodeConfigRefusals, getBuiltinNodeConfigContracts } from './flow-node-config-refusals'; +import { FlowSchema } from './flow.zod'; +import { SCHEMALESS_NODE_CONFIG_SCHEMAS } from './schemaless-node-config.zod'; + +const ENTRY_ID = 'flow-script-subflow-config-undeclared-keys-refused'; + +type Config = Record; + +/** The measured node: `summarize` in the showcase's `showcase_task_completed`. */ +const MEASURED: Config = { function: 'summarizeCompletedTask', inputs: { taskId: '{record.id}' }, outputVariable: 'summary' }; +const SUBFLOW: Config = { flowName: 'child_flow', input: { id: '{record.id}' }, outputVariable: 'out' }; + +/** start → one node of `type` → end. */ +function flowWith(type: string, config: unknown, name = 'key_probe') { + return { + name, + label: 'Key probe', + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'summarize', type, label: 'N', config }, + { id: 'done', type: 'end', label: 'Done' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'summarize' }, + { id: 'e2', source: 'summarize', target: 'done' }, + ], + }; +} + +interface IssueSig { code: string; path: string; message: string } + +function issuesOf(flow: unknown): IssueSig[] { + const r = FlowSchema.safeParse(flow); + return r.success ? [] : r.error.issues.map((i) => ({ code: i.code, path: i.path.join('.'), message: i.message })); +} + +/** The contract's own unknown-key sentence for a config — read, never re-spelled. */ +function contractSentence(type: string, config: Config): string { + const own = getBuiltinNodeConfigContracts().get(type)!.schema.safeParse(config); + return own.success ? '' : own.error?.issues.find((i) => i.code === 'unrecognized_keys')?.message ?? ''; +} + +describe('the measured node: refused at save, with its location', () => { + it('a script node with bogusKey is refused at nodes.1.config.bogusKey, in the contract\'s own words', () => { + const config = { ...MEASURED, bogusKey: 1 }; + const issues = issuesOf(flowWith('script', config)); + expect(issues.map(({ code, path }) => ({ code, path }))).toEqual([{ code: 'custom', path: 'nodes.1.config.bogusKey' }]); + expect(issues[0]!.message).toBe(flowNodeConfigRefusals('script', config)[0]!.message); + expect(contractSentence('script', config)).toMatch(/`bogusKey`/); + expect(issues[0]!.message).toContain(contractSentence('script', config)); + }); + + it('the judge answers with the closed-set code, its params and the key as path', () => { + const refusals = flowNodeConfigRefusals('script', { ...MEASURED, bogusKey: 1 }); + expect(refusals.map(({ code, params, path, source }) => ({ code, params, path, source }))).toEqual([ + { code: 'node-config-refused-by-contract', params: { nodeType: 'script', key: 'bogusKey' }, path: 'bogusKey', source: '' }, + ]); + }); + + it('the same for subflow', () => { + const config = { ...SUBFLOW, bogusKey: 1 }; + expect(issuesOf(flowWith('subflow', config)).map(({ code, path }) => ({ code, path }))).toEqual([ + { code: 'custom', path: 'nodes.1.config.bogusKey' }, + ]); + expect(flowNodeConfigRefusals('subflow', config).map(({ code, params }) => ({ code, params }))).toEqual([ + { code: 'node-config-refused-by-contract', params: { nodeType: 'subflow', key: 'bogusKey' } }, + ]); + }); + + it('one refusal per undeclared key, each anchored at its own key', () => { + expect(flowNodeConfigRefusals('script', { ...MEASURED, bogusKey: 1, other: 2 }).map(({ path }) => path)) + .toEqual(['bogusKey', 'other']); + }); + + it('a known slip carries the contract\'s own prescription: subflow timeoutMs belongs on the node', () => { + const [refusal] = flowNodeConfigRefusals('subflow', { ...SUBFLOW, timeoutMs: 30000 }); + expect(refusal!.path).toBe('timeoutMs'); + expect(refusal!.message).toContain(contractSentence('subflow', { ...SUBFLOW, timeoutMs: 30000 })); + expect(refusal!.message).toMatch(/belongs on the NODE/); + }); + + it('a key beside a refused value: each keeps its own refusal', () => { + expect(flowNodeConfigRefusals('script', { ...MEASURED, function: '', bogusKey: 1 }).map(({ path }) => path).sort()) + .toEqual(['bogusKey', 'function']); + }); + + it('a node inside an ADR-0031 region body is refused at the path the author wrote', () => { + const body = { nodes: [{ id: 'inner', type: 'script', label: 'Inner', config: { function: 'f', bogusKey: 1 } }], edges: [] }; + expect(issuesOf(flowWith('loop', { collection: '{rows}', body })).map(({ path }) => path)) + .toEqual(['nodes.1.config.body.nodes.0.config.bogusKey']); + }); +}); + +describe('what stays as it was (lit controls)', () => { + it('CONTROL: the measured node without its extra key, and a valid subflow, parse clean', () => { + for (const [type, config] of [['script', MEASURED], ['subflow', SUBFLOW]] as const) { + expect(getBuiltinNodeConfigContracts().get(type)!.schema.safeParse(config).success, type).toBe(true); + expect(flowNodeConfigRefusals(type, config), type).toEqual([]); + expect(issuesOf(flowWith(type, config)), type).toEqual([]); + } + }); + + it('CONTROL: the key-judged builtins are exactly the schemaless ones with an executor contract', () => { + const judged = [...getBuiltinNodeConfigContracts().keys()] + .filter((type) => flowNodeConfigRefusals(type, { bogusKey: 1 }).some((r) => r.path === 'bogusKey')); + expect(judged.sort()).toEqual(['script', 'subflow']); + expect(judged.every((type) => Object.prototype.hasOwnProperty.call(SCHEMALESS_NODE_CONFIG_SCHEMAS, type))).toBe(true); + }); + + it('CONTROL: a builtin with a descriptor configSchema keeps its undeclared key at registration', () => { + for (const [type, config] of [ + ['http', { url: 'https://example.test/hook', method: 'POST', bogusKey: 1 }], + ['create_record', { objectName: 'task', fields: { title: 'X' }, bogusKey: 1 }], + ['screen', { fields: [{ name: 'qty', visibleIf: 'x' }] }], + ] as const) { + // The contract refuses it — the build arm holds back, registration judges it. + expect(getBuiltinNodeConfigContracts().get(type)!.schema.safeParse(config).success, type).toBe(false); + expect(flowNodeConfigRefusals(type, config), type).toEqual([]); + expect(issuesOf(flowWith(type, config)), type).toEqual([]); + } + }); + + it('CONTROL: decision is schemaless but its executor parses nothing, so its keys stay unjudged', () => { + expect(flowNodeConfigRefusals('decision', { condition: 'a == b', whateverElse: 1 })).toEqual([]); + }); + + it('CONTROL: a tombstoned retired script key keeps its existing path', () => { + const config = { ...MEASURED, actionType: 'email' }; + const own = getBuiltinNodeConfigContracts().get('script')!.schema.safeParse(config); + expect(own.success ? [] : own.error!.issues.map((i) => ({ code: i.code, path: i.path.join('.') }))).toEqual([ + { code: 'invalid_type', path: 'actionType' }, + ]); + expect(own.success ? '' : own.error!.issues[0]!.message).toMatch(/was removed in @objectstack\/spec 17/); + expect(flowNodeConfigRefusals('script', config)).toEqual([]); + expect(issuesOf(flowWith('script', config))).toEqual([]); + }); +}); + +describe('every door that parses a flow refuses it', () => { + const stackWith = (flows: unknown[]) => ({ + manifest: { id: 'com.example.keys', name: 'keys', version: '1.0.0', type: 'app', namespace: 'key' }, + objects: [{ name: 'key_task', label: 'Task', fields: { title: { type: 'text', label: 'Title' } } }], + flows, + }); + const refused = flowWith('script', { ...MEASURED, bogusKey: 1 }, 'key_refused'); + const subflowRefused = flowWith('subflow', { ...SUBFLOW, bogusKey: 1 }, 'key_subflow'); + const accepted = flowWith('script', MEASURED, 'key_ok'); + + it('defineStack wraps the refusal in its ADR-0112 envelope, at flows.1.nodes.1.config.bogusKey', () => { + let refusal: { code?: unknown; status?: unknown; issues?: Array<{ path: unknown[]; code: string }> } | undefined; + try { + defineStack(stackWith([accepted, refused]) as never); + } catch (e) { + refusal = e as typeof refusal; + } + expect(refusal, 'defineStack must refuse the flow').toBeDefined(); + expect({ code: refusal!.code, status: refusal!.status }).toEqual({ code: 'STACK_SCHEMA_INVALID', status: 422 }); + expect(refusal!.issues!.map((i) => ({ path: i.path.join('.'), code: i.code }))).toEqual([ + { path: 'flows.1.nodes.1.config.bogusKey', code: 'custom' }, + ]); + }); + + it('CONTROL: defineStack accepts the measured node without its extra key', () => { + expect(() => defineStack(stackWith([accepted]) as never)).not.toThrow(); + }); + + it('ObjectStackDefinitionSchema — the stack parse validate and compile run — refuses both types', () => { + const r = ObjectStackDefinitionSchema.safeParse(stackWith([refused, subflowRefused])); + expect(r.success).toBe(false); + expect(r.success ? [] : r.error.issues.map((i) => i.path.join('.'))).toEqual([ + 'flows.0.nodes.1.config.bogusKey', + 'flows.1.nodes.1.config.bogusKey', + ]); + expect(ObjectStackDefinitionSchema.safeParse(stackWith([accepted])).success).toBe(true); + }); + + it('the registered `flow` type schema — what the metadata save door validates against — refuses it too', () => { + const schema = getMetadataTypeSchema('flow') as unknown as typeof FlowSchema; + expect(schema).toBeDefined(); + const r = schema.safeParse(subflowRefused); + expect(r.success).toBe(false); + expect(r.success ? [] : r.error.issues.map((i) => i.path.join('.'))).toEqual(['nodes.1.config.bogusKey']); + expect(schema.safeParse(accepted).success).toBe(true); + }); + + it('an artifact\'s parse refuses it', () => { + const body = { id: 'com.example.keys', name: 'keys', version: '1.0.0', type: 'app' }; + const r = ArtifactStagePackageBodySchema.safeParse({ ...body, flows: [refused] }); + expect(r.success).toBe(false); + expect(r.success ? [] : r.error.issues.map((i) => i.path.join('.'))).toEqual(['flows.0.nodes.1.config.bogusKey']); + const ok = ArtifactStagePackageBodySchema.safeParse({ ...body, flows: [accepted] }); + expect(ok.success, JSON.stringify(ok.error?.issues ?? [])).toBe(true); + }); +}); + +describe('the ADR-0087 ledger', () => { + it('registers one D3 entry at protocol 18, with no D2 conversion', () => { + const entries = MIGRATIONS_BY_MAJOR[18]!.semantic.filter((e) => e.id === ENTRY_ID); + expect(entries, 'the narrowing needs its own D3 entry').toHaveLength(1); + const [entry] = entries; + expect(entry!.conversionIds ?? []).toEqual([]); + expect(entry!.acceptanceCriteria.length).toBeGreaterThan(0); + }); + + it('names the step-18 rationale fragment for it', () => { + expect(MIGRATIONS_BY_MAJOR[18]!.rationale).toContain(ENTRY_ID); + }); +}); diff --git a/packages/spec/src/automation/flow-node-config-refusals.ts b/packages/spec/src/automation/flow-node-config-refusals.ts index 01e47e2d7e3..154b3502de2 100644 --- a/packages/spec/src/automation/flow-node-config-refusals.ts +++ b/packages/spec/src/automation/flow-node-config-refusals.ts @@ -14,7 +14,10 @@ * `ApprovalNodeConfigSchema` with no plugin loaded. And (#21898) **a value a * builtin node's executor contract refuses**, where the build can know what * the run will parse — see {@link flowNodeConfigRefusals}' first arm for what - * that excludes, and why. + * that excludes, and why. And (#21982) **a key a builtin's executor contract + * does not declare, where no other door judges it** — `script` and `subflow`, + * whose descriptors publish no `configSchema` for registration's key check to + * read ({@link builtinKeysJudged}). * * Its refusal codes join the closed flow slot table * (`FLOW_SLOT_REFUSAL_CODES`, `flow-node-expression-paths.ts`); the @@ -44,7 +47,10 @@ import { UpdateRecordConfigSchema, } from './builtin-node-config.zod'; import { HttpConfigSchema, NotifyConfigSchema } from './io-node-config.zod'; -import { ScriptConfigSchema, SubflowConfigSchema } from './schemaless-node-config.zod'; +// [#21982] `SCHEMALESS_NODE_CONFIG_SCHEMAS` is the spec's own record of the +// node types that publish no descriptor `configSchema`; read only inside +// `builtinKeysJudged`, like the contracts beside it. +import { SCHEMALESS_NODE_CONFIG_SCHEMAS, ScriptConfigSchema, SubflowConfigSchema } from './schemaless-node-config.zod'; // [#21850] The one plugin node contract the spec declares. Read only inside // `getDeclaredPluginNodeConfigContracts`, like the executor contracts above. // `approval.zod.ts` imports nothing from `automation/` (zod, the membership-role @@ -331,9 +337,11 @@ interface ContractIssue { * another judge owns the finding: * * - key membership — an undeclared key (`unrecognized_keys`) and a tombstoned - * one (a `retiredKey()`, an `invalid_type` expecting `never`): registration - * refuses an undeclared key against the descriptor with its own - * prescriptions, the lint names the retired script keys, and the conversion + * one (a `retiredKey()`, an `invalid_type` expecting `never`): an undeclared + * key is the key arm's where no descriptor publishes a `configSchema` + * ({@link builtinKeysJudged}: `script`, `subflow`), and registration's + * everywhere else — it refuses one against the descriptor with its own + * prescriptions; the lint names the retired script keys, and the conversion * layer rewrites a retired spelling before the two doors that convert first; * - an ADR-0031 region slot, the slot itself included (`try: 5`): a region's * shape is `validateControlFlow`'s, and its nodes are the region walk's; @@ -362,6 +370,30 @@ function builtinValueJudged( return true; } +// ─── The builtin KEY arm (#21982) ───────────────────────────────────── + +/** + * [#21982] Does the build judge KEY MEMBERSHIP on this builtin's executor + * contract? Only where no door before the run does: a builtin whose descriptor + * publishes no `configSchema`, the spec's own schemaless class + * ({@link SCHEMALESS_NODE_CONFIG_SCHEMAS}). `registerFlow`'s undeclared-key + * walk (`validateNodeConfigKeys`) derives the declared set from that + * descriptor schema, so it skips these types, while their executors parse the + * strict contract and refuse the node on an undeclared key at every run. + * + * Today `script` and `subflow`. `decision` is in the schemaless class but has + * no builtin contract: its executor parses nothing, so an undeclared key fails + * no run. Every other builtin's undeclared key stays registration's, judged + * against its descriptor with that walk's own prescriptions — the spec arm + * does not shadow it, because `registerFlow` parses `FlowSchema` first. + * + * Asked only for a type in {@link getBuiltinNodeConfigContracts}; read on + * first use, like the contracts. + */ +function builtinKeysJudged(nodeType: string): boolean { + return Object.prototype.hasOwnProperty.call(SCHEMALESS_NODE_CONFIG_SCHEMAS, nodeType); +} + /** * [#21654] The write nodes, each with the verb its refusal names — the same * three, in the same words, as the run-time refusal in `service-automation` @@ -421,7 +453,9 @@ function nodeConfigKeyMissingMessage(nodeType: string, key: string): string { /** * [#21850] The refusal for a key a WHOLE-judged contract does not declare, or a - * value it refuses, where the author wrote it — the contract's own sentence, + * value it refuses — and (#21898) a value a builtin contract refuses, and + * (#21982) a key a key-judged builtin contract does not declare — where the + * author wrote it: the contract's own sentence, * inside one that names the node type and the key. `prescribe` adds the closing * instruction for a plain value finding; an unknown key's text (with its * did-you-mean) and a rule's text already say what to write. @@ -480,10 +514,21 @@ function unrecognizedKeysOf(issue: { readonly code: string }): readonly string[] * (`outputVariable` for a `create_record` `42`; `fields[0].min` for a * screen field's `'1'`), the same code and message the declared plugin * contract below uses. Kept only where {@link builtinValueJudged} holds — - * key MEMBERSHIP is not judged here (an undeclared key, a tombstoned one), - * nor a region slot, a `predicate` / `value` ledger slot, a run-resolved - * key, or any value carrying a `{token}`: never refused for its - * pre-interpolation type. + * key MEMBERSHIP is not judged by this bullet (an undeclared key is the + * next one's, a tombstoned key nobody's here), nor a region slot, a + * `predicate` / `value` ledger slot, a run-resolved key, or any value + * carrying a `{token}`: never refused for its pre-interpolation type. + * - (#21982) A key the contract does not declare, on a builtin whose + * descriptor publishes no `configSchema` ({@link builtinKeysJudged}: + * `script`, `subflow`) → `node-config-refused-by-contract`, anchored at the + * key, one refusal per undeclared key (`bogusKey` on a `script`), in the + * contract's own words — its prescription for a known slip included + * (`subflow` `timeoutMs` belongs on the node). Registration's undeclared-key + * walk reads the descriptor schema these types do not publish, and their + * executors parse the strict contract before anything else, so this is the + * one door before the run that refuses them. Every other builtin's + * undeclared key stays registration's, and a tombstoned key (a + * `retiredKey()`) keeps the path it had. * * Where the build cannot read the config whole, it reads only what is sound, * and each such type is named here, not skipped in silence: @@ -576,6 +621,9 @@ export function flowNodeConfigRefusals(nodeType: string, config: unknown): FlowN const contract = builtin ?? declared; if (!contract) return out; const whole = declared !== undefined; + // [#21982] Key membership: a declared plugin contract's always, a builtin's + // only where no descriptor `configSchema` lets registration judge it. + const keysJudged = whole || builtinKeysJudged(nodeType); const authored = config ?? {}; if (!isRecord(authored)) return out; if (contract.parsedWhen && !contract.parsedWhen(authored)) return out; @@ -583,7 +631,7 @@ export function flowNodeConfigRefusals(nodeType: string, config: unknown): FlowN if (result.success) return out; const seen = new Set(); for (const issue of result.error?.issues ?? []) { - if (whole) { + if (keysJudged) { // An unknown key's issue sits on the object that holds it (the config // itself for a top-level key, so its `path` is empty): anchor one // refusal at each key the author wrote, with the contract's sentence. diff --git a/packages/spec/src/automation/flow-node-expression-paths.ts b/packages/spec/src/automation/flow-node-expression-paths.ts index f905af35b0f..bd905904d18 100644 --- a/packages/spec/src/automation/flow-node-expression-paths.ts +++ b/packages/spec/src/automation/flow-node-expression-paths.ts @@ -556,10 +556,13 @@ export interface FlowSlotRefusalParams { readonly objectName: string; }; /** - * (#21850) A key a WHOLE-judged node contract does not declare, or a value it - * refuses, at the key the author wrote — today the `approval` node's, the one - * plugin node contract the spec declares. Its message is the contract's own - * sentence, inside one naming the node type and the key. + * A node's executor contract refuses what the author wrote, at the key they + * wrote it: (#21850) a key the WHOLE-judged `approval` contract — the one + * plugin node contract the spec declares — does not declare, or a value it + * refuses; (#21898) a value a builtin node's executor contract refuses; and + * (#21982) a key a `script` or `subflow` executor contract does not declare. + * Its message is the contract's own sentence, inside one naming the node type + * and the key. */ 'node-config-refused-by-contract': { readonly nodeType: string; readonly key: string }; } diff --git a/packages/spec/src/automation/flow.zod.ts b/packages/spec/src/automation/flow.zod.ts index 6f05aaecab0..3498ae5f1d8 100644 --- a/packages/spec/src/automation/flow.zod.ts +++ b/packages/spec/src/automation/flow.zod.ts @@ -159,17 +159,24 @@ export const FLOW_PAUSE_CAPABLE_NODE_TYPES: readonly string[] = [ * note). One type-specific exception to the open slot (#14945): the structural * `end` node has no executor and no descriptor, so the flow parse is the ONLY * door its config passes through — {@link parseEndNodeConfig} applies - * {@link EndConfigSchema} to it. Every other type's `config` stays the - * executor's to close. One VALUE rule reaches into an open `config` at the - * flow level without closing its key set (#17493): a blank string in a slot - * the expression ledger declares with the `predicate` role is refused by the - * `FlowSchema` superRefine — see the block there for its scope. A PRESENCE - * rule reaches in beside it, still without closing the key set (#20316): a - * key the node's executor contract requires, left out, and a `decision` - * branch list the executor cannot read — `flowNodeConfigRefusals`, in the - * same superRefine — and (#21898) a VALUE rule with it, again without closing - * the key set: a value a builtin node's executor contract refuses, where the - * build can know what the run will parse. + * {@link EndConfigSchema} to it. For every other type the `config` SHAPE + * stays open here (`FlowNodeSchema.config` is a `z.record`) and the executor's + * contract is what closes it; the flow level reads into it through one judge, + * `flowNodeConfigRefusals`, in the `FlowSchema` superRefine, and never + * re-declares a type's shape. A VALUE rule reaches in (#17493): a blank string + * in a slot the expression ledger declares with the `predicate` role is + * refused — see the block there for its scope. A PRESENCE rule beside it + * (#20316): a key the node's executor contract requires, left out, and a + * `decision` branch list the executor cannot read. A builtin VALUE rule with + * it (#21898): a value a builtin node's executor contract refuses, where the + * build can know what the run will parse. And KEY MEMBERSHIP only where the + * build is the one door before the run: the declared `approval` contract is + * judged whole (#21850), and a key a `script` or `subflow` executor contract + * does not declare is refused (#21982) — those two descriptors publish no + * `configSchema` for registration's undeclared-key walk to read. Every other + * builtin's undeclared key is judged at `registerFlow` against its descriptor, + * and so is a plugin type's whose contract the spec does not declare; the + * flow parse does not judge those keys. */ /** diff --git a/packages/spec/src/migrations/entries/semantic/18.flow-script-subflow-config-undeclared-keys-refused.ts b/packages/spec/src/migrations/entries/semantic/18.flow-script-subflow-config-undeclared-keys-refused.ts new file mode 100644 index 00000000000..937bbd17f8d --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.flow-script-subflow-config-undeclared-keys-refused.ts @@ -0,0 +1,74 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #21982 — the D3 entry for the build doors refusing an UNDECLARED KEY on a +// `script` or `subflow` node's config: the key half of the executor-contract +// arm of `flowNodeConfigRefusals` (`flow-node-config-refusals.ts`), beside the +// value half (`flow-builtin-node-config-values-refused`) and the presence half +// (`flow-node-config-required-keys-refused`). It narrows a flow's accept set; +// no key is removed, so there is no tombstone and no RETIRED_KEYS_BY_MAJOR +// row. There is no D2 conversion either: the platform cannot know what an +// undeclared key was meant to be, and the runtime never ran such a node. +// +// No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it +// inside a code span and a table cell. +export const entry: SemanticMigration = { + id: 'flow-script-subflow-config-undeclared-keys-refused', + surface: + 'a script or subflow flow node whose config carries a key its executor contract does not declare — a ' + + 'typo (funtion), a key copied from another node type (a subflow timeoutMs written inside config, an ' + + 'approvers list on a script), or a key nothing reads (bogusKey). script declares function, inputs and ' + + 'outputVariable; subflow declares flowName, input and outputVariable. Never a retired script key ' + + '(actionType, template, recipients, variables, script), which keeps its own path, and never a key on ' + + 'any other builtin node type, whose undeclared keys registration already judges against the node ' + + 'type descriptor. Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, ' + + 'defineFlow(), an exported stack passed to objectstack validate or objectstack compile, a flow saved ' + + 'from the Studio flow designer, and a flow row already sitting in sys_metadata', + replacement: + 'the key the contract declares, or no key: rename a typo to the declared key it meant (`function`, ' + + '`inputs`, `outputVariable` on a `script`; `flowName`, `input`, `outputVariable` on a `subflow`), move ' + + 'a value the function or the child flow should receive into `inputs` (script) or `input` (subflow), ' + + 'move a `subflow` timeout to the node itself (`{ id, type: \'subflow\', timeoutMs: 30000, config: { … } }`), ' + + 'and delete a key nothing reads. The refusal carries the contract\'s own sentence, with its ' + + 'did-you-mean for a near miss', + reason: + 'The `script` and `subflow` executors (`service-automation` `builtin/screen-nodes.ts`, ' + + '`builtin/subflow-node.ts`) parse the node\'s `config` against a strict contract ' + + '(`ScriptConfigSchema`, `SubflowConfigSchema`) before they act, and refuse the node on an undeclared ' + + 'key. No door before the run judged one: `registerFlow`\'s undeclared-key check derives the declared ' + + 'set from the node type descriptor\'s `configSchema`, and these two descriptors publish none (the ' + + 'schemaless class, `SCHEMALESS_NODE_CONFIG_SCHEMAS`), while the build doors\' executor-contract arm ' + + 'judged required keys and present values but held key membership back on the premise that ' + + 'registration judges it. So a `script` node carrying `bogusKey` passed `FlowSchema.parse`, `objectstack ' + + 'validate` and `objectstack compile` (which copied the key into the artifact), registered, and then ' + + 'failed every run that reached the node: the config is metadata, and no rerun could succeed. The one ' + + 'judge `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first) and `objectstack ' + + 'validate` share (`flowNodeConfigRefusals`) now refuses such a key on these two types as ' + + '`node-config-refused-by-contract`, anchored at the key, one refusal per key, in the contract\'s own ' + + 'words — the code the value half and the approval contract already use. Every other builtin keeps its ' + + 'undeclared keys where they were judged: at registration, against its descriptor, with that check\'s ' + + 'own prescriptions. `decision` is schemaless too, but its executor parses no contract, so an ' + + 'undeclared key there fails no run and stays unjudged. A retired `script` key keeps its tombstone ' + + 'path. ⚠️ A spelling the ADR-0087 D2 conversion `flow-node-script-config-aliases` or ' + + '`flow-node-subflow-flow-alias` still rewrites at load (`functionName`, `input` on a `script`; `flow` ' + + 'on a `subflow`) is converted before the judge at every door that converts first; met by a direct ' + + '`FlowSchema.parse` or `defineFlow()` it is refused like any other undeclared key, as the missing ' + + 'canonical key already was. ⚠️ No D2 conversion: the platform cannot know what an undeclared key was ' + + 'meant to be. ⚠️ Where such a node already sits the whole flow is refused: registered from the ' + + 'metadata registry or `sys_metadata` at boot it is skipped with a `warn` naming it, its trigger not ' + + 'armed, while the flows beside it register; a `defineStack({ flows })` source throws ' + + '`StackSchemaInvalidError` for the whole stack; an artifact file is refused whole at load. ADR-0087, ' + + 'ADR-0031.', + acceptanceCriteria: + 'Run `objectstack validate` over every stack authored in config files, and boot every deployed ' + + 'stack. Each refusal names the node and the key: `FlowSchema.parse` anchors a `custom` issue at ' + + '`nodes.N.config.` (`nodes.N.config.bogusKey`, or the region path ' + + '`nodes.N.config.body.nodes.M.config…`), `objectstack validate` prints the same path, and ' + + '`validateStackExpressions` phrases it as `node \'summarize\' (script) config.bogusKey`. For each hit ' + + 'rename, move or delete the key per the replacement. Two proofs. (1) For a stack authored in config ' + + 'files, `objectstack validate` is clean. (2) Boot the stack and confirm each flow REGISTERS: no ' + + '`failed to register flow` warn for it — that warn line is the locator for a row that exists only in ' + + '`sys_metadata`. A `script` or `subflow` node whose keys its contract declares parses and registers ' + + 'byte-identically to before.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index b135be10c98..2377160040e 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5668,6 +5668,22 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + '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`.', }, + { + id: 'flow-script-subflow-config-undeclared-keys-refused', + order: 87, + text: + '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`.', + }, { id: 'flow-write-node-stored-metadata-target-refused', order: 74, @@ -13771,6 +13787,76 @@ const step18: MigrationStep = { + 'predicate parses and registers byte-identically to before, and a non-string in these ' + 'slots keeps its own earlier refusal (at `registerFlow` and `objectstack validate`).', }, + // #21982 — the D3 entry for the build doors refusing an UNDECLARED KEY on a + // `script` or `subflow` node's config: the key half of the executor-contract + // arm of `flowNodeConfigRefusals` (`flow-node-config-refusals.ts`), beside the + // value half (`flow-builtin-node-config-values-refused`) and the presence half + // (`flow-node-config-required-keys-refused`). It narrows a flow's accept set; + // no key is removed, so there is no tombstone and no RETIRED_KEYS_BY_MAJOR + // row. There is no D2 conversion either: the platform cannot know what an + // undeclared key was meant to be, and the runtime never ran such a node. + // + // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it + // inside a code span and a table cell. + { + id: 'flow-script-subflow-config-undeclared-keys-refused', + surface: + 'a script or subflow flow node whose config carries a key its executor contract does not declare — a ' + + 'typo (funtion), a key copied from another node type (a subflow timeoutMs written inside config, an ' + + 'approvers list on a script), or a key nothing reads (bogusKey). script declares function, inputs and ' + + 'outputVariable; subflow declares flowName, input and outputVariable. Never a retired script key ' + + '(actionType, template, recipients, variables, script), which keeps its own path, and never a key on ' + + 'any other builtin node type, whose undeclared keys registration already judges against the node ' + + 'type descriptor. Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, ' + + 'defineFlow(), an exported stack passed to objectstack validate or objectstack compile, a flow saved ' + + 'from the Studio flow designer, and a flow row already sitting in sys_metadata', + replacement: + 'the key the contract declares, or no key: rename a typo to the declared key it meant (`function`, ' + + '`inputs`, `outputVariable` on a `script`; `flowName`, `input`, `outputVariable` on a `subflow`), move ' + + 'a value the function or the child flow should receive into `inputs` (script) or `input` (subflow), ' + + 'move a `subflow` timeout to the node itself (`{ id, type: \'subflow\', timeoutMs: 30000, config: { … } }`), ' + + 'and delete a key nothing reads. The refusal carries the contract\'s own sentence, with its ' + + 'did-you-mean for a near miss', + reason: + 'The `script` and `subflow` executors (`service-automation` `builtin/screen-nodes.ts`, ' + + '`builtin/subflow-node.ts`) parse the node\'s `config` against a strict contract ' + + '(`ScriptConfigSchema`, `SubflowConfigSchema`) before they act, and refuse the node on an undeclared ' + + 'key. No door before the run judged one: `registerFlow`\'s undeclared-key check derives the declared ' + + 'set from the node type descriptor\'s `configSchema`, and these two descriptors publish none (the ' + + 'schemaless class, `SCHEMALESS_NODE_CONFIG_SCHEMAS`), while the build doors\' executor-contract arm ' + + 'judged required keys and present values but held key membership back on the premise that ' + + 'registration judges it. So a `script` node carrying `bogusKey` passed `FlowSchema.parse`, `objectstack ' + + 'validate` and `objectstack compile` (which copied the key into the artifact), registered, and then ' + + 'failed every run that reached the node: the config is metadata, and no rerun could succeed. The one ' + + 'judge `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first) and `objectstack ' + + 'validate` share (`flowNodeConfigRefusals`) now refuses such a key on these two types as ' + + '`node-config-refused-by-contract`, anchored at the key, one refusal per key, in the contract\'s own ' + + 'words — the code the value half and the approval contract already use. Every other builtin keeps its ' + + 'undeclared keys where they were judged: at registration, against its descriptor, with that check\'s ' + + 'own prescriptions. `decision` is schemaless too, but its executor parses no contract, so an ' + + 'undeclared key there fails no run and stays unjudged. A retired `script` key keeps its tombstone ' + + 'path. ⚠️ A spelling the ADR-0087 D2 conversion `flow-node-script-config-aliases` or ' + + '`flow-node-subflow-flow-alias` still rewrites at load (`functionName`, `input` on a `script`; `flow` ' + + 'on a `subflow`) is converted before the judge at every door that converts first; met by a direct ' + + '`FlowSchema.parse` or `defineFlow()` it is refused like any other undeclared key, as the missing ' + + 'canonical key already was. ⚠️ No D2 conversion: the platform cannot know what an undeclared key was ' + + 'meant to be. ⚠️ Where such a node already sits the whole flow is refused: registered from the ' + + 'metadata registry or `sys_metadata` at boot it is skipped with a `warn` naming it, its trigger not ' + + 'armed, while the flows beside it register; a `defineStack({ flows })` source throws ' + + '`StackSchemaInvalidError` for the whole stack; an artifact file is refused whole at load. ADR-0087, ' + + 'ADR-0031.', + acceptanceCriteria: + 'Run `objectstack validate` over every stack authored in config files, and boot every deployed ' + + 'stack. Each refusal names the node and the key: `FlowSchema.parse` anchors a `custom` issue at ' + + '`nodes.N.config.` (`nodes.N.config.bogusKey`, or the region path ' + + '`nodes.N.config.body.nodes.M.config…`), `objectstack validate` prints the same path, and ' + + '`validateStackExpressions` phrases it as `node \'summarize\' (script) config.bogusKey`. For each hit ' + + 'rename, move or delete the key per the replacement. Two proofs. (1) For a stack authored in config ' + + 'files, `objectstack validate` is clean. (2) Boot the stack and confirm each flow REGISTERS: no ' + + '`failed to register flow` warn for it — that warn line is the locator for a row that exists only in ' + + '`sys_metadata`. A `script` or `subflow` node whose keys its contract declares parses and registers ' + + 'byte-identically to before.', + }, // 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