diff --git a/.changeset/21850-flow-approval-node-config-contract-refused.md b/.changeset/21850-flow-approval-node-config-contract-refused.md new file mode 100644 index 00000000000..145dbe0f836 --- /dev/null +++ b/.changeset/21850-flow-approval-node-config-contract-refused.md @@ -0,0 +1,41 @@ +--- +'@objectstack/spec': minor +--- + +A flow `approval` node's `config` is judged at parse against the contract the spec declares for it, `ApprovalNodeConfigSchema`, whole: an undeclared key, a refused value and a required key left out are each refused with a location, in the contract's own words. + +Clause-②: yes (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 approval node's executor parses `node.config` against `ApprovalNodeConfigSchema` before it does anything else and fails the node on any issue. Registration already refused an undeclared key, but a refused value such as `escalation.timeoutHours: 0.5` registered and then failed every run that reached the node, and no build door asked about either: `objectstack validate` and `objectstack compile` exited 0 on an `escalation.bogusKey` or a `timeoutHours: 0.5`, and compile copied it into `dist/objectstack.json`. + +**What is refused.** An `approval` node, at any depth, whose `config` the approval contract refuses. The judge is `flowNodeConfigRefusals`, the one `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first) and `objectstack validate` share; the approval contract joins it as a declared contract map beside the builtin executor contracts, with no plugin loaded. Every issue that contract raises is refused, because the executor refuses on every one: + +- an undeclared key, at the key (`nodes.N.config.escalation.bogusKey`, one issue per key, the top level included), and a refused value, at its key (`nodes.N.config.escalation.timeoutHours` for `0.5` under its minimum of 1): the new closed-set code `node-config-refused-by-contract`, `params: { nodeType, key }`, whose message carries the contract's own sentence — for an alias, its did-you-mean (`timeout` → `timeoutHours`); +- a required key left out (`approvers`; `timeoutHours` inside an `escalation` block): `node-config-key-missing`, as for a builtin node, or `node-config-key-required-by-rule` where a rule of the contract requires it. + +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 (`422 INVALID_METADATA`). + +**What stays accepted, byte for byte.** Every approval node the contract accepts, an `approval_revise` node, and every builtin node: the builtin arm still judges only a key left out, so an undeclared key or a wrong-typed value on a builtin node is judged where it was before. A plugin node type whose contract the spec does not declare stays outside the build doors. + +## FROM → TO + +| you wrote | write instead | +|:--|:--| +| `escalation: { …, bogusKey: 1 }`, or any key the contract does not declare | delete the key, or rename it to the one the refusal's did-you-mean names (`timeout` → `timeoutHours`, `mode` → `behavior`, `quorum` → `minApprovals`) | +| `escalation: { timeoutHours: 0.5 }` | `escalation: { timeoutHours: 1 }` — whole wall-clock hours, at least 1 | +| `escalation: { enabled: false }` with no `timeoutHours` | delete the `escalation` block | +| `steps`, `entryCriteria`, `onApprove`, `onReject` or `rejectionBehavior` on the node | the flow graph, as the refusal's guidance says (successive nodes, the entering edge's `condition`, the `approve` / `reject` out-edges, a back-edge) | +| an approval node with no `approvers` | `approvers: [{ type: 'position', value: '' }]` (or any approver the contract accepts) | + +**The one-line fix: write the shape the approval contract declares at 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 `5e0b489bca`, every approval node `config` authored in this repository parses under the contract: `examples/**` (15 nodes, all in the showcase), `content/docs/**` (6 snippets), `skills/**` (5 snippets) and the `packages/qa/dogfood` fixtures (6 nodes), and so does the Studio designer's approval seed at the pinned objectui commit. Deployed metadata, and repositories other than these two, 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. + +### The kit + +- **The refusal.** The declared contract map in `automation/flow-node-config-refusals.ts`, read by the same executor-contract arm of `flowNodeConfigRefusals`; the new code joins `FLOW_SLOT_REFUSAL_CODES`. +- **The ledger.** The D3 semantic entry `flow-approval-node-config-contract-refused` (protocol 18). No key is removed, so there is no tombstone, and there is no D2 conversion: the platform cannot know the approvers, the key or the value the author meant. diff --git a/packages/lint/src/runtime-gate.test.ts b/packages/lint/src/runtime-gate.test.ts index 0658364f335..eea9e7824cb 100644 --- a/packages/lint/src/runtime-gate.test.ts +++ b/packages/lint/src/runtime-gate.test.ts @@ -45,7 +45,6 @@ const cleanApprovalFlow = { type: 'approval', config: { approvers: [{ type: 'expression', value: 'current.owner' }], - emptyApproverPolicy: 'reject', }, }, ], diff --git a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts index b81463197f0..71015004691 100644 --- a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts +++ b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts @@ -70,7 +70,6 @@ const validApprovalFlow = () => { const flow = brokenApprovalFlow(); flow.nodes[1]!.config = { approvers: [{ type: 'expression', value: 'current.owner' }], - emptyApproverPolicy: 'reject', } as any; return flow; }; diff --git a/packages/objectql/src/plugin.authoring-channel.test.ts b/packages/objectql/src/plugin.authoring-channel.test.ts index 1931a529d1e..0b4657e97eb 100644 --- a/packages/objectql/src/plugin.authoring-channel.test.ts +++ b/packages/objectql/src/plugin.authoring-channel.test.ts @@ -274,7 +274,6 @@ describe('#6710 — the authoring channel is threaded from plugin option to prot const flow = brokenApprovalFlow(); (flow.nodes[1] as any).config = { approvers: [{ type: 'expression', value: 'current.owner' }], - emptyApproverPolicy: 'reject', }; const result = await (kernel.getService('protocol') as any).saveMetaItem({ type: 'flow', name: 'leave_approval', item: flow, diff --git a/packages/services/service-automation/src/engine.test.ts b/packages/services/service-automation/src/engine.test.ts index 65c85b49fdd..462c0ae7246 100644 --- a/packages/services/service-automation/src/engine.test.ts +++ b/packages/services/service-automation/src/engine.test.ts @@ -2626,7 +2626,7 @@ describe('Action Descriptor Registry (ADR-0018)', () => { type: 'autolaunched' as const, nodes: [ { id: 'start', type: 'start', label: 'Start' }, - { id: 'custom', type, label: 'Custom' }, + { id: 'custom', type, label: 'Custom', ...(type === 'approval' ? { config: { approvers: [{ type: 'user', value: 'u1' }] } } : {}) }, { id: 'end', type: 'end', label: 'End' }, ], edges: [ diff --git a/packages/spec/src/automation/flow-approval-node-config-contract.test.ts b/packages/spec/src/automation/flow-approval-node-config-contract.test.ts new file mode 100644 index 00000000000..2fe35620cc8 --- /dev/null +++ b/packages/spec/src/automation/flow-approval-node-config-contract.test.ts @@ -0,0 +1,265 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21850] The build doors judge an `approval` node's `config` against the + * contract the spec declares for it, `ApprovalNodeConfigSchema`, WHOLE — the + * declared contract map in `flow-node-config-refusals.ts`, beside the builtin + * executor contracts and read by the same judge, `flowNodeConfigRefusals`. + * + * The approval executor (`plugin-approvals`) parses `node.config ?? {}` + * against that contract before it does anything else and fails the node on + * ANY issue, so every contract finding is one the run would refuse: a key the + * contract requires left out, a key it does not declare, a value it refuses. + * `FlowSchema` used to accept all three, so `objectstack validate` and + * `objectstack compile` exited 0 on them and compile copied the shape into the + * artifact; the author learned otherwise at the first run. + * + * Every door that parses a flow meets the judge: `FlowSchema` itself, + * `defineStack`, the stack parse `objectstack validate` and `compile` run, the + * registered `flow` type schema the metadata save door validates against, and + * an artifact's parse — pinned here. `registerFlow` parses first, and + * `validateStackExpressions` calls the same judge. + * + * No plugin is loaded for any of it: the contract is the spec's own. The + * builtin arm is unchanged, presence-only — a control below holds it there. + */ + +import { describe, expect, it } from 'vitest'; + +import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; +import { MIGRATIONS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; +import { ArtifactStagePackageBodySchema, ObjectStackDefinitionSchema, defineStack } from '../stack.zod'; +import { ApprovalEscalationSchema, ApprovalNodeConfigSchema } from './approval.zod'; +import { flowNodeConfigRefusals, getBuiltinNodeConfigContracts } from './flow-node-config-refusals'; +import { FlowSchema } from './flow.zod'; + +const ENTRY_ID = 'flow-approval-node-config-contract-refused'; + +type Config = Record; + +const APPROVERS = [{ type: 'position', value: 'finance_reviewer' }]; + +/** A whole approval config the contract accepts — the accept control, and the base every probe edits. */ +const VALID: Config = { + approvers: APPROVERS, + behavior: 'first_response', + lockRecord: true, + escalation: { enabled: true, timeoutHours: 4, action: 'notify', notifySubmitter: true }, +}; + +const withEscalation = (escalation: Config): Config => ({ ...VALID, escalation }); + +/** start → the approval node → approve / reject ends. */ +function flowWith(config: unknown, name = 'approval_probe') { + return { + name, + label: 'Approval probe', + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'gate', type: 'approval', label: 'Gate', ...(config === undefined ? {} : { config }) }, + { id: 'approved', type: 'end', label: 'Approved' }, + { id: 'rejected', type: 'end', label: 'Rejected' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'gate' }, + { id: 'e2', source: 'gate', target: 'approved', label: 'approve' }, + { id: 'e3', source: 'gate', target: 'rejected', label: 'reject' }, + ], + }; +} + +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 approval contract's own sentence at one issue path — read, never re-spelled. */ +function contractSentence(schema: { safeParse(v: unknown): { success: boolean; error?: { issues: Array<{ path: PropertyKey[]; message: string }> } } }, value: unknown, path: string): string { + const own = schema.safeParse(value); + return own.success ? '' : own.error?.issues.find((i) => i.path.join('.') === path)?.message ?? ''; +} + +describe('FlowSchema judges an approval node config against its declared contract, whole', () => { + it('an undeclared escalation key is refused at nodes.1.config.escalation.bogusKey', () => { + const config = withEscalation({ timeoutHours: 2, action: 'notify', bogusKey: 1 }); + const issues = issuesOf(flowWith(config)); + expect(issues.map(({ code, path }) => ({ code, path }))).toEqual([ + { code: 'custom', path: 'nodes.1.config.escalation.bogusKey' }, + ]); + // The judge's own words, never re-spelled, carrying the contract's own sentence. + expect(issues[0]!.message).toBe(flowNodeConfigRefusals('approval', config)[0]!.message); + expect(issues[0]!.message).toContain(contractSentence(ApprovalEscalationSchema, config.escalation, '')); + }); + + it('a timeoutHours under the contract minimum is refused at nodes.1.config.escalation.timeoutHours', () => { + const issues = issuesOf(flowWith(withEscalation({ timeoutHours: 0.5, action: 'notify' }))); + expect(issues.map(({ code, path }) => ({ code, path }))).toEqual([ + { code: 'custom', path: 'nodes.1.config.escalation.timeoutHours' }, + ]); + }); + + it('the judge answers both with its code, params and path', () => { + const bogus = flowNodeConfigRefusals('approval', withEscalation({ timeoutHours: 2, bogusKey: 1 })); + const halfHour = flowNodeConfigRefusals('approval', withEscalation({ timeoutHours: 0.5 })); + expect([...bogus, ...halfHour].map(({ code, params, path, source }) => ({ code, params, path, source }))).toEqual([ + { code: 'node-config-refused-by-contract', params: { nodeType: 'approval', key: 'escalation.bogusKey' }, path: 'escalation.bogusKey', source: '' }, + { code: 'node-config-refused-by-contract', params: { nodeType: 'approval', key: 'escalation.timeoutHours' }, path: 'escalation.timeoutHours', source: '' }, + ]); + }); + + it('an alias keeps the contract\'s own did-you-mean, beside the key it leaves out', () => { + const config = withEscalation({ timeout: 2 }); + const issues = issuesOf(flowWith(config)); + expect(issues.map(({ path }) => path).sort()).toEqual([ + 'nodes.1.config.escalation.timeout', + 'nodes.1.config.escalation.timeoutHours', + ]); + const aliasSentence = contractSentence(ApprovalEscalationSchema, config.escalation, ''); + expect(aliasSentence).toContain('`timeoutHours`'); + expect(issues.find((i) => i.path.endsWith('.timeout'))!.message).toContain(aliasSentence); + }); + + it('an undeclared top-level key is refused at the key, one refusal per key', () => { + const issues = issuesOf(flowWith({ ...VALID, steps: [], quorum: 2 })); + expect(issues.map(({ path }) => path)).toEqual(['nodes.1.config.steps', 'nodes.1.config.quorum']); + }); + + it('a key the contract requires, left out, is refused with the builtin arm\'s code', () => { + for (const config of [undefined, {}]) { + expect(issuesOf(flowWith(config)).map(({ path }) => path), JSON.stringify(config)).toEqual(['nodes.1.config.approvers']); + expect(flowNodeConfigRefusals('approval', config).map(({ code }) => code)).toEqual(['node-config-key-missing']); + } + }); + + it('a value a rule of the contract refuses is refused in the rule\'s own words', () => { + const config = { ...VALID, onEmptyApprovers: 'fail', fallbackApprovers: APPROVERS }; + const [refusal] = flowNodeConfigRefusals('approval', config); + expect(refusal!.path).toBe('onEmptyApprovers'); + expect(refusal!.message).toContain(contractSentence(ApprovalNodeConfigSchema, config, 'onEmptyApprovers')); + }); + + it('the judge refuses exactly what the contract refuses, over a sweep of configs', () => { + const sweep: unknown[] = [ + undefined, {}, VALID, + { approvers: [] }, + { approvers: 'u1' }, + { approvers: [{ type: 'user', value: 'u1' }] }, + { ...VALID, behavior: 'weighted' }, + { ...VALID, minApprovals: 0 }, + { ...VALID, maxRevisions: 1.5 }, + { ...VALID, decisionOutputs: ['note', { key: 'picked', type: 'user', multiple: true }] }, + withEscalation({ timeoutHours: 1 }), + withEscalation({ enabled: false }), + withEscalation({ timeoutHours: 2, action: 'escalate' }), + withEscalation({ timeoutHours: '2' }), + ]; + for (const config of sweep) { + const refused = flowNodeConfigRefusals('approval', config).length > 0; + expect(refused, JSON.stringify(config)).toBe(!ApprovalNodeConfigSchema.safeParse(config ?? {}).success); + } + }); +}); + +describe('what stays accepted (lit controls)', () => { + it('CONTROL: a valid approval node parses', () => { + expect(issuesOf(flowWith(VALID))).toEqual([]); + expect(flowNodeConfigRefusals('approval', VALID)).toEqual([]); + }); + + it('CONTROL: an escalation at the contract minimum, and a node with no escalation block, parse', () => { + expect(issuesOf(flowWith(withEscalation({ timeoutHours: 1 })))).toEqual([]); + expect(issuesOf(flowWith({ approvers: APPROVERS }))).toEqual([]); + }); + + it('CONTROL: the builtin arm stays presence-only — an undeclared key on a builtin node still parses', () => { + const flow = { + ...flowWith(VALID), + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'call', type: 'http', label: 'Call', config: { url: 'https://example.test', bogusKey: 1 } }, + { id: 'done', type: 'end', label: 'Done' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'call' }, { id: 'e2', source: 'call', target: 'done' }], + }; + expect(issuesOf(flow)).toEqual([]); + // …and the builtin map, the executor-reconciled one, did not gain the plugin type. + expect(getBuiltinNodeConfigContracts().has('approval')).toBe(false); + }); + + it('CONTROL: approval_revise carries no config contract and is not judged', () => { + expect(flowNodeConfigRefusals('approval_revise', { anything: 1 })).toEqual([]); + }); +}); + +describe('every door that parses a flow refuses it', () => { + const stackWith = (flows: unknown[]) => ({ + manifest: { id: 'com.example.approvals', name: 'approvals', version: '1.0.0', type: 'app', namespace: 'apv' }, + objects: [{ name: 'apv_request', label: 'Request', fields: { title: { type: 'text', label: 'Title' } } }], + flows, + }); + const refused = flowWith(withEscalation({ timeoutHours: 2, bogusKey: 1 }), 'apv_refused'); + const accepted = flowWith(VALID, 'apv_ok'); + + it('defineStack wraps the refusal in its ADR-0112 envelope, at flows.N.nodes.1.config.escalation.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 approval 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.escalation.bogusKey', code: 'custom' }, + ]); + }); + + it('CONTROL: defineStack accepts the valid approval flow alone', () => { + expect(() => defineStack(stackWith([accepted]) as never)).not.toThrow(); + }); + + it('ObjectStackDefinitionSchema — the stack parse validate and compile run — refuses it at the same path', () => { + const r = ObjectStackDefinitionSchema.safeParse(stackWith([flowWith(withEscalation({ timeoutHours: 0.5 }), 'apv_half')])); + expect(r.success).toBe(false); + expect(r.success ? [] : r.error.issues.map((i) => i.path.join('.'))).toEqual(['flows.0.nodes.1.config.escalation.timeoutHours']); + 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(refused); + expect(r.success).toBe(false); + expect(r.success ? [] : r.error.issues.map((i) => i.path.join('.'))).toEqual(['nodes.1.config.escalation.bogusKey']); + expect(schema.safeParse(accepted).success).toBe(true); + }); + + it('an artifact\'s parse refuses it', () => { + const body = { id: 'com.example.approvals', name: 'approvals', 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.escalation.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('registers no tombstone: no approval config key is removed', () => { + const all = Object.values(RETIRED_KEYS_BY_MAJOR).flat(); + expect(all.filter((k) => /Approval(NodeConfig|Escalation)[^:]*:/.test(k))).toEqual([]); + // CONTROL: the flattened table is the real one — it carries a known step-18 tombstone. + expect(all).toContain('api/RestApiEndpoint:timeout'); + }); +}); diff --git a/packages/spec/src/automation/flow-node-config-refusals.ts b/packages/spec/src/automation/flow-node-config-refusals.ts index 82a173479eb..f915eb58bcd 100644 --- a/packages/spec/src/automation/flow-node-config-refusals.ts +++ b/packages/spec/src/automation/flow-node-config-refusals.ts @@ -9,7 +9,9 @@ * first) and `objectstack validate` share, beside the expression ledger's * `predicateSlotRefusal` and closing the same gap: a node's `config` is an * open `z.record`, so what its executor requires, or refuses, was checked by - * nobody until the run. + * nobody until the run. And (#21850) **the whole config of a plugin node type + * whose contract the spec itself declares** — `approval`, judged against + * `ApprovalNodeConfigSchema` with no plugin loaded. * * Its refusal codes join the closed flow slot table * (`FLOW_SLOT_REFUSAL_CODES`, `flow-node-expression-paths.ts`); the @@ -40,6 +42,11 @@ import { } from './builtin-node-config.zod'; import { HttpConfigSchema, NotifyConfigSchema } from './io-node-config.zod'; import { 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 +// leaf, `lazySchema` and `strictObject` only), so it adds no cycle here. +import { APPROVAL_NODE_TYPE, ApprovalNodeConfigSchema } from './approval.zod'; /** * The executor contract a builtin node's `config` is parsed against at run @@ -89,7 +96,11 @@ let cachedBuiltinNodeConfigContracts: ReadonlyMap { if (cachedBuiltinNodeConfigContracts === undefined) { @@ -112,6 +123,39 @@ export function getBuiltinNodeConfigContracts(): ReadonlyMap | undefined; + +/** + * [#21850] Every PLUGIN node type whose `config` contract the spec itself + * declares, keyed by `node.type` — the declared contract map. Today one: + * `approval`, whose executor (`plugin-approvals`, `approval-node.ts`) parses + * `node.config ?? {}` against `ApprovalNodeConfigSchema` before it does + * anything else, and fails the node on ANY issue. + * + * Judged WHOLE, unlike the builtin map's presence-only arm: every issue the + * contract raises is a refusal — a key it requires left out, a key it does not + * declare, and a value it refuses — because the executor refuses the node on + * every one of them, so a flow carrying one would fail at every run that + * reached the node. The contract is a `strictObject` whose unknown-key text + * carries its own did-you-mean (`timeout` → `timeoutHours`), and that text is + * the refusal's. + * + * ⛔ No plugin is loaded to build it, and no node type joins it whose contract + * the spec does not declare: a plugin node type the spec knows nothing about + * stays outside the build doors, judged at registration by its descriptor's + * own `configSchema`. + * + * Built on first use, never at module load, like the builtin map. + */ +function getDeclaredPluginNodeConfigContracts(): ReadonlyMap { + if (cachedDeclaredPluginNodeConfigContracts === undefined) { + cachedDeclaredPluginNodeConfigContracts = new Map([ + [APPROVAL_NODE_TYPE, { schema: ApprovalNodeConfigSchema }], + ]); + } + return cachedDeclaredPluginNodeConfigContracts; +} + /** A plain object (not an array, not `null`). */ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); @@ -241,6 +285,36 @@ 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, + * 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. + */ +function nodeConfigRefusedByContractMessage( + nodeType: string, + key: string, + contractMessage: string, + prescribe: boolean, +): string { + const reason = contractMessage.trim(); + return ( + `This \`${nodeType}\` node's config is refused at \`${key}\` by the ${nodeType} contract: ` + + `${/[.!?]$/.test(reason) ? reason : `${reason}.`} Its executor parses the config against that contract ` + + 'before it does anything else and refuses the node on any finding, so every run that reached this node ' + + 'would fail there; the config is metadata, and re-running changes nothing.' + + (prescribe ? ` Write a value the ${nodeType} contract accepts at \`${key}\`.` : '') + ); +} + +/** The keys an `unrecognized_keys` issue names (Zod carries them beside its `path`). */ +function unrecognizedKeysOf(issue: { readonly code: string }): readonly string[] { + if (issue.code !== 'unrecognized_keys') return []; + const keys = (issue as { readonly keys?: unknown }).keys; + return Array.isArray(keys) ? keys.filter((key): key is string => typeof key === 'string') : []; +} + /** * Every reason a node's `config` is refused on SHAPE or PRESENCE, and (#21654) * a write node's static TARGET in the stored-metadata family — the ONE judge @@ -276,6 +350,21 @@ function nodeConfigKeyMissingMessage(nodeType: string, key: string): string { * `FlowSchema.parse` of a pre-conversion spelling meets the refusal, exactly * as it meets every other tombstone. * + * ## A declared plugin contract — judged whole (#21850) + * + * For a type in {@link getDeclaredPluginNodeConfigContracts} (`approval`) the + * same parse is made, and EVERY issue is kept, because that executor refuses + * the node on every issue: the two codes above for a key left out, and + * + * - a key the contract does not declare, or a value it refuses, where the + * author wrote it → `node-config-refused-by-contract`, anchored at the key + * (`escalation.bogusKey`, one refusal per unknown key; `escalation. + * timeoutHours` for `0.5` under its `min(1)`), whose message names the node + * type and the key around the contract's own sentence — for an unknown key, + * its did-you-mean (`timeout` → `timeoutHours`). + * + * The builtin arm stays presence-only: nothing here widens what it judges. + * * ## The decision branch shape * * `decision` is parsed by nothing at run time — its executor reads @@ -322,8 +411,12 @@ export function flowNodeConfigRefusals(nodeType: string, config: unknown): FlowN } const writeTarget = storedMetadataWriteTargetRefusal(nodeType, config); if (writeTarget) out.push(writeTarget); - const contract = getBuiltinNodeConfigContracts().get(nodeType); + const builtin = getBuiltinNodeConfigContracts().get(nodeType); + // [#21850] A declared plugin contract is judged WHOLE: see the docblock. + const declared = builtin ? undefined : getDeclaredPluginNodeConfigContracts().get(nodeType); + const contract = builtin ?? declared; if (!contract) return out; + const whole = declared !== undefined; const authored = config ?? {}; if (!isRecord(authored)) return out; if (contract.parsedWhen && !contract.parsedWhen(authored)) return out; @@ -331,13 +424,42 @@ 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) { + // 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. + for (const unknownKey of unrecognizedKeysOf(issue)) { + const key = ledgerPathOf([...issue.path, unknownKey]); + if (seen.has(key)) continue; + seen.add(key); + out.push({ + code: 'node-config-refused-by-contract', + params: { nodeType, key }, + message: nodeConfigRefusedByContractMessage(nodeType, key, issue.message, false), + source: '', + path: key, + }); + } + if (issue.code === 'unrecognized_keys') continue; + } if (issue.path.length === 0) continue; if (insideRegion(nodeType, issue.path)) continue; if (insideValueSlot(nodeType, issue.path)) continue; - if (!absentAt(authored, issue.path)) continue; + const absent = absentAt(authored, issue.path); + if (!absent && !whole) continue; const key = ledgerPathOf(issue.path); if (seen.has(key)) continue; seen.add(key); + if (!absent) { + out.push({ + code: 'node-config-refused-by-contract', + params: { nodeType, key }, + message: nodeConfigRefusedByContractMessage(nodeType, key, issue.message, issue.code !== 'custom'), + source: '', + path: key, + }); + continue; + } out.push( issue.code === 'custom' ? { code: 'node-config-key-required-by-rule', params: { nodeType, key }, message: issue.message, source: '', path: key } diff --git a/packages/spec/src/automation/flow-node-expression-paths.ts b/packages/spec/src/automation/flow-node-expression-paths.ts index d99d4e740ef..f905af35b0f 100644 --- a/packages/spec/src/automation/flow-node-expression-paths.ts +++ b/packages/spec/src/automation/flow-node-expression-paths.ts @@ -555,6 +555,13 @@ export interface FlowSlotRefusalParams { readonly nodeType: 'create_record' | 'update_record' | 'delete_record'; 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. + */ + 'node-config-refused-by-contract': { readonly nodeType: string; readonly key: string }; } /** Every refusal code the three flow slot refusal producers emit. */ @@ -573,7 +580,8 @@ export type FlowNodeConfigRefusalCode = | 'decision-branch-label-missing' | 'node-config-key-missing' | 'node-config-key-required-by-rule' - | 'write-node-stored-metadata-target'; + | 'write-node-stored-metadata-target' + | 'node-config-refused-by-contract'; /** One refusal's `code` and `params`, correlated: narrowing on `code` narrows `params`. */ type FlowSlotRefusalOf = { message: string; source: string } & { @@ -618,6 +626,7 @@ const FLOW_SLOT_REFUSAL_CODE_TABLE = { 'node-config-key-missing': true, 'node-config-key-required-by-rule': true, 'write-node-stored-metadata-target': true, + 'node-config-refused-by-contract': true, } as const satisfies Record; /** diff --git a/packages/spec/src/automation/flow-region-pause-and-end.test.ts b/packages/spec/src/automation/flow-region-pause-and-end.test.ts index 287845b80d0..38aed536003 100644 --- a/packages/spec/src/automation/flow-region-pause-and-end.test.ts +++ b/packages/spec/src/automation/flow-region-pause-and-end.test.ts @@ -50,6 +50,9 @@ const pausingNode = (type: string, id = 'pauser'): FlowNode => ({ ...(type === 'wait' ? { waitEventConfig: { eventType: 'timer' as const, timerDuration: 'PT1H' } } : {}), ...(type === 'map' ? { config: { collection: '{items}', flowName: 'per_item' } } : {}), ...(type === 'subflow' ? { config: { flowName: 'child' } } : {}), + // The approval node's declared contract is judged whole at parse, so its + // fixture carries the one key that contract requires. + ...(type === 'approval' ? { config: { approvers: [{ type: 'user', value: 'u1' }] } } : {}), }); const step = (id: string): FlowNode => ({ id, type: 'assignment', label: id }); diff --git a/packages/spec/src/automation/flow-slot-refusal-codes.test.ts b/packages/spec/src/automation/flow-slot-refusal-codes.test.ts index b71e9be826a..f132347f58f 100644 --- a/packages/spec/src/automation/flow-slot-refusal-codes.test.ts +++ b/packages/spec/src/automation/flow-slot-refusal-codes.test.ts @@ -39,6 +39,7 @@ import { import * as automation from './index.js'; import { flowNodeConfigRefusals } from './flow-node-config-refusals.js'; import { NotifyConfigSchema } from './io-node-config.zod.js'; +import { ApprovalNodeConfigSchema } from './approval.zod.js'; import { STORED_METADATA_BODY_PRESCRIPTION } from '../kernel/stored-metadata-body-objects.js'; /** What one refusal says, whichever producer said it. */ @@ -98,6 +99,27 @@ const FAMILY_WRITE = (nodeType: string, verb: string, objectName: string): strin + 'metadata, and a flow may not write it directly: every run that reaches the node refuses it before anything is ' + `written, and re-running changes nothing. ${STORED_METADATA_BODY_PRESCRIPTION}`; +/** + * [#21850] A key the approval node's declared contract does not declare, or a + * value it refuses — the contract's own sentence inside the refusal's, with + * the closing instruction only for a plain value finding. + */ +const REFUSED_BY_CONTRACT = (nodeType: string, key: string, sentence: string, prescribe: boolean): string => + `This \`${nodeType}\` node's config is refused at \`${key}\` by the ${nodeType} contract: ` + + `${/[.!?]$/.test(sentence) ? sentence : `${sentence}.`} Its executor parses the config against that contract ` + + 'before it does anything else and refuses the node on any finding, so every run that reached this node would ' + + 'fail there; the config is metadata, and re-running changes nothing.' + + (prescribe ? ` Write a value the ${nodeType} contract accepts at \`${key}\`.` : ''); + +/** An approval approver slate the contract accepts — every approval pin carries it. */ +const APPROVERS = [{ type: 'user', value: 'u1' }]; + +/** The approval contract's own words for one config at one issue path — read, never re-spelled. */ +const approvalSentence = (config: unknown, path: string): string => { + const own = ApprovalNodeConfigSchema.safeParse(config); + return own.success ? '' : own.error.issues.find((i) => i.path.join('.') === path)?.message ?? ''; +}; + /** The notify contract's own words for a node with no content source — read, never re-spelled. */ const NOTIFY_TITLE_RULE = (() => { const own = NotifyConfigSchema.safeParse({ recipients: ['u1'] }); @@ -320,6 +342,41 @@ const PINS: { readonly [C in FlowSlotRefusalCode]: readonly [Pin, ...Pin[] source: '', }, ], + 'node-config-refused-by-contract': [ + { + produce: nodeConfig('approval', { approvers: APPROVERS, escalation: { timeoutHours: 2, bogusKey: 1 } }), + params: { nodeType: 'approval', key: 'escalation.bogusKey' }, + message: REFUSED_BY_CONTRACT( + 'approval', + 'escalation.bogusKey', + approvalSentence({ approvers: APPROVERS, escalation: { timeoutHours: 2, bogusKey: 1 } }, 'escalation'), + false, + ), + source: '', + }, + { + produce: nodeConfig('approval', { approvers: APPROVERS, escalation: { timeoutHours: 0.5 } }), + params: { nodeType: 'approval', key: 'escalation.timeoutHours' }, + message: REFUSED_BY_CONTRACT( + 'approval', + 'escalation.timeoutHours', + approvalSentence({ approvers: APPROVERS, escalation: { timeoutHours: 0.5 } }, 'escalation.timeoutHours'), + true, + ), + source: '', + }, + { + produce: nodeConfig('approval', { approvers: APPROVERS, onEmptyApprovers: 'fail', fallbackApprovers: APPROVERS }), + params: { nodeType: 'approval', key: 'onEmptyApprovers' }, + message: REFUSED_BY_CONTRACT( + 'approval', + 'onEmptyApprovers', + approvalSentence({ approvers: APPROVERS, onEmptyApprovers: 'fail', fallbackApprovers: APPROVERS }, 'onEmptyApprovers'), + false, + ), + source: '', + }, + ], }; describe('flow slot refusal codes — one pin per code (code, params, unchanged message)', () => { @@ -341,6 +398,14 @@ describe('flow slot refusal codes — one pin per code (code, params, unchanged expect(NOTIFY_TITLE_RULE.length).toBeGreaterThan(40); }); + it('the refused-by-contract pins read real sentences off the approval contract, not empty ones', () => { + expect(approvalSentence({ approvers: APPROVERS, escalation: { timeoutHours: 2, bogusKey: 1 } }, 'escalation')).toContain('`bogusKey`'); + expect(approvalSentence({ approvers: APPROVERS, escalation: { timeoutHours: 0.5 } }, 'escalation.timeoutHours').length).toBeGreaterThan(10); + expect( + approvalSentence({ approvers: APPROVERS, onEmptyApprovers: 'fail', fallbackApprovers: APPROVERS }, 'onEmptyApprovers'), + ).toContain('fallbackApprovers'); + }); + it('the structural lead sentence is the published constant, byte for byte', () => { expect(STRUCTURAL_CONDITION_SHAPE_REFUSAL).toBe(STRUCTURAL_LEAD); }); @@ -390,6 +455,7 @@ const NODE_CONFIG_CODES: ReadonlySet = new Set = [ ...SWEEP.map((value) => ['create_record', { objectName: value }] as const), ['update_record', { objectName: 'sys_metadata_history' }], ['delete_record', { objectName: 'sys_metadata' }], + ['approval', undefined], + ['approval', {}], + ['approval', { approvers: APPROVERS, notAKey: 1 }], + ...SWEEP.map((value) => ['approval', { approvers: APPROVERS, escalation: value }] as const), + ...SWEEP.map((value) => ['approval', { approvers: APPROVERS, escalation: { timeoutHours: value } }] as const), ]; describe('flow slot refusal codes — the closed set', () => { diff --git a/packages/spec/src/automation/flow.zod.ts b/packages/spec/src/automation/flow.zod.ts index 523bba91071..17df6773920 100644 --- a/packages/spec/src/automation/flow.zod.ts +++ b/packages/spec/src/automation/flow.zod.ts @@ -1474,15 +1474,19 @@ export const FlowSchema = lazySchema(() => strictObject( // node against at run time, so a flow carrying one used to register and // then fail every run that reached the node (`loop` with a `body` and no // `collection`, `map` with no `collection`, a CRUD node with no - // `objectName`, …). Only ABSENCE is judged: a present value of the wrong - // type, or an undeclared key, stays where it is judged today; + // `objectName`, …). For a builtin only ABSENCE is judged: a present value + // of the wrong type, or an undeclared key, stays where it is judged today. + // The one plugin node contract the spec declares, `approval` (#21850), is + // judged WHOLE — its executor refuses the node on any contract finding — + // so its undeclared keys and refused values are refused here too; // - a `decision` branch list the executor cannot read — `conditions` not an // array, a branch that is not an object, and a branch whose `label` is // absent, blank or not text. The last one never failed a run at all: the // matched branch reported no label, and traversal took EVERY out-edge. // - // A PRESENCE rule, never a key-set closure: the node `config` stays the open - // record the header of this module describes. Walked with + // A PRESENCE rule for a builtin, never a key-set closure: the node `config` + // stays the open record the header of this module describes, and only the + // approval node's declared contract closes its key set. Walked with // `collectFlowGraphs`, so a node inside an ADR-0031 region body is judged at // the path the author wrote; a container's own judgement skips its regions' // insides, which this same walk reaches as graphs of their own. diff --git a/packages/spec/src/migrations/entries/semantic/18.flow-approval-node-config-contract-refused.ts b/packages/spec/src/migrations/entries/semantic/18.flow-approval-node-config-contract-refused.ts new file mode 100644 index 00000000000..bab3832ba87 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.flow-approval-node-config-contract-refused.ts @@ -0,0 +1,70 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #21850 — the D3 entry for the build doors judging an `approval` node's config +// against the contract the spec declares for it (`ApprovalNodeConfigSchema`), +// whole: the declared contract map in `flow-node-config-refusals.ts`, read by +// the one judge `flowNodeConfigRefusals`. 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 the approvers, the key +// or the value the author meant, 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-approval-node-config-contract-refused', + surface: + 'an approval flow node whose config the approval node contract (ApprovalNodeConfigSchema) refuses — ' + + 'a key it does not declare (escalation.bogusKey, a top-level key such as steps or onApprove, an ' + + 'alias such as escalation.timeout), a value it refuses (escalation.timeoutHours below 1, an ' + + 'unknown behavior or escalation.action, an empty approvers list, a fallbackApprovers list under any ' + + 'policy but fallback), or a key it requires left out (approvers; escalation.timeoutHours inside an ' + + 'escalation block). 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 shape the approval contract declares, written on the node\'s `config`: `approvers` with at least ' + + 'one approver, and inside an `escalation` block a `timeoutHours` of at least 1 (wall-clock hours; ' + + '`timeoutHours: 1` is the shortest SLA the contract accepts). An undeclared key is renamed to the key ' + + 'the refusal\'s did-you-mean names (`timeout` → `timeoutHours`, `mode` → `behavior`, `quorum` → ' + + '`minApprovals`) or deleted; a process-level key (`steps`, `entryCriteria`, `onApprove`, `onReject`, ' + + '`rejectionBehavior`) moves onto the flow graph as the refusal\'s guidance says. To turn an SLA off, ' + + 'delete the whole `escalation` block — an `escalation: { enabled: false }` with no `timeoutHours` ' + + 'is refused like any block missing it', + reason: + 'An approval node\'s executor (`plugin-approvals`) parses `node.config` against ' + + '`ApprovalNodeConfigSchema` before it does anything else and fails the node on ANY issue. ' + + 'Registration already refused an undeclared key, against the descriptor\'s published `configSchema`, ' + + 'but a refused value (`timeoutHours: 0.5`) registered and then failed every run that reached the node ' + + '— the config is metadata, and no rerun could succeed. The build doors asked about neither: ' + + '`FlowSchema.parse` judged only the builtin node types\' ' + + 'executor contracts, and only for a key left out, so `objectstack validate` and `objectstack compile` ' + + 'exited 0 on an `escalation.bogusKey` or a `timeoutHours: 0.5` and compile copied it into the ' + + 'artifact. The contract is the spec\'s own, so the build can judge it with no plugin loaded: the ' + + 'approval node joins a declared contract map beside the builtin executor contracts, read by the one ' + + 'judge `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first) and ' + + '`objectstack validate` share (`flowNodeConfigRefusals`), and is judged WHOLE — every issue the ' + + 'contract raises is refused, because the executor refuses on every one. An undeclared key or a ' + + 'refused value is `node-config-refused-by-contract`, anchored at the key, in the contract\'s own ' + + 'sentence (its did-you-mean included); a key left out keeps `node-config-key-missing` or ' + + '`node-config-key-required-by-rule`. The builtin arm is unchanged and stays presence-only. ' + + 'A plugin node type whose contract the spec does not declare stays outside the build doors, as ' + + 'before. ⚠️ No D2 conversion: the platform cannot know the approvers, the key or the value the ' + + 'author meant, and no value it could write would keep what the flow did. ⚠️ 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-0019.', + 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.escalation.bogusKey`, `nodes.N.config.escalation.' + + 'timeoutHours`, `nodes.N.config.approvers`), `objectstack validate` prints the same path, and ' + + '`validateStackExpressions` phrases it as `node \'gate\' (approval) config.escalation.bogusKey`. ' + + 'For each hit write what the contract accepts, 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`. An approval node the contract accepts parses and ' + + 'registers byte-identically to before.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index a346ed77329..95f60ec4b62 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5579,6 +5579,22 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + '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.', }, + { + id: 'flow-approval-node-config-contract-refused', + order: 84, + text: + '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`.', + }, { id: 'flow-decision-edge-branching-first-match', order: 45, @@ -13057,6 +13073,72 @@ const step18: MigrationStep = { + 'the media-column move (the column step of `objectstack migrate files-to-references ' + '--apply`).', }, + // #21850 — the D3 entry for the build doors judging an `approval` node's config + // against the contract the spec declares for it (`ApprovalNodeConfigSchema`), + // whole: the declared contract map in `flow-node-config-refusals.ts`, read by + // the one judge `flowNodeConfigRefusals`. 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 the approvers, the key + // or the value the author meant, 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-approval-node-config-contract-refused', + surface: + 'an approval flow node whose config the approval node contract (ApprovalNodeConfigSchema) refuses — ' + + 'a key it does not declare (escalation.bogusKey, a top-level key such as steps or onApprove, an ' + + 'alias such as escalation.timeout), a value it refuses (escalation.timeoutHours below 1, an ' + + 'unknown behavior or escalation.action, an empty approvers list, a fallbackApprovers list under any ' + + 'policy but fallback), or a key it requires left out (approvers; escalation.timeoutHours inside an ' + + 'escalation block). 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 shape the approval contract declares, written on the node\'s `config`: `approvers` with at least ' + + 'one approver, and inside an `escalation` block a `timeoutHours` of at least 1 (wall-clock hours; ' + + '`timeoutHours: 1` is the shortest SLA the contract accepts). An undeclared key is renamed to the key ' + + 'the refusal\'s did-you-mean names (`timeout` → `timeoutHours`, `mode` → `behavior`, `quorum` → ' + + '`minApprovals`) or deleted; a process-level key (`steps`, `entryCriteria`, `onApprove`, `onReject`, ' + + '`rejectionBehavior`) moves onto the flow graph as the refusal\'s guidance says. To turn an SLA off, ' + + 'delete the whole `escalation` block — an `escalation: { enabled: false }` with no `timeoutHours` ' + + 'is refused like any block missing it', + reason: + 'An approval node\'s executor (`plugin-approvals`) parses `node.config` against ' + + '`ApprovalNodeConfigSchema` before it does anything else and fails the node on ANY issue. ' + + 'Registration already refused an undeclared key, against the descriptor\'s published `configSchema`, ' + + 'but a refused value (`timeoutHours: 0.5`) registered and then failed every run that reached the node ' + + '— the config is metadata, and no rerun could succeed. The build doors asked about neither: ' + + '`FlowSchema.parse` judged only the builtin node types\' ' + + 'executor contracts, and only for a key left out, so `objectstack validate` and `objectstack compile` ' + + 'exited 0 on an `escalation.bogusKey` or a `timeoutHours: 0.5` and compile copied it into the ' + + 'artifact. The contract is the spec\'s own, so the build can judge it with no plugin loaded: the ' + + 'approval node joins a declared contract map beside the builtin executor contracts, read by the one ' + + 'judge `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first) and ' + + '`objectstack validate` share (`flowNodeConfigRefusals`), and is judged WHOLE — every issue the ' + + 'contract raises is refused, because the executor refuses on every one. An undeclared key or a ' + + 'refused value is `node-config-refused-by-contract`, anchored at the key, in the contract\'s own ' + + 'sentence (its did-you-mean included); a key left out keeps `node-config-key-missing` or ' + + '`node-config-key-required-by-rule`. The builtin arm is unchanged and stays presence-only. ' + + 'A plugin node type whose contract the spec does not declare stays outside the build doors, as ' + + 'before. ⚠️ No D2 conversion: the platform cannot know the approvers, the key or the value the ' + + 'author meant, and no value it could write would keep what the flow did. ⚠️ 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-0019.', + 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.escalation.bogusKey`, `nodes.N.config.escalation.' + + 'timeoutHours`, `nodes.N.config.approvers`), `objectstack validate` prints the same path, and ' + + '`validateStackExpressions` phrases it as `node \'gate\' (approval) config.escalation.bogusKey`. ' + + 'For each hit write what the contract accepts, 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`. An approval node the contract accepts parses and ' + + 'registers byte-identically to before.', + }, // The absent half of the decision-branch predicate rule. A SEPARATE entry from // `flow-predicate-slot-blank-string-refused` on purpose: that one keeps the // run a blank predicate made (it evaluated `false`, so `'false'` runs the same