From 910ad7fa8501465ab60e06b962b094b1d4465ffa Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 14:27:30 +0000 Subject: [PATCH 1/5] wip(spec): judge the approval node's declared config contract whole at the build doors Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude --- .../automation/flow-node-config-refusals.ts | 130 +++++++++++++++++- .../automation/flow-node-expression-paths.ts | 11 +- packages/spec/src/automation/flow.zod.ts | 12 +- 3 files changed, 144 insertions(+), 9 deletions(-) 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.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. From ab132a21099a382e9fcb18e13200d7b2dab18858 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 14:35:02 +0000 Subject: [PATCH 2/5] wip(spec): pins, the D3 entry and its step-18 rationale fragment Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude --- ...flow-approval-node-config-contract.test.ts | 265 ++++++++++++++++++ .../flow-region-pause-and-end.test.ts | 3 + .../flow-slot-refusal-codes.test.ts | 71 +++++ ...w-approval-node-config-contract-refused.ts | 70 +++++ packages/spec/src/migrations/registry.ts | 82 ++++++ 5 files changed, 491 insertions(+) create mode 100644 packages/spec/src/automation/flow-approval-node-config-contract.test.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.flow-approval-node-config-contract-refused.ts 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-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/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 From 7cfdb16f2306c7d3c22d1214cbca6eb336a6353c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 14:38:56 +0000 Subject: [PATCH 3/5] wip: the BREAKING changeset with its ADR-0087 disposition Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude --- ...w-approval-node-config-contract-refused.md | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .changeset/21850-flow-approval-node-config-contract-refused.md 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. From 3fc48ab07f1a11886f07d0c8a8187c51bfd92961 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 15:01:23 +0000 Subject: [PATCH 4/5] test: drop an undeclared approval config key from three clean-flow fixtures The publish-gate fixtures' "clean" approval flow carried emptyApproverPolicy, a key the approval contract never declared; the build doors now judge that contract whole, so the fixture was never clean. Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude --- packages/lint/src/runtime-gate.test.ts | 1 - .../src/protocol.runtime-authoring-gate.test.ts | 1 - packages/objectql/src/plugin.authoring-channel.test.ts | 1 - 3 files changed, 3 deletions(-) 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, From 76118d27fe2ed2592da573f73a23c186dc14e063 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 15:47:26 +0000 Subject: [PATCH 5/5] test(service-automation): the plugin-node-type fixture's approval node carries the approvers its contract requires registerFlow parses FlowSchema first, and the build doors now judge an approval node's config against its declared contract, so the vocabulary-seal fixture's contract-less approval node is refused before the test reaches what it measures. Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude --- packages/services/service-automation/src/engine.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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: [