|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#21850] The build doors judge an `approval` node's `config` against the |
| 5 | + * contract the spec declares for it, `ApprovalNodeConfigSchema`, WHOLE — the |
| 6 | + * declared contract map in `flow-node-config-refusals.ts`, beside the builtin |
| 7 | + * executor contracts and read by the same judge, `flowNodeConfigRefusals`. |
| 8 | + * |
| 9 | + * The approval executor (`plugin-approvals`) parses `node.config ?? {}` |
| 10 | + * against that contract before it does anything else and fails the node on |
| 11 | + * ANY issue, so every contract finding is one the run would refuse: a key the |
| 12 | + * contract requires left out, a key it does not declare, a value it refuses. |
| 13 | + * `FlowSchema` used to accept all three, so `objectstack validate` and |
| 14 | + * `objectstack compile` exited 0 on them and compile copied the shape into the |
| 15 | + * artifact; the author learned otherwise at the first run. |
| 16 | + * |
| 17 | + * Every door that parses a flow meets the judge: `FlowSchema` itself, |
| 18 | + * `defineStack`, the stack parse `objectstack validate` and `compile` run, the |
| 19 | + * registered `flow` type schema the metadata save door validates against, and |
| 20 | + * an artifact's parse — pinned here. `registerFlow` parses first, and |
| 21 | + * `validateStackExpressions` calls the same judge. |
| 22 | + * |
| 23 | + * No plugin is loaded for any of it: the contract is the spec's own. The |
| 24 | + * builtin arm is unchanged, presence-only — a control below holds it there. |
| 25 | + */ |
| 26 | + |
| 27 | +import { describe, expect, it } from 'vitest'; |
| 28 | + |
| 29 | +import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; |
| 30 | +import { MIGRATIONS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; |
| 31 | +import { ArtifactStagePackageBodySchema, ObjectStackDefinitionSchema, defineStack } from '../stack.zod'; |
| 32 | +import { ApprovalEscalationSchema, ApprovalNodeConfigSchema } from './approval.zod'; |
| 33 | +import { flowNodeConfigRefusals, getBuiltinNodeConfigContracts } from './flow-node-config-refusals'; |
| 34 | +import { FlowSchema } from './flow.zod'; |
| 35 | + |
| 36 | +const ENTRY_ID = 'flow-approval-node-config-contract-refused'; |
| 37 | + |
| 38 | +type Config = Record<string, unknown>; |
| 39 | + |
| 40 | +const APPROVERS = [{ type: 'position', value: 'finance_reviewer' }]; |
| 41 | + |
| 42 | +/** A whole approval config the contract accepts — the accept control, and the base every probe edits. */ |
| 43 | +const VALID: Config = { |
| 44 | + approvers: APPROVERS, |
| 45 | + behavior: 'first_response', |
| 46 | + lockRecord: true, |
| 47 | + escalation: { enabled: true, timeoutHours: 4, action: 'notify', notifySubmitter: true }, |
| 48 | +}; |
| 49 | + |
| 50 | +const withEscalation = (escalation: Config): Config => ({ ...VALID, escalation }); |
| 51 | + |
| 52 | +/** start → the approval node → approve / reject ends. */ |
| 53 | +function flowWith(config: unknown, name = 'approval_probe') { |
| 54 | + return { |
| 55 | + name, |
| 56 | + label: 'Approval probe', |
| 57 | + type: 'autolaunched', |
| 58 | + nodes: [ |
| 59 | + { id: 'start', type: 'start', label: 'Start' }, |
| 60 | + { id: 'gate', type: 'approval', label: 'Gate', ...(config === undefined ? {} : { config }) }, |
| 61 | + { id: 'approved', type: 'end', label: 'Approved' }, |
| 62 | + { id: 'rejected', type: 'end', label: 'Rejected' }, |
| 63 | + ], |
| 64 | + edges: [ |
| 65 | + { id: 'e1', source: 'start', target: 'gate' }, |
| 66 | + { id: 'e2', source: 'gate', target: 'approved', label: 'approve' }, |
| 67 | + { id: 'e3', source: 'gate', target: 'rejected', label: 'reject' }, |
| 68 | + ], |
| 69 | + }; |
| 70 | +} |
| 71 | + |
| 72 | +interface IssueSig { code: string; path: string; message: string } |
| 73 | + |
| 74 | +function issuesOf(flow: unknown): IssueSig[] { |
| 75 | + const r = FlowSchema.safeParse(flow); |
| 76 | + return r.success ? [] : r.error.issues.map((i) => ({ code: i.code, path: i.path.join('.'), message: i.message })); |
| 77 | +} |
| 78 | + |
| 79 | +/** The approval contract's own sentence at one issue path — read, never re-spelled. */ |
| 80 | +function contractSentence(schema: { safeParse(v: unknown): { success: boolean; error?: { issues: Array<{ path: PropertyKey[]; message: string }> } } }, value: unknown, path: string): string { |
| 81 | + const own = schema.safeParse(value); |
| 82 | + return own.success ? '' : own.error?.issues.find((i) => i.path.join('.') === path)?.message ?? ''; |
| 83 | +} |
| 84 | + |
| 85 | +describe('FlowSchema judges an approval node config against its declared contract, whole', () => { |
| 86 | + it('an undeclared escalation key is refused at nodes.1.config.escalation.bogusKey', () => { |
| 87 | + const config = withEscalation({ timeoutHours: 2, action: 'notify', bogusKey: 1 }); |
| 88 | + const issues = issuesOf(flowWith(config)); |
| 89 | + expect(issues.map(({ code, path }) => ({ code, path }))).toEqual([ |
| 90 | + { code: 'custom', path: 'nodes.1.config.escalation.bogusKey' }, |
| 91 | + ]); |
| 92 | + // The judge's own words, never re-spelled, carrying the contract's own sentence. |
| 93 | + expect(issues[0]!.message).toBe(flowNodeConfigRefusals('approval', config)[0]!.message); |
| 94 | + expect(issues[0]!.message).toContain(contractSentence(ApprovalEscalationSchema, config.escalation, '')); |
| 95 | + }); |
| 96 | + |
| 97 | + it('a timeoutHours under the contract minimum is refused at nodes.1.config.escalation.timeoutHours', () => { |
| 98 | + const issues = issuesOf(flowWith(withEscalation({ timeoutHours: 0.5, action: 'notify' }))); |
| 99 | + expect(issues.map(({ code, path }) => ({ code, path }))).toEqual([ |
| 100 | + { code: 'custom', path: 'nodes.1.config.escalation.timeoutHours' }, |
| 101 | + ]); |
| 102 | + }); |
| 103 | + |
| 104 | + it('the judge answers both with its code, params and path', () => { |
| 105 | + const bogus = flowNodeConfigRefusals('approval', withEscalation({ timeoutHours: 2, bogusKey: 1 })); |
| 106 | + const halfHour = flowNodeConfigRefusals('approval', withEscalation({ timeoutHours: 0.5 })); |
| 107 | + expect([...bogus, ...halfHour].map(({ code, params, path, source }) => ({ code, params, path, source }))).toEqual([ |
| 108 | + { code: 'node-config-refused-by-contract', params: { nodeType: 'approval', key: 'escalation.bogusKey' }, path: 'escalation.bogusKey', source: '' }, |
| 109 | + { code: 'node-config-refused-by-contract', params: { nodeType: 'approval', key: 'escalation.timeoutHours' }, path: 'escalation.timeoutHours', source: '' }, |
| 110 | + ]); |
| 111 | + }); |
| 112 | + |
| 113 | + it('an alias keeps the contract\'s own did-you-mean, beside the key it leaves out', () => { |
| 114 | + const config = withEscalation({ timeout: 2 }); |
| 115 | + const issues = issuesOf(flowWith(config)); |
| 116 | + expect(issues.map(({ path }) => path).sort()).toEqual([ |
| 117 | + 'nodes.1.config.escalation.timeout', |
| 118 | + 'nodes.1.config.escalation.timeoutHours', |
| 119 | + ]); |
| 120 | + const aliasSentence = contractSentence(ApprovalEscalationSchema, config.escalation, ''); |
| 121 | + expect(aliasSentence).toContain('`timeoutHours`'); |
| 122 | + expect(issues.find((i) => i.path.endsWith('.timeout'))!.message).toContain(aliasSentence); |
| 123 | + }); |
| 124 | + |
| 125 | + it('an undeclared top-level key is refused at the key, one refusal per key', () => { |
| 126 | + const issues = issuesOf(flowWith({ ...VALID, steps: [], quorum: 2 })); |
| 127 | + expect(issues.map(({ path }) => path)).toEqual(['nodes.1.config.steps', 'nodes.1.config.quorum']); |
| 128 | + }); |
| 129 | + |
| 130 | + it('a key the contract requires, left out, is refused with the builtin arm\'s code', () => { |
| 131 | + for (const config of [undefined, {}]) { |
| 132 | + expect(issuesOf(flowWith(config)).map(({ path }) => path), JSON.stringify(config)).toEqual(['nodes.1.config.approvers']); |
| 133 | + expect(flowNodeConfigRefusals('approval', config).map(({ code }) => code)).toEqual(['node-config-key-missing']); |
| 134 | + } |
| 135 | + }); |
| 136 | + |
| 137 | + it('a value a rule of the contract refuses is refused in the rule\'s own words', () => { |
| 138 | + const config = { ...VALID, onEmptyApprovers: 'fail', fallbackApprovers: APPROVERS }; |
| 139 | + const [refusal] = flowNodeConfigRefusals('approval', config); |
| 140 | + expect(refusal!.path).toBe('onEmptyApprovers'); |
| 141 | + expect(refusal!.message).toContain(contractSentence(ApprovalNodeConfigSchema, config, 'onEmptyApprovers')); |
| 142 | + }); |
| 143 | + |
| 144 | + it('the judge refuses exactly what the contract refuses, over a sweep of configs', () => { |
| 145 | + const sweep: unknown[] = [ |
| 146 | + undefined, {}, VALID, |
| 147 | + { approvers: [] }, |
| 148 | + { approvers: 'u1' }, |
| 149 | + { approvers: [{ type: 'user', value: 'u1' }] }, |
| 150 | + { ...VALID, behavior: 'weighted' }, |
| 151 | + { ...VALID, minApprovals: 0 }, |
| 152 | + { ...VALID, maxRevisions: 1.5 }, |
| 153 | + { ...VALID, decisionOutputs: ['note', { key: 'picked', type: 'user', multiple: true }] }, |
| 154 | + withEscalation({ timeoutHours: 1 }), |
| 155 | + withEscalation({ enabled: false }), |
| 156 | + withEscalation({ timeoutHours: 2, action: 'escalate' }), |
| 157 | + withEscalation({ timeoutHours: '2' }), |
| 158 | + ]; |
| 159 | + for (const config of sweep) { |
| 160 | + const refused = flowNodeConfigRefusals('approval', config).length > 0; |
| 161 | + expect(refused, JSON.stringify(config)).toBe(!ApprovalNodeConfigSchema.safeParse(config ?? {}).success); |
| 162 | + } |
| 163 | + }); |
| 164 | +}); |
| 165 | + |
| 166 | +describe('what stays accepted (lit controls)', () => { |
| 167 | + it('CONTROL: a valid approval node parses', () => { |
| 168 | + expect(issuesOf(flowWith(VALID))).toEqual([]); |
| 169 | + expect(flowNodeConfigRefusals('approval', VALID)).toEqual([]); |
| 170 | + }); |
| 171 | + |
| 172 | + it('CONTROL: an escalation at the contract minimum, and a node with no escalation block, parse', () => { |
| 173 | + expect(issuesOf(flowWith(withEscalation({ timeoutHours: 1 })))).toEqual([]); |
| 174 | + expect(issuesOf(flowWith({ approvers: APPROVERS }))).toEqual([]); |
| 175 | + }); |
| 176 | + |
| 177 | + it('CONTROL: the builtin arm stays presence-only — an undeclared key on a builtin node still parses', () => { |
| 178 | + const flow = { |
| 179 | + ...flowWith(VALID), |
| 180 | + nodes: [ |
| 181 | + { id: 'start', type: 'start', label: 'Start' }, |
| 182 | + { id: 'call', type: 'http', label: 'Call', config: { url: 'https://example.test', bogusKey: 1 } }, |
| 183 | + { id: 'done', type: 'end', label: 'Done' }, |
| 184 | + ], |
| 185 | + edges: [{ id: 'e1', source: 'start', target: 'call' }, { id: 'e2', source: 'call', target: 'done' }], |
| 186 | + }; |
| 187 | + expect(issuesOf(flow)).toEqual([]); |
| 188 | + // …and the builtin map, the executor-reconciled one, did not gain the plugin type. |
| 189 | + expect(getBuiltinNodeConfigContracts().has('approval')).toBe(false); |
| 190 | + }); |
| 191 | + |
| 192 | + it('CONTROL: approval_revise carries no config contract and is not judged', () => { |
| 193 | + expect(flowNodeConfigRefusals('approval_revise', { anything: 1 })).toEqual([]); |
| 194 | + }); |
| 195 | +}); |
| 196 | + |
| 197 | +describe('every door that parses a flow refuses it', () => { |
| 198 | + const stackWith = (flows: unknown[]) => ({ |
| 199 | + manifest: { id: 'com.example.approvals', name: 'approvals', version: '1.0.0', type: 'app', namespace: 'apv' }, |
| 200 | + objects: [{ name: 'apv_request', label: 'Request', fields: { title: { type: 'text', label: 'Title' } } }], |
| 201 | + flows, |
| 202 | + }); |
| 203 | + const refused = flowWith(withEscalation({ timeoutHours: 2, bogusKey: 1 }), 'apv_refused'); |
| 204 | + const accepted = flowWith(VALID, 'apv_ok'); |
| 205 | + |
| 206 | + it('defineStack wraps the refusal in its ADR-0112 envelope, at flows.N.nodes.1.config.escalation.bogusKey', () => { |
| 207 | + let refusal: { code?: unknown; status?: unknown; issues?: Array<{ path: unknown[]; code: string }> } | undefined; |
| 208 | + try { |
| 209 | + defineStack(stackWith([accepted, refused]) as never); |
| 210 | + } catch (e) { |
| 211 | + refusal = e as typeof refusal; |
| 212 | + } |
| 213 | + expect(refusal, 'defineStack must refuse the approval flow').toBeDefined(); |
| 214 | + expect({ code: refusal!.code, status: refusal!.status }).toEqual({ code: 'STACK_SCHEMA_INVALID', status: 422 }); |
| 215 | + expect(refusal!.issues!.map((i) => ({ path: i.path.join('.'), code: i.code }))).toEqual([ |
| 216 | + { path: 'flows.1.nodes.1.config.escalation.bogusKey', code: 'custom' }, |
| 217 | + ]); |
| 218 | + }); |
| 219 | + |
| 220 | + it('CONTROL: defineStack accepts the valid approval flow alone', () => { |
| 221 | + expect(() => defineStack(stackWith([accepted]) as never)).not.toThrow(); |
| 222 | + }); |
| 223 | + |
| 224 | + it('ObjectStackDefinitionSchema — the stack parse validate and compile run — refuses it at the same path', () => { |
| 225 | + const r = ObjectStackDefinitionSchema.safeParse(stackWith([flowWith(withEscalation({ timeoutHours: 0.5 }), 'apv_half')])); |
| 226 | + expect(r.success).toBe(false); |
| 227 | + expect(r.success ? [] : r.error.issues.map((i) => i.path.join('.'))).toEqual(['flows.0.nodes.1.config.escalation.timeoutHours']); |
| 228 | + expect(ObjectStackDefinitionSchema.safeParse(stackWith([accepted])).success).toBe(true); |
| 229 | + }); |
| 230 | + |
| 231 | + it('the registered `flow` type schema — what the metadata save door validates against — refuses it too', () => { |
| 232 | + const schema = getMetadataTypeSchema('flow') as unknown as typeof FlowSchema; |
| 233 | + expect(schema).toBeDefined(); |
| 234 | + const r = schema.safeParse(refused); |
| 235 | + expect(r.success).toBe(false); |
| 236 | + expect(r.success ? [] : r.error.issues.map((i) => i.path.join('.'))).toEqual(['nodes.1.config.escalation.bogusKey']); |
| 237 | + expect(schema.safeParse(accepted).success).toBe(true); |
| 238 | + }); |
| 239 | + |
| 240 | + it('an artifact\'s parse refuses it', () => { |
| 241 | + const body = { id: 'com.example.approvals', name: 'approvals', version: '1.0.0', type: 'app' }; |
| 242 | + const r = ArtifactStagePackageBodySchema.safeParse({ ...body, flows: [refused] }); |
| 243 | + expect(r.success).toBe(false); |
| 244 | + expect(r.success ? [] : r.error.issues.map((i) => i.path.join('.'))).toEqual(['flows.0.nodes.1.config.escalation.bogusKey']); |
| 245 | + const ok = ArtifactStagePackageBodySchema.safeParse({ ...body, flows: [accepted] }); |
| 246 | + expect(ok.success, JSON.stringify(ok.error?.issues ?? [])).toBe(true); |
| 247 | + }); |
| 248 | +}); |
| 249 | + |
| 250 | +describe('the ADR-0087 ledger', () => { |
| 251 | + it('registers one D3 entry at protocol 18, with no D2 conversion', () => { |
| 252 | + const entries = MIGRATIONS_BY_MAJOR[18]!.semantic.filter((e) => e.id === ENTRY_ID); |
| 253 | + expect(entries, 'the narrowing needs its own D3 entry').toHaveLength(1); |
| 254 | + const [entry] = entries; |
| 255 | + expect(entry!.conversionIds ?? []).toEqual([]); |
| 256 | + expect(entry!.acceptanceCriteria.length).toBeGreaterThan(0); |
| 257 | + }); |
| 258 | + |
| 259 | + it('registers no tombstone: no approval config key is removed', () => { |
| 260 | + const all = Object.values(RETIRED_KEYS_BY_MAJOR).flat(); |
| 261 | + expect(all.filter((k) => /Approval(NodeConfig|Escalation)[^:]*:/.test(k))).toEqual([]); |
| 262 | + // CONTROL: the flattened table is the real one — it carries a known step-18 tombstone. |
| 263 | + expect(all).toContain('api/RestApiEndpoint:timeout'); |
| 264 | + }); |
| 265 | +}); |
0 commit comments