From 47addb85d055d42a52a0ed90fcf70c361bdccea6 Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Mon, 7 Sep 2026 12:10:14 +0200 Subject: [PATCH 01/25] feat(types): add DecisionContract and lift it onto BaseNode Hand-written contract types for a gate node: declarable effects tuple, DecisionAction discriminated on effect, DecisionDeadline, DecisionContract. BaseNode gains an optional decision field; presence marks a gate. WB-500 --- .../workflow-execution/decision-contract.ts | 89 +++++++++++++++++++ .../src/workflow-execution/execution-model.ts | 4 + 2 files changed, 93 insertions(+) create mode 100644 packages/types/src/workflow-execution/decision-contract.ts diff --git a/packages/types/src/workflow-execution/decision-contract.ts b/packages/types/src/workflow-execution/decision-contract.ts new file mode 100644 index 000000000..a210f72da --- /dev/null +++ b/packages/types/src/workflow-execution/decision-contract.ts @@ -0,0 +1,89 @@ +/** + * Effects a gate may declare on its actions. `resume-with-edits` is deliberately absent: + * it is never declared; the backend derives it when a `resume` call carries edits. + */ +export const DECLARABLE_DECISION_EFFECTS = ['resume', 'reject', 'rerun-source'] as const; + +/** One of {@link DECLARABLE_DECISION_EFFECTS}. */ +export type DeclarableDecisionEffect = (typeof DECLARABLE_DECISION_EFFECTS)[number]; + +/** Every effect a decision can take, including the derived `resume-with-edits`. */ +export type DecisionEffect = DeclarableDecisionEffect | 'resume-with-edits'; + +type DecisionActionBase = { + /** + * What a submitted decision names. Unique within the gate. Any string: the client's + * vocabulary, not an engine keyword. + */ + name: string; + /** Text shown to the decider. */ + label: string; +}; + +/** Accepts the proposal, edited or not: the run continues on `port`. Exactly one per gate. */ +export type ResumeDecisionAction = DecisionActionBase & { + effect: 'resume'; + /** Output handle the run continues on. Defaults to `approved`. Never `errorRoute`. */ + port: string; +}; + +/** Rejects the proposal: the run continues on `port`. At most one per gate. */ +export type RejectDecisionAction = DecisionActionBase & { + effect: 'reject'; + /** Output handle the run continues on. Defaults to `rejected`. Must differ from the resume port. */ + port: string; + /** Whether the decider must give a reason. Defaults to `false`. */ + reasonRequired: boolean; +}; + +/** Re-runs the proposal source with the decider's comment. At most one per gate. */ +export type RerunSourceDecisionAction = DecisionActionBase & { + effect: 'rerun-source'; + /** Upper bound on re-runs of the proposal source. Integer of at least 1. Defaults to `3`. */ + maxIterations: number; +}; + +/** + * One action the decider can take, discriminated on `effect`. `port`, `reasonRequired` and + * `maxIterations` may be omitted in authored JSON; the backend parser materialises their + * defaults, so a parsed contract always carries them. + */ +export type DecisionAction = ResumeDecisionAction | RejectDecisionAction | RerunSourceDecisionAction; + +/** Time limit on a parked gate. */ +export type DecisionDeadline = { + /** + * Counted from the moment the gate parks. A number followed by `ms`, `s`, `m`, `h` or `d`, + * such as `'30s'` or `'3d'`. + */ + after: string; + /** What happens when `after` elapses. Typed open for future policies; only `'reject'` is accepted today. */ + policy: string; +}; + +/** + * The human decision a node asks for before the run continues. Authored under + * `data.properties.decision` in the editor snapshot and lifted to `BaseNode.decision`. + * Any node type may carry one; a node that does is a gate. Unknown keys at every level + * are preserved. + */ +export type DecisionContract = { + /** Shape version of the contract. A future shape change bumps it. */ + version: 1; + /** Actions offered to the decider: exactly one `resume`, at most one `reject`, at most one `rerun-source`. */ + actions: DecisionAction[]; + /** + * JSON Schema of the decision form. `readOnly: true` marks a field the decider cannot + * edit; `x-pii: true` marks personal data. Opaque to the engine. + */ + schema: Record; + /** JsonForms UI schema for the decision form. Passed through; never read by the backend. */ + uiSchema?: Record; + /** + * The proposal source: the node whose output the decider judges. Must be a direct + * predecessor of the gate; absent means the gate's only predecessor. + */ + proposalSourceNodeId?: string; + /** Absent means the gate waits forever. */ + deadline?: DecisionDeadline; +}; diff --git a/packages/types/src/workflow-execution/execution-model.ts b/packages/types/src/workflow-execution/execution-model.ts index ac5f38363..3c923d4a4 100644 --- a/packages/types/src/workflow-execution/execution-model.ts +++ b/packages/types/src/workflow-execution/execution-model.ts @@ -1,3 +1,5 @@ +import type { DecisionContract } from './decision-contract'; + // Runner-level decision applied when a node throws. // `fail` aborts the whole execution (default); `continue` absorbs the error into // `nodeOutputs[id] = { error }` and propagates downstream; `errorRoute` does the @@ -32,6 +34,8 @@ export type BaseNode = { // without knowing any product's vocabulary. label?: string; errorPolicy?: NodeErrorPolicy; + /** Present on a gate: the decision a human takes before the run continues. Nothing detects a gate by `type`. */ + decision?: DecisionContract; role?: NodeRole; }; From 7afe929d01108bb54832d12044109213e68b303c Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Mon, 7 Sep 2026 13:03:31 +0200 Subject: [PATCH 02/25] feat(backend): zod schema for the gate decision contract Loose objects at every level so unknown keys survive. Actions are a discriminated union on effect with parse-time defaults for port, reasonRequired and maxIterations. Cardinality, unique names, distinct resume/reject ports, required-subset-of-properties and the deadline duration format are checked in the schema; graph rules come next. WB-500 --- .../decision/decision-contract-schema.test.ts | 289 ++++++++++++++++++ .../decision/decision-contract-schema.ts | 147 +++++++++ 2 files changed, 436 insertions(+) create mode 100644 apps/backend/src/domain/decision/decision-contract-schema.test.ts create mode 100644 apps/backend/src/domain/decision/decision-contract-schema.ts diff --git a/apps/backend/src/domain/decision/decision-contract-schema.test.ts b/apps/backend/src/domain/decision/decision-contract-schema.test.ts new file mode 100644 index 000000000..c9f072d2e --- /dev/null +++ b/apps/backend/src/domain/decision/decision-contract-schema.test.ts @@ -0,0 +1,289 @@ +import { describe, expect, expectTypeOf, it } from 'vitest'; +import type { z } from 'zod'; + +import { + DECLARABLE_DECISION_EFFECTS, + type DecisionContract, +} from '@workflow-builder/types/workflow-execution/decision-contract'; + +import { decisionContractSchema } from './decision-contract-schema'; + +const approve = { name: 'approve', label: 'Approve', effect: 'resume', port: 'approved' }; +const reject = { name: 'reject', label: 'Reject', effect: 'reject', port: 'rejected', reasonRequired: false }; +const reRequest = { name: 're-request', label: 'Ask again', effect: 'rerun-source', maxIterations: 3 }; + +const refundForm = { + type: 'object', + properties: { + orderDate: { type: 'string', title: 'Order date', readOnly: true }, + customerEmail: { type: 'string', title: 'Customer e-mail', readOnly: true, 'x-pii': true }, + refundAmount: { type: 'number', title: 'Refund amount' }, + emailDraft: { type: 'string', title: 'E-mail draft' }, + }, + required: ['refundAmount'], +}; + +// The refund story from the design workshop. +function workedExample() { + return { + version: 1, + actions: [approve, reject, reRequest], + schema: refundForm, + uiSchema: { type: 'VerticalLayout', elements: [] }, + proposalSourceNodeId: 'source-1', + deadline: { after: '3d', policy: 'reject' }, + }; +} + +function contract(overrides: Record = {}): unknown { + return { ...workedExample(), ...overrides }; +} + +function issuePaths(input: unknown): string[] { + const result = decisionContractSchema.safeParse(input); + return result.success ? [] : result.error.issues.map((issue) => issue.path.join('.')); +} + +describe('decisionContractSchema', () => { + it('accepts the refund worked example', () => { + expect(decisionContractSchema.safeParse(workedExample()).success).toBe(true); + }); + + it('accepts a minimal gate: one resume action and an empty form', () => { + const minimal = { + version: 1, + actions: [{ name: 'ok', label: 'OK', effect: 'resume' }], + schema: { type: 'object', properties: {} }, + }; + + expect(decisionContractSchema.safeParse(minimal).success).toBe(true); + }); + + it.each([...DECLARABLE_DECISION_EFFECTS])('accepts a declared %s action', (effect) => { + const resume = { name: 'ok', label: 'OK', effect: 'resume' }; + const actions = effect === 'resume' ? [resume] : [resume, { name: 'other', label: 'Other', effect }]; + + expect(decisionContractSchema.safeParse(contract({ actions })).success).toBe(true); + }); + + it('materialises the defaults for port, reasonRequired and maxIterations', () => { + const parsed = decisionContractSchema.parse( + contract({ + actions: [ + { name: 'approve', label: 'Approve', effect: 'resume' }, + { name: 'reject', label: 'Reject', effect: 'reject' }, + { name: 're-request', label: 'Ask again', effect: 'rerun-source' }, + ], + }), + ); + + expect(parsed.actions).toEqual([ + { name: 'approve', label: 'Approve', effect: 'resume', port: 'approved' }, + { name: 'reject', label: 'Reject', effect: 'reject', port: 'rejected', reasonRequired: false }, + { name: 're-request', label: 'Ask again', effect: 'rerun-source', maxIterations: 3 }, + ]); + }); + + it('keeps unknown keys at every level', () => { + const input = { + ...workedExample(), + audience: 'finance', + actions: [{ ...approve, icon: 'check' }], + schema: { + ...refundForm, + $schema: 'https://json-schema.org/draft/2020-12/schema', + properties: { + ...refundForm.properties, + customerEmail: { ...refundForm.properties.customerEmail, 'x-mask': 'email' }, + }, + }, + uiSchema: { type: 'VerticalLayout', elements: [{ type: 'Control', scope: '#/properties/refundAmount' }] }, + deadline: { after: '3d', policy: 'reject', warnAfter: '2d' }, + }; + + const parsed = decisionContractSchema.parse(input); + + expect(parsed).toMatchObject({ + audience: 'finance', + actions: [{ icon: 'check' }], + schema: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + properties: { customerEmail: { title: 'Customer e-mail', 'x-mask': 'email' } }, + }, + uiSchema: input.uiSchema, + deadline: { warnAfter: '2d' }, + }); + }); + + it('accepts an explicit readOnly: false', () => { + const schema = { type: 'object', properties: { amount: { type: 'number', readOnly: false } } }; + + const parsed = decisionContractSchema.parse(contract({ schema })); + + expect(parsed.schema.properties['amount']).toEqual({ type: 'number', readOnly: false }); + }); + + it.each(['100ms', '30s', '10m', '1.5h', '24h', '3d', '7d'])('accepts a deadline of %s', (after) => { + expect(decisionContractSchema.safeParse(contract({ deadline: { after, policy: 'reject' } })).success).toBe(true); + }); + + it('accepts a gate without deadline, uiSchema or proposalSourceNodeId', () => { + const { version, actions, schema } = workedExample(); + + expect(decisionContractSchema.safeParse({ version, actions, schema }).success).toBe(true); + }); + + it('names the declarable effects when the effect is unknown', () => { + const result = decisionContractSchema.safeParse(contract({ actions: [{ ...approve, effect: 'escalate' }] })); + + expect(result.success).toBe(false); + expect(result.success ? '' : result.error.issues[0]?.message).toContain(DECLARABLE_DECISION_EFFECTS.join(', ')); + }); + + it.each<{ name: string; input: unknown; path: string }>([ + { name: 'a version other than 1', input: contract({ version: 2 }), path: 'version' }, + { name: 'an empty action list', input: contract({ actions: [] }), path: 'actions' }, + { + name: 'a duplicate action name', + input: contract({ actions: [approve, { ...reject, name: 'approve' }] }), + path: 'actions.1.name', + }, + { + name: 'an effect outside the declarable set', + input: contract({ actions: [{ ...approve, effect: 'escalate' }] }), + path: 'actions.0.effect', + }, + { + name: "a declared 'resume-with-edits'", + input: contract({ actions: [approve, { ...reject, effect: 'resume-with-edits' }] }), + path: 'actions.1.effect', + }, + { name: 'no resume action', input: contract({ actions: [reject] }), path: 'actions' }, + { + name: 'two resume actions', + input: contract({ actions: [approve, { ...approve, name: 'approve-2' }] }), + path: 'actions.1.effect', + }, + { + name: 'two reject actions', + input: contract({ actions: [approve, reject, { ...reject, name: 'decline' }] }), + path: 'actions.2.effect', + }, + { + name: 'two rerun-source actions', + input: contract({ actions: [approve, reRequest, { ...reRequest, name: 'retry' }] }), + path: 'actions.2.effect', + }, + { name: 'an empty action name', input: contract({ actions: [{ ...approve, name: '' }] }), path: 'actions.0.name' }, + { + name: 'an empty action label', + input: contract({ actions: [{ ...approve, label: '' }] }), + path: 'actions.0.label', + }, + { name: 'an empty resume port', input: contract({ actions: [{ ...approve, port: '' }] }), path: 'actions.0.port' }, + { + name: "a resume port of 'errorRoute'", + input: contract({ actions: [{ ...approve, port: 'errorRoute' }] }), + path: 'actions.0.port', + }, + { + name: "a reject port of 'errorRoute'", + input: contract({ actions: [approve, { ...reject, port: 'errorRoute' }] }), + path: 'actions.1.port', + }, + { + name: 'a reject port equal to the resume port', + input: contract({ actions: [approve, { ...reject, port: 'approved' }] }), + path: 'actions.1.port', + }, + { + name: 'a non-boolean reasonRequired', + input: contract({ actions: [approve, { ...reject, reasonRequired: 'yes' }] }), + path: 'actions.1.reasonRequired', + }, + { + name: 'maxIterations below 1', + input: contract({ actions: [approve, { ...reRequest, maxIterations: 0 }] }), + path: 'actions.1.maxIterations', + }, + { + name: 'a fractional maxIterations', + input: contract({ actions: [approve, { ...reRequest, maxIterations: 1.5 }] }), + path: 'actions.1.maxIterations', + }, + { + name: "a form schema whose type is not 'object'", + input: contract({ schema: { ...refundForm, type: 'array' } }), + path: 'schema.type', + }, + { + name: 'a form schema without properties', + input: contract({ schema: { type: 'object' } }), + path: 'schema.properties', + }, + { + name: 'a form property without a type', + input: contract({ schema: { type: 'object', properties: { refundAmount: { title: 'Refund amount' } } } }), + path: 'schema.properties.refundAmount.type', + }, + { + name: 'a non-boolean readOnly', + input: contract({ schema: { type: 'object', properties: { orderDate: { type: 'string', readOnly: 'true' } } } }), + path: 'schema.properties.orderDate.readOnly', + }, + { + name: 'a non-boolean x-pii', + input: contract({ schema: { type: 'object', properties: { email: { type: 'string', 'x-pii': 'yes' } } } }), + path: 'schema.properties.email.x-pii', + }, + { + name: 'a required field that is not declared', + input: contract({ schema: { ...refundForm, required: ['discount'] } }), + path: 'schema.required.0', + }, + { + name: 'a required field that exists only on Object.prototype', + input: contract({ schema: { ...refundForm, required: ['constructor'] } }), + path: 'schema.required.0', + }, + { + name: 'a deadline without a unit', + input: contract({ deadline: { after: '3', policy: 'reject' } }), + path: 'deadline.after', + }, + { + name: 'a deadline of zero', + input: contract({ deadline: { after: '0s', policy: 'reject' } }), + path: 'deadline.after', + }, + { + name: 'a negative deadline', + input: contract({ deadline: { after: '-5m', policy: 'reject' } }), + path: 'deadline.after', + }, + { + name: 'a deadline beyond the protobuf Duration range', + input: contract({ deadline: { after: '3652501d', policy: 'reject' } }), + path: 'deadline.after', + }, + { name: 'a deadline without a policy', input: contract({ deadline: { after: '3d' } }), path: 'deadline.policy' }, + { + name: "a deadline policy other than 'reject'", + input: contract({ deadline: { after: '3d', policy: 'escalate' } }), + path: 'deadline.policy', + }, + { name: 'a uiSchema that is not an object', input: contract({ uiSchema: 'vertical' }), path: 'uiSchema' }, + { + name: 'a non-string proposalSourceNodeId', + input: contract({ proposalSourceNodeId: 42 }), + path: 'proposalSourceNodeId', + }, + ])('rejects $name', ({ input, path }) => { + expect(decisionContractSchema.safeParse(input).success).toBe(false); + expect(issuePaths(input)).toContain(path); + }); + + it('parses into a value assignable to DecisionContract', () => { + expectTypeOf>().toMatchTypeOf(); + }); +}); diff --git a/apps/backend/src/domain/decision/decision-contract-schema.ts b/apps/backend/src/domain/decision/decision-contract-schema.ts new file mode 100644 index 000000000..457878888 --- /dev/null +++ b/apps/backend/src/domain/decision/decision-contract-schema.ts @@ -0,0 +1,147 @@ +import { z } from 'zod'; + +import { DECLARABLE_DECISION_EFFECTS } from '@workflow-builder/types/workflow-execution/decision-contract'; + +// Mirrors DURATION_PATTERN and the protobuf Duration range in +// packages/temporal/src/workflow/profile-validation.ts (follow-up: shared-duration-format) +const DURATION_PATTERN = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/; +const UNIT_MS = { ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 } as const; +const MIN_DURATION_MS = 0.000_001; +const MAX_DURATION_MS = 315_576_000_000 * UNIT_MS.s; + +function isDurationString(value: string): boolean { + const match = DURATION_PATTERN.exec(value); + if (match === null) return false; + const milliseconds = Number.parseFloat(match[1]) * UNIT_MS[match[2] as keyof typeof UNIT_MS]; + return milliseconds >= MIN_DURATION_MS && milliseconds <= MAX_DURATION_MS; +} + +const durationSchema = z + .string() + .refine( + isDurationString, + "must be a positive duration such as '30s', '24h' or '3d' (a number followed by ms, s, m, h or d)", + ); + +// 'errorRoute' is the handle the runner reserves for the error policy. +const portSchema = z + .string() + .min(1, 'port must not be empty') + .refine((port) => port !== 'errorRoute', "port must not be the reserved 'errorRoute'"); + +const actionBase = { + name: z.string().min(1, 'name must not be empty'), + label: z.string().min(1, 'label must not be empty'), +}; + +const resumeActionSchema = z.looseObject({ + ...actionBase, + effect: z.literal('resume'), + port: portSchema.default('approved'), +}); + +const rejectActionSchema = z.looseObject({ + ...actionBase, + effect: z.literal('reject'), + port: portSchema.default('rejected'), + reasonRequired: z.boolean().default(false), +}); + +const rerunSourceActionSchema = z.looseObject({ + ...actionBase, + effect: z.literal('rerun-source'), + maxIterations: z.int().min(1).default(3), +}); + +const decisionActionSchema = z.discriminatedUnion( + 'effect', + [resumeActionSchema, rejectActionSchema, rerunSourceActionSchema], + { + error: (issue) => + issue.code === 'invalid_union' ? `effect must be one of ${DECLARABLE_DECISION_EFFECTS.join(', ')}` : undefined, + }, +); + +const formPropertySchema = z.looseObject({ + type: z.string(), + readOnly: z.boolean().optional(), + 'x-pii': z.boolean().optional(), +}); + +// Shape only. Validating values against the schema needs a JSON Schema validator the +// backend does not have yet (follow-up: decision-edit-value-validation) +const formSchema = z + .looseObject({ + type: z.literal('object'), + properties: z.record(z.string(), formPropertySchema), + required: z.array(z.string()).optional(), + }) + .superRefine((form, context) => { + for (const [index, name] of (form.required ?? []).entries()) { + if (!Object.hasOwn(form.properties, name)) { + context.addIssue({ + code: 'custom', + message: `required field '${name}' is not declared in properties`, + path: ['required', index], + }); + } + } + }); + +const deadlineSchema = z.looseObject({ + after: durationSchema, + policy: z.string().refine((policy) => policy === 'reject', "policy must be 'reject'"), +}); + +export const decisionContractSchema = z + .looseObject({ + version: z.literal(1), + actions: z.array(decisionActionSchema).min(1, 'at least one action is required'), + schema: formSchema, + uiSchema: z.record(z.string(), z.unknown()).optional(), + proposalSourceNodeId: z.string().optional(), + deadline: deadlineSchema.optional(), + }) + .superRefine((contract, context) => { + const seenNames = new Set(); + const firstIndexByEffect = new Map(); + + for (const [index, action] of contract.actions.entries()) { + if (seenNames.has(action.name)) { + context.addIssue({ + code: 'custom', + message: `action name '${action.name}' is used more than once`, + path: ['actions', index, 'name'], + }); + } + seenNames.add(action.name); + + if (firstIndexByEffect.has(action.effect)) { + context.addIssue({ + code: 'custom', + message: `only one action may have effect '${action.effect}'`, + path: ['actions', index, 'effect'], + }); + } else { + firstIndexByEffect.set(action.effect, index); + } + } + + const resumeIndex = firstIndexByEffect.get('resume'); + if (resumeIndex === undefined) { + context.addIssue({ code: 'custom', message: "an action with effect 'resume' is required", path: ['actions'] }); + return; + } + + const rejectIndex = firstIndexByEffect.get('reject'); + if (rejectIndex === undefined) return; + const resume = contract.actions[resumeIndex]; + const reject = contract.actions[rejectIndex]; + if (resume.effect === 'resume' && reject.effect === 'reject' && resume.port === reject.port) { + context.addIssue({ + code: 'custom', + message: `reject port '${reject.port}' must differ from the resume port`, + path: ['actions', rejectIndex, 'port'], + }); + } + }); From 2bff5b5efe961e48a0dc2eb2eded0b89076d2c91 Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Mon, 7 Sep 2026 14:25:27 +0200 Subject: [PATCH 03/25] feat(backend): validate gate contracts and proposal sources in the snapshot properties becomes a loose object with the reserved decision key, so a gate contract is parsed in place and everything else passes through. A superRefine on the snapshot checks the graph rules: an explicit proposalSourceNodeId must be a direct predecessor, and a gate declaring rerun-source needs a resolvable source that is not itself a gate. resolveProposalSource is a pure function over the execution-model shape, shared with the pending-decision resource and the rerun loop later. WB-500 --- .../domain/decision/proposal-source.test.ts | 67 ++++++ .../src/domain/decision/proposal-source.ts | 40 ++++ .../src/domain/mapper/snapshot-schema.test.ts | 192 ++++++++++++++++++ .../src/domain/mapper/snapshot-schema.ts | 68 ++++++- 4 files changed, 359 insertions(+), 8 deletions(-) create mode 100644 apps/backend/src/domain/decision/proposal-source.test.ts create mode 100644 apps/backend/src/domain/decision/proposal-source.ts diff --git a/apps/backend/src/domain/decision/proposal-source.test.ts b/apps/backend/src/domain/decision/proposal-source.test.ts new file mode 100644 index 000000000..2749b664c --- /dev/null +++ b/apps/backend/src/domain/decision/proposal-source.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; + +import type { DecisionContract } from '@workflow-builder/types/workflow-execution/decision-contract'; + +import { resolveProposalSource } from './proposal-source'; + +function contract(proposalSourceNodeId?: string): DecisionContract { + return { + version: 1, + actions: [{ name: 'approve', label: 'Approve', effect: 'resume', port: 'approved' }], + schema: { type: 'object', properties: {} }, + ...(proposalSourceNodeId === undefined ? {} : { proposalSourceNodeId }), + }; +} + +function edge(sourceNodeId: string, targetNodeId: string) { + return { sourceNodeId, targetNodeId }; +} + +describe('resolveProposalSource', () => { + it('reports a node without a contract, or an unknown id, as not a gate', () => { + const nodes = [{ id: 'plain' }]; + + expect(resolveProposalSource(nodes, [], 'plain')).toEqual({ error: 'not_a_gate' }); + expect(resolveProposalSource(nodes, [], 'missing')).toEqual({ error: 'not_a_gate' }); + }); + + it('returns an explicit source that is a direct predecessor', () => { + const nodes = [{ id: 'a' }, { id: 'b' }, { id: 'gate', decision: contract('a') }]; + const edges = [edge('a', 'gate'), edge('b', 'gate')]; + + expect(resolveProposalSource(nodes, edges, 'gate')).toEqual({ sourceNodeId: 'a' }); + }); + + it('rejects an explicit source that is not a direct predecessor, including a successor', () => { + const nodes = [{ id: 'a' }, { id: 'after' }, { id: 'gate', decision: contract('after') }]; + const edges = [edge('a', 'gate'), edge('gate', 'after')]; + + expect(resolveProposalSource(nodes, edges, 'gate')).toEqual({ error: 'explicit_source_not_a_predecessor' }); + }); + + it('falls back to the only direct predecessor when no source is declared', () => { + const nodes = [{ id: 'a' }, { id: 'gate', decision: contract() }]; + + expect(resolveProposalSource(nodes, [edge('a', 'gate')], 'gate')).toEqual({ sourceNodeId: 'a' }); + }); + + it('counts parallel edges from one node as a single predecessor', () => { + const nodes = [{ id: 'a' }, { id: 'gate', decision: contract() }]; + const edges = [edge('a', 'gate'), edge('a', 'gate')]; + + expect(resolveProposalSource(nodes, edges, 'gate')).toEqual({ sourceNodeId: 'a' }); + }); + + it('reports no predecessor when the gate has only outgoing edges', () => { + const nodes = [{ id: 'gate', decision: contract() }, { id: 'after' }]; + + expect(resolveProposalSource(nodes, [edge('gate', 'after')], 'gate')).toEqual({ error: 'no_predecessor' }); + }); + + it('reports ambiguity when several predecessors exist and none is declared', () => { + const nodes = [{ id: 'a' }, { id: 'b' }, { id: 'gate', decision: contract() }]; + const edges = [edge('a', 'gate'), edge('b', 'gate')]; + + expect(resolveProposalSource(nodes, edges, 'gate')).toEqual({ error: 'ambiguous_predecessor' }); + }); +}); diff --git a/apps/backend/src/domain/decision/proposal-source.ts b/apps/backend/src/domain/decision/proposal-source.ts new file mode 100644 index 000000000..33785bcd2 --- /dev/null +++ b/apps/backend/src/domain/decision/proposal-source.ts @@ -0,0 +1,40 @@ +import { unique } from 'remeda'; + +import type { BaseNode, WorkflowEdgeDefinition } from '@workflow-builder/types/workflow-execution/execution-model'; + +type GraphNode = Pick; +type GraphEdge = Pick; + +export type UnresolvedSourceReason = + | 'not_a_gate' + | 'explicit_source_not_a_predecessor' + | 'no_predecessor' + | 'ambiguous_predecessor'; + +// `error?: undefined` on the success member lets a caller narrow with a plain +// `if (resolution.error !== undefined)` while both-set and neither-set stay unrepresentable. +export type ProposalSourceResolution = + | { sourceNodeId: string; error?: undefined } + | { sourceNodeId?: undefined; error: UnresolvedSourceReason }; + +// Takes the execution-model shape so a caller holding a WorkflowDefinition passes its +// nodes and edges straight in; the snapshot schema adapts before calling. +export function resolveProposalSource( + nodes: readonly GraphNode[], + edges: readonly GraphEdge[], + gateId: string, +): ProposalSourceResolution { + const gate = nodes.find((node) => node.id === gateId); + if (gate?.decision === undefined) return { error: 'not_a_gate' }; + + const predecessors = unique(edges.filter((edge) => edge.targetNodeId === gateId).map((edge) => edge.sourceNodeId)); + const explicit = gate.decision.proposalSourceNodeId; + if (explicit !== undefined) { + return predecessors.includes(explicit) + ? { sourceNodeId: explicit } + : { error: 'explicit_source_not_a_predecessor' }; + } + if (predecessors.length === 0) return { error: 'no_predecessor' }; + if (predecessors.length > 1) return { error: 'ambiguous_predecessor' }; + return { sourceNodeId: predecessors[0] }; +} diff --git a/apps/backend/src/domain/mapper/snapshot-schema.test.ts b/apps/backend/src/domain/mapper/snapshot-schema.test.ts index e0841f3c5..8d23e07e5 100644 --- a/apps/backend/src/domain/mapper/snapshot-schema.test.ts +++ b/apps/backend/src/domain/mapper/snapshot-schema.test.ts @@ -115,6 +115,198 @@ describe('workflowSnapshotSchema', () => { }); }); +function node(id: string, properties?: Record) { + return { id, data: { type: 'product/any', properties } }; +} + +function edge(source: string, target: string, sourceHandle?: string) { + return { id: `${source}->${target}${sourceHandle ?? ''}`, source, target, sourceHandle }; +} + +function issuePaths(snapshot: unknown): string[] { + const result = workflowSnapshotSchema.safeParse(snapshot); + return result.success ? [] : result.error.issues.map((issue) => issue.path.join('.')); +} + +describe('workflowSnapshotSchema: gate contracts', () => { + const approve = { name: 'approve', label: 'Approve', effect: 'resume' }; + const reRequest = { name: 're-request', label: 'Ask again', effect: 'rerun-source' }; + const emptyForm = { type: 'object', properties: {} }; + + function gate(id: string, decision: Record) { + return { + id, + data: { type: 'product/any', properties: { decision: { version: 1, schema: emptyForm, ...decision } } }, + }; + } + + it('parses a gate and materialises the contract defaults inside properties', () => { + const parsed = workflowSnapshotSchema.parse({ + nodes: [node('src'), gate('gate', { actions: [approve, reRequest] })], + edges: [edge('src', 'gate')], + }); + + expect(parsed.nodes[1]!.data.properties?.decision?.actions).toEqual([ + { ...approve, port: 'approved' }, + { ...reRequest, maxIterations: 3 }, + ]); + }); + + it('leaves the properties of a node without a contract untouched', () => { + const properties = { label: 'Plain', decisionBranches: [{ x: 1 }], meta: { deep: { nested: true } } }; + + const parsed = workflowSnapshotSchema.parse({ nodes: [node('n1', properties)], edges: [] }); + + expect(parsed.nodes[0]!.data.properties).toEqual(properties); + }); + + it('points a contract issue at the node index and field', () => { + const snapshot = { + nodes: [node('src'), gate('gate', { actions: [approve, { ...approve, name: 'approve-2' }] })], + edges: [edge('src', 'gate')], + }; + + expect(issuePaths(snapshot)).toContain('nodes.1.data.properties.decision.actions.1.effect'); + }); + + it('rejects `decision: null`; absent is the only way to not be a gate', () => { + const snapshot = { nodes: [node('n1', { decision: null })], edges: [] }; + + expect(issuePaths(snapshot)).toContain('nodes.0.data.properties.decision'); + }); + + it.each<{ name: string; snapshot: unknown }>([ + { + name: 'an explicit source that is a direct predecessor', + snapshot: { + nodes: [node('a'), node('b'), gate('gate', { actions: [approve], proposalSourceNodeId: 'a' })], + edges: [edge('a', 'gate'), edge('b', 'gate')], + }, + }, + { + name: 'a rerun-source gate with exactly one predecessor and no explicit source', + snapshot: { nodes: [node('a'), gate('gate', { actions: [approve, reRequest] })], edges: [edge('a', 'gate')] }, + }, + { + name: 'a rerun-source gate with several predecessors when the explicit source picks one', + snapshot: { + nodes: [node('a'), node('b'), gate('gate', { actions: [approve, reRequest], proposalSourceNodeId: 'b' })], + edges: [edge('a', 'gate'), edge('b', 'gate')], + }, + }, + { + name: 'a rerun-source gate whose single predecessor connects through two handles', + snapshot: { + nodes: [node('a'), gate('gate', { actions: [approve, reRequest] })], + edges: [edge('a', 'gate', 'left'), edge('a', 'gate', 'right')], + }, + }, + { + name: 'a gate without rerun-source and with several predecessors and no explicit source', + snapshot: { + nodes: [node('a'), node('b'), gate('gate', { actions: [approve] })], + edges: [edge('a', 'gate'), edge('b', 'gate')], + }, + }, + { + name: 'a gate without rerun-source whose explicit source is another gate', + snapshot: { + nodes: [ + gate('first', { actions: [approve] }), + gate('second', { actions: [approve], proposalSourceNodeId: 'first' }), + ], + edges: [edge('first', 'second')], + }, + }, + { + name: 'two independent gates in one snapshot', + snapshot: { + nodes: [ + node('a'), + gate('g1', { actions: [approve, reRequest] }), + node('b'), + gate('g2', { actions: [approve, reRequest] }), + ], + edges: [edge('a', 'g1'), edge('g1', 'b'), edge('b', 'g2')], + }, + }, + ])('accepts $name', ({ snapshot }) => { + expect(workflowSnapshotSchema.safeParse(snapshot).success).toBe(true); + }); + + it.each<{ name: string; snapshot: unknown; path: string }>([ + { + name: 'an explicit source with no edge into the gate', + snapshot: { + nodes: [node('a'), node('b'), gate('gate', { actions: [approve], proposalSourceNodeId: 'b' })], + edges: [edge('a', 'gate')], + }, + path: 'nodes.2.data.properties.decision.proposalSourceNodeId', + }, + { + name: 'an explicit source that is a successor, not a predecessor', + snapshot: { + nodes: [node('a'), gate('gate', { actions: [approve], proposalSourceNodeId: 'after' }), node('after')], + edges: [edge('a', 'gate'), edge('gate', 'after')], + }, + path: 'nodes.1.data.properties.decision.proposalSourceNodeId', + }, + { + name: 'a rerun-source gate with no predecessor', + snapshot: { + nodes: [gate('gate', { actions: [approve, reRequest] }), node('after')], + edges: [edge('gate', 'after')], + }, + path: 'nodes.0.data.properties.decision.proposalSourceNodeId', + }, + { + name: 'a rerun-source gate with several predecessors and no explicit source', + snapshot: { + nodes: [node('a'), node('b'), gate('gate', { actions: [approve, reRequest] })], + edges: [edge('a', 'gate'), edge('b', 'gate')], + }, + path: 'nodes.2.data.properties.decision.proposalSourceNodeId', + }, + { + name: 'a rerun-source gate whose implicit source is itself a gate', + snapshot: { + nodes: [node('a'), gate('first', { actions: [approve] }), gate('second', { actions: [approve, reRequest] })], + edges: [edge('a', 'first'), edge('first', 'second')], + }, + path: 'nodes.2.data.properties.decision.proposalSourceNodeId', + }, + { + name: 'a rerun-source gate whose explicit source is itself a gate', + snapshot: { + nodes: [ + node('a'), + gate('first', { actions: [approve] }), + gate('second', { actions: [approve, reRequest], proposalSourceNodeId: 'first' }), + ], + edges: [edge('a', 'first'), edge('a', 'second'), edge('first', 'second')], + }, + path: 'nodes.2.data.properties.decision.proposalSourceNodeId', + }, + { + name: 'only the broken gate when another gate in the snapshot is fine', + snapshot: { + nodes: [ + node('a'), + gate('g1', { actions: [approve, reRequest] }), + gate('g2', { actions: [approve, reRequest] }), + ], + edges: [edge('a', 'g1'), edge('a', 'g2'), edge('g1', 'g2')], + }, + path: 'nodes.2.data.properties.decision.proposalSourceNodeId', + }, + ])('rejects $name', ({ snapshot, path }) => { + const paths = issuePaths(snapshot); + + expect(paths).toContain(path); + expect(paths.filter((candidate) => candidate.endsWith('proposalSourceNodeId'))).toEqual([path]); + }); +}); + describe('mapToExecutionModel', () => { it('copies every property the runner does not lift into `config`', () => { const snapshot = workflowSnapshotSchema.parse({ diff --git a/apps/backend/src/domain/mapper/snapshot-schema.ts b/apps/backend/src/domain/mapper/snapshot-schema.ts index a7acf610b..09565a403 100644 --- a/apps/backend/src/domain/mapper/snapshot-schema.ts +++ b/apps/backend/src/domain/mapper/snapshot-schema.ts @@ -1,11 +1,15 @@ // Validates the workflow snapshot at the HTTP boundary structurally only: // every node has `id` and `data.type`; every edge has `id`, `source`, `target`. -// `data.properties` is opaque here — the backend does not know any product's -// node vocabulary. Per-type validation belongs to whichever worker registers -// executors for that vocabulary; an unknown node type surfaces at runtime as +// `data.properties` is opaque here except for the reserved `decision` key, which +// marks a gate and is validated as a contract. The backend does not know any +// product's node vocabulary; per-type validation belongs to whichever worker +// registers executors for it, and an unknown node type surfaces at runtime as // a `node_failed` event with the missing-executor message. import { z } from 'zod'; +import { decisionContractSchema } from '../decision/decision-contract-schema'; +import { type UnresolvedSourceReason, resolveProposalSource } from '../decision/proposal-source'; + const frontendNodeSchema = z.object({ id: z.string(), data: z.object({ @@ -15,7 +19,7 @@ const frontendNodeSchema = z.object({ // starts from. The editor's node kind (`start-node`, `node`, ...) is a // rendering detail and deliberately not read here. isStartNode: z.boolean().optional(), - properties: z.record(z.string(), z.unknown()).optional(), + properties: z.looseObject({ decision: decisionContractSchema.optional() }).optional(), }), }); @@ -26,9 +30,57 @@ const frontendEdgeSchema = z.object({ sourceHandle: z.string().nullable().optional(), }); -export const workflowSnapshotSchema = z.object({ - nodes: z.array(frontendNodeSchema), - edges: z.array(frontendEdgeSchema), -}); +function describeUnresolvedSource(reason: UnresolvedSourceReason, explicit?: string) { + switch (reason) { + case 'explicit_source_not_a_predecessor': { + return `proposalSourceNodeId '${explicit}' is not a direct predecessor of this node`; + } + case 'no_predecessor': { + return 'a rerun-source action needs a proposal source, but this node has no predecessor'; + } + case 'ambiguous_predecessor': { + return 'a rerun-source action needs one proposal source; several predecessors exist, set proposalSourceNodeId'; + } + case 'not_a_gate': { + return 'this node carries no decision contract'; + } + } +} + +export const workflowSnapshotSchema = z + .object({ + nodes: z.array(frontendNodeSchema), + edges: z.array(frontendEdgeSchema), + }) + .superRefine((snapshot, context) => { + const nodes = snapshot.nodes.map((node) => ({ id: node.id, decision: node.data.properties?.decision })); + const edges = snapshot.edges.map((edge) => ({ sourceNodeId: edge.source, targetNodeId: edge.target })); + + for (const [index, node] of snapshot.nodes.entries()) { + const decision = node.data.properties?.decision; + if (decision === undefined) continue; + const declaresRerun = decision.actions.some((action) => action.effect === 'rerun-source'); + if (decision.proposalSourceNodeId === undefined && !declaresRerun) continue; + + const path = ['nodes', index, 'data', 'properties', 'decision', 'proposalSourceNodeId']; + const resolution = resolveProposalSource(nodes, edges, node.id); + if (resolution.error !== undefined) { + context.addIssue({ + code: 'custom', + message: describeUnresolvedSource(resolution.error, decision.proposalSourceNodeId), + path, + }); + continue; + } + const sourceIsGate = nodes.some((other) => other.id === resolution.sourceNodeId && other.decision !== undefined); + if (declaresRerun && sourceIsGate) { + context.addIssue({ + code: 'custom', + message: `proposal source '${resolution.sourceNodeId}' is itself a gate and cannot be re-run`, + path, + }); + } + } + }); export type WorkflowSnapshot = z.infer; From 99e1c2caf5a66322b854c82b0aad1f6918343925 Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Mon, 7 Sep 2026 15:10:12 +0200 Subject: [PATCH 04/25] refactor(backend): one dictionary for decision-contract issue messages Every domain message the contract and graph validation can produce lives in DECISION_ISSUE_MESSAGES; the schemas and the snapshot refine read it instead of carrying inline strings. Tests assert the message at each path through the same dictionary, which surfaced one row that had been passing on path alone with the wrong rule in mind. WB-500 --- .../decision/decision-contract-schema.test.ts | 75 +++++++++++++++---- .../decision/decision-contract-schema.ts | 51 +++++-------- .../domain/decision/decision-issues.test.ts | 29 +++++++ .../src/domain/decision/decision-issues.ts | 33 ++++++++ .../src/domain/mapper/snapshot-schema.test.ts | 27 +++++-- .../src/domain/mapper/snapshot-schema.ts | 35 +++------ 6 files changed, 167 insertions(+), 83 deletions(-) create mode 100644 apps/backend/src/domain/decision/decision-issues.test.ts create mode 100644 apps/backend/src/domain/decision/decision-issues.ts diff --git a/apps/backend/src/domain/decision/decision-contract-schema.test.ts b/apps/backend/src/domain/decision/decision-contract-schema.test.ts index c9f072d2e..357949d25 100644 --- a/apps/backend/src/domain/decision/decision-contract-schema.test.ts +++ b/apps/backend/src/domain/decision/decision-contract-schema.test.ts @@ -7,6 +7,7 @@ import { } from '@workflow-builder/types/workflow-execution/decision-contract'; import { decisionContractSchema } from './decision-contract-schema'; +import { type DecisionIssueCode, decisionIssueMessage } from './decision-issues'; const approve = { name: 'approve', label: 'Approve', effect: 'resume', port: 'approved' }; const reject = { name: 'reject', label: 'Reject', effect: 'reject', port: 'rejected', reasonRequired: false }; @@ -39,11 +40,15 @@ function contract(overrides: Record = {}): unknown { return { ...workedExample(), ...overrides }; } -function issuePaths(input: unknown): string[] { +function issuesOf(input: unknown): { path: string; message: string }[] { const result = decisionContractSchema.safeParse(input); - return result.success ? [] : result.error.issues.map((issue) => issue.path.join('.')); + return result.success + ? [] + : result.error.issues.map((issue) => ({ path: issue.path.join('.'), message: issue.message })); } +const declarableEffects = DECLARABLE_DECISION_EFFECTS.join(', '); + describe('decisionContractSchema', () => { it('accepts the refund worked example', () => { expect(decisionContractSchema.safeParse(workedExample()).success).toBe(true); @@ -133,68 +138,93 @@ describe('decisionContractSchema', () => { expect(decisionContractSchema.safeParse({ version, actions, schema }).success).toBe(true); }); - it('names the declarable effects when the effect is unknown', () => { - const result = decisionContractSchema.safeParse(contract({ actions: [{ ...approve, effect: 'escalate' }] })); - - expect(result.success).toBe(false); - expect(result.success ? '' : result.error.issues[0]?.message).toContain(DECLARABLE_DECISION_EFFECTS.join(', ')); - }); - - it.each<{ name: string; input: unknown; path: string }>([ + // `issue` names the dictionary entry expected at `path`; rows without one fail on zod's + // own structural check. + it.each<{ name: string; input: unknown; path: string; issue?: { code: DecisionIssueCode; value?: string } }>([ { name: 'a version other than 1', input: contract({ version: 2 }), path: 'version' }, - { name: 'an empty action list', input: contract({ actions: [] }), path: 'actions' }, + { + name: 'an empty action list', + input: contract({ actions: [] }), + path: 'actions', + issue: { code: 'actions_empty' }, + }, { name: 'a duplicate action name', input: contract({ actions: [approve, { ...reject, name: 'approve' }] }), path: 'actions.1.name', + issue: { code: 'duplicate_action_name', value: 'approve' }, }, { name: 'an effect outside the declarable set', input: contract({ actions: [{ ...approve, effect: 'escalate' }] }), path: 'actions.0.effect', + issue: { code: 'unknown_effect', value: declarableEffects }, }, { name: "a declared 'resume-with-edits'", input: contract({ actions: [approve, { ...reject, effect: 'resume-with-edits' }] }), path: 'actions.1.effect', + issue: { code: 'unknown_effect', value: declarableEffects }, + }, + { + name: 'no resume action', + input: contract({ actions: [reject] }), + path: 'actions', + issue: { code: 'resume_required' }, }, - { name: 'no resume action', input: contract({ actions: [reject] }), path: 'actions' }, { name: 'two resume actions', input: contract({ actions: [approve, { ...approve, name: 'approve-2' }] }), path: 'actions.1.effect', + issue: { code: 'duplicate_effect', value: 'resume' }, }, { name: 'two reject actions', input: contract({ actions: [approve, reject, { ...reject, name: 'decline' }] }), path: 'actions.2.effect', + issue: { code: 'duplicate_effect', value: 'reject' }, }, { name: 'two rerun-source actions', input: contract({ actions: [approve, reRequest, { ...reRequest, name: 'retry' }] }), path: 'actions.2.effect', + issue: { code: 'duplicate_effect', value: 'rerun-source' }, + }, + { + name: 'an empty action name', + input: contract({ actions: [{ ...approve, name: '' }] }), + path: 'actions.0.name', + issue: { code: 'name_empty' }, }, - { name: 'an empty action name', input: contract({ actions: [{ ...approve, name: '' }] }), path: 'actions.0.name' }, { name: 'an empty action label', input: contract({ actions: [{ ...approve, label: '' }] }), path: 'actions.0.label', + issue: { code: 'label_empty' }, + }, + { + name: 'an empty resume port', + input: contract({ actions: [{ ...approve, port: '' }] }), + path: 'actions.0.port', + issue: { code: 'port_empty' }, }, - { name: 'an empty resume port', input: contract({ actions: [{ ...approve, port: '' }] }), path: 'actions.0.port' }, { name: "a resume port of 'errorRoute'", input: contract({ actions: [{ ...approve, port: 'errorRoute' }] }), path: 'actions.0.port', + issue: { code: 'port_reserved' }, }, { name: "a reject port of 'errorRoute'", input: contract({ actions: [approve, { ...reject, port: 'errorRoute' }] }), path: 'actions.1.port', + issue: { code: 'port_reserved' }, }, { name: 'a reject port equal to the resume port', input: contract({ actions: [approve, { ...reject, port: 'approved' }] }), path: 'actions.1.port', + issue: { code: 'reject_port_equals_resume_port', value: 'approved' }, }, { name: 'a non-boolean reasonRequired', @@ -240,37 +270,44 @@ describe('decisionContractSchema', () => { name: 'a required field that is not declared', input: contract({ schema: { ...refundForm, required: ['discount'] } }), path: 'schema.required.0', + issue: { code: 'required_field_undeclared', value: 'discount' }, }, { name: 'a required field that exists only on Object.prototype', input: contract({ schema: { ...refundForm, required: ['constructor'] } }), path: 'schema.required.0', + issue: { code: 'required_field_undeclared', value: 'constructor' }, }, { name: 'a deadline without a unit', input: contract({ deadline: { after: '3', policy: 'reject' } }), path: 'deadline.after', + issue: { code: 'deadline_format' }, }, { name: 'a deadline of zero', input: contract({ deadline: { after: '0s', policy: 'reject' } }), path: 'deadline.after', + issue: { code: 'deadline_format' }, }, { name: 'a negative deadline', input: contract({ deadline: { after: '-5m', policy: 'reject' } }), path: 'deadline.after', + issue: { code: 'deadline_format' }, }, { name: 'a deadline beyond the protobuf Duration range', input: contract({ deadline: { after: '3652501d', policy: 'reject' } }), path: 'deadline.after', + issue: { code: 'deadline_format' }, }, { name: 'a deadline without a policy', input: contract({ deadline: { after: '3d' } }), path: 'deadline.policy' }, { name: "a deadline policy other than 'reject'", input: contract({ deadline: { after: '3d', policy: 'escalate' } }), path: 'deadline.policy', + issue: { code: 'deadline_policy' }, }, { name: 'a uiSchema that is not an object', input: contract({ uiSchema: 'vertical' }), path: 'uiSchema' }, { @@ -278,9 +315,15 @@ describe('decisionContractSchema', () => { input: contract({ proposalSourceNodeId: 42 }), path: 'proposalSourceNodeId', }, - ])('rejects $name', ({ input, path }) => { + ])('rejects $name', ({ input, path, issue }) => { + const issues = issuesOf(input); + const atPath = issues.filter((candidate) => candidate.path === path); + expect(decisionContractSchema.safeParse(input).success).toBe(false); - expect(issuePaths(input)).toContain(path); + expect(atPath.length).toBeGreaterThan(0); + if (issue !== undefined) { + expect(atPath.map((candidate) => candidate.message)).toContain(decisionIssueMessage(issue.code, issue.value)); + } }); it('parses into a value assignable to DecisionContract', () => { diff --git a/apps/backend/src/domain/decision/decision-contract-schema.ts b/apps/backend/src/domain/decision/decision-contract-schema.ts index 457878888..b5b28e831 100644 --- a/apps/backend/src/domain/decision/decision-contract-schema.ts +++ b/apps/backend/src/domain/decision/decision-contract-schema.ts @@ -2,6 +2,8 @@ import { z } from 'zod'; import { DECLARABLE_DECISION_EFFECTS } from '@workflow-builder/types/workflow-execution/decision-contract'; +import { decisionIssue, decisionIssueMessage } from './decision-issues'; + // Mirrors DURATION_PATTERN and the protobuf Duration range in // packages/temporal/src/workflow/profile-validation.ts (follow-up: shared-duration-format) const DURATION_PATTERN = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/; @@ -16,22 +18,17 @@ function isDurationString(value: string): boolean { return milliseconds >= MIN_DURATION_MS && milliseconds <= MAX_DURATION_MS; } -const durationSchema = z - .string() - .refine( - isDurationString, - "must be a positive duration such as '30s', '24h' or '3d' (a number followed by ms, s, m, h or d)", - ); +const durationSchema = z.string().refine(isDurationString, decisionIssueMessage('deadline_format')); // 'errorRoute' is the handle the runner reserves for the error policy. const portSchema = z .string() - .min(1, 'port must not be empty') - .refine((port) => port !== 'errorRoute', "port must not be the reserved 'errorRoute'"); + .min(1, decisionIssueMessage('port_empty')) + .refine((port) => port !== 'errorRoute', decisionIssueMessage('port_reserved')); const actionBase = { - name: z.string().min(1, 'name must not be empty'), - label: z.string().min(1, 'label must not be empty'), + name: z.string().min(1, decisionIssueMessage('name_empty')), + label: z.string().min(1, decisionIssueMessage('label_empty')), }; const resumeActionSchema = z.looseObject({ @@ -58,7 +55,9 @@ const decisionActionSchema = z.discriminatedUnion( [resumeActionSchema, rejectActionSchema, rerunSourceActionSchema], { error: (issue) => - issue.code === 'invalid_union' ? `effect must be one of ${DECLARABLE_DECISION_EFFECTS.join(', ')}` : undefined, + issue.code === 'invalid_union' + ? decisionIssueMessage('unknown_effect', DECLARABLE_DECISION_EFFECTS.join(', ')) + : undefined, }, ); @@ -79,24 +78,20 @@ const formSchema = z .superRefine((form, context) => { for (const [index, name] of (form.required ?? []).entries()) { if (!Object.hasOwn(form.properties, name)) { - context.addIssue({ - code: 'custom', - message: `required field '${name}' is not declared in properties`, - path: ['required', index], - }); + context.addIssue(decisionIssue('required_field_undeclared', ['required', index], name)); } } }); const deadlineSchema = z.looseObject({ after: durationSchema, - policy: z.string().refine((policy) => policy === 'reject', "policy must be 'reject'"), + policy: z.string().refine((policy) => policy === 'reject', decisionIssueMessage('deadline_policy')), }); export const decisionContractSchema = z .looseObject({ version: z.literal(1), - actions: z.array(decisionActionSchema).min(1, 'at least one action is required'), + actions: z.array(decisionActionSchema).min(1, decisionIssueMessage('actions_empty')), schema: formSchema, uiSchema: z.record(z.string(), z.unknown()).optional(), proposalSourceNodeId: z.string().optional(), @@ -108,20 +103,12 @@ export const decisionContractSchema = z for (const [index, action] of contract.actions.entries()) { if (seenNames.has(action.name)) { - context.addIssue({ - code: 'custom', - message: `action name '${action.name}' is used more than once`, - path: ['actions', index, 'name'], - }); + context.addIssue(decisionIssue('duplicate_action_name', ['actions', index, 'name'], action.name)); } seenNames.add(action.name); if (firstIndexByEffect.has(action.effect)) { - context.addIssue({ - code: 'custom', - message: `only one action may have effect '${action.effect}'`, - path: ['actions', index, 'effect'], - }); + context.addIssue(decisionIssue('duplicate_effect', ['actions', index, 'effect'], action.effect)); } else { firstIndexByEffect.set(action.effect, index); } @@ -129,7 +116,7 @@ export const decisionContractSchema = z const resumeIndex = firstIndexByEffect.get('resume'); if (resumeIndex === undefined) { - context.addIssue({ code: 'custom', message: "an action with effect 'resume' is required", path: ['actions'] }); + context.addIssue(decisionIssue('resume_required', ['actions'])); return; } @@ -138,10 +125,6 @@ export const decisionContractSchema = z const resume = contract.actions[resumeIndex]; const reject = contract.actions[rejectIndex]; if (resume.effect === 'resume' && reject.effect === 'reject' && resume.port === reject.port) { - context.addIssue({ - code: 'custom', - message: `reject port '${reject.port}' must differ from the resume port`, - path: ['actions', rejectIndex, 'port'], - }); + context.addIssue(decisionIssue('reject_port_equals_resume_port', ['actions', rejectIndex, 'port'], reject.port)); } }); diff --git a/apps/backend/src/domain/decision/decision-issues.test.ts b/apps/backend/src/domain/decision/decision-issues.test.ts new file mode 100644 index 000000000..d421f66b4 --- /dev/null +++ b/apps/backend/src/domain/decision/decision-issues.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; + +import { DECISION_ISSUE_MESSAGES, decisionIssue, decisionIssueMessage } from './decision-issues'; + +describe('decisionIssueMessage', () => { + it('fills the placeholder', () => { + expect(decisionIssueMessage('duplicate_action_name', 'approve')).toBe( + "action name 'approve' is used more than once", + ); + }); + + it('keeps replacement patterns in the value verbatim', () => { + expect(decisionIssueMessage('source_is_a_gate', '$&-$1')).toBe( + "proposal source '$&-$1' is itself a gate and cannot be re-run", + ); + }); + + it('ignores a value for a message without a placeholder', () => { + expect(decisionIssueMessage('resume_required', 'ignored')).toBe(DECISION_ISSUE_MESSAGES.resume_required); + }); + + it('builds the issue shape a superRefine adds', () => { + expect(decisionIssue('port_empty', ['actions', 0, 'port'])).toEqual({ + code: 'custom', + message: 'port must not be empty', + path: ['actions', 0, 'port'], + }); + }); +}); diff --git a/apps/backend/src/domain/decision/decision-issues.ts b/apps/backend/src/domain/decision/decision-issues.ts new file mode 100644 index 000000000..62fa5e0f1 --- /dev/null +++ b/apps/backend/src/domain/decision/decision-issues.ts @@ -0,0 +1,33 @@ +// Every message the decision-contract validation can produce. `{value}` is the one +// interpolation slot. Structural failures (wrong type, missing key) keep zod's wording. +export const DECISION_ISSUE_MESSAGES = { + actions_empty: 'at least one action is required', + name_empty: 'name must not be empty', + label_empty: 'label must not be empty', + unknown_effect: 'effect must be one of {value}', + duplicate_action_name: "action name '{value}' is used more than once", + duplicate_effect: "only one action may have effect '{value}'", + resume_required: "an action with effect 'resume' is required", + port_empty: 'port must not be empty', + port_reserved: "port must not be the reserved 'errorRoute'", + reject_port_equals_resume_port: "reject port '{value}' must differ from the resume port", + required_field_undeclared: "required field '{value}' is not declared in properties", + deadline_format: "must be a positive duration such as '30s', '24h' or '3d' (a number followed by ms, s, m, h or d)", + deadline_policy: "policy must be 'reject'", + source_not_a_gate: 'this node carries no decision contract', + source_not_a_predecessor: "proposalSourceNodeId '{value}' is not a direct predecessor of this node", + source_missing: 'a rerun-source action needs a proposal source, but this node has no predecessor', + source_ambiguous: 'several predecessors; set proposalSourceNodeId to say which one rerun-source re-runs', + source_is_a_gate: "proposal source '{value}' is itself a gate and cannot be re-run", +} as const; + +export type DecisionIssueCode = keyof typeof DECISION_ISSUE_MESSAGES; + +export function decisionIssueMessage(code: DecisionIssueCode, value?: string): string { + // A function replacer, so a value containing `$&` or `$1` lands verbatim. + return DECISION_ISSUE_MESSAGES[code].replace('{value}', () => value ?? ''); +} + +export function decisionIssue(code: DecisionIssueCode, path: PropertyKey[], value?: string) { + return { code: 'custom' as const, message: decisionIssueMessage(code, value), path }; +} diff --git a/apps/backend/src/domain/mapper/snapshot-schema.test.ts b/apps/backend/src/domain/mapper/snapshot-schema.test.ts index 8d23e07e5..d6d8fffa4 100644 --- a/apps/backend/src/domain/mapper/snapshot-schema.test.ts +++ b/apps/backend/src/domain/mapper/snapshot-schema.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; +import { type DecisionIssueCode, decisionIssueMessage } from '../decision/decision-issues'; import { mapToExecutionModel } from './from-integration-data'; import { workflowSnapshotSchema } from './snapshot-schema'; @@ -123,9 +124,15 @@ function edge(source: string, target: string, sourceHandle?: string) { return { id: `${source}->${target}${sourceHandle ?? ''}`, source, target, sourceHandle }; } -function issuePaths(snapshot: unknown): string[] { +function issuesOf(snapshot: unknown): { path: string; message: string }[] { const result = workflowSnapshotSchema.safeParse(snapshot); - return result.success ? [] : result.error.issues.map((issue) => issue.path.join('.')); + return result.success + ? [] + : result.error.issues.map((issue) => ({ path: issue.path.join('.'), message: issue.message })); +} + +function issuePaths(snapshot: unknown): string[] { + return issuesOf(snapshot).map((issue) => issue.path); } describe('workflowSnapshotSchema: gate contracts', () => { @@ -234,7 +241,7 @@ describe('workflowSnapshotSchema: gate contracts', () => { expect(workflowSnapshotSchema.safeParse(snapshot).success).toBe(true); }); - it.each<{ name: string; snapshot: unknown; path: string }>([ + it.each<{ name: string; snapshot: unknown; path: string; issue: { code: DecisionIssueCode; value?: string } }>([ { name: 'an explicit source with no edge into the gate', snapshot: { @@ -242,6 +249,7 @@ describe('workflowSnapshotSchema: gate contracts', () => { edges: [edge('a', 'gate')], }, path: 'nodes.2.data.properties.decision.proposalSourceNodeId', + issue: { code: 'source_not_a_predecessor', value: 'b' }, }, { name: 'an explicit source that is a successor, not a predecessor', @@ -250,6 +258,7 @@ describe('workflowSnapshotSchema: gate contracts', () => { edges: [edge('a', 'gate'), edge('gate', 'after')], }, path: 'nodes.1.data.properties.decision.proposalSourceNodeId', + issue: { code: 'source_not_a_predecessor', value: 'after' }, }, { name: 'a rerun-source gate with no predecessor', @@ -258,6 +267,7 @@ describe('workflowSnapshotSchema: gate contracts', () => { edges: [edge('gate', 'after')], }, path: 'nodes.0.data.properties.decision.proposalSourceNodeId', + issue: { code: 'source_missing' }, }, { name: 'a rerun-source gate with several predecessors and no explicit source', @@ -266,6 +276,7 @@ describe('workflowSnapshotSchema: gate contracts', () => { edges: [edge('a', 'gate'), edge('b', 'gate')], }, path: 'nodes.2.data.properties.decision.proposalSourceNodeId', + issue: { code: 'source_ambiguous' }, }, { name: 'a rerun-source gate whose implicit source is itself a gate', @@ -274,6 +285,7 @@ describe('workflowSnapshotSchema: gate contracts', () => { edges: [edge('a', 'first'), edge('first', 'second')], }, path: 'nodes.2.data.properties.decision.proposalSourceNodeId', + issue: { code: 'source_is_a_gate', value: 'first' }, }, { name: 'a rerun-source gate whose explicit source is itself a gate', @@ -286,6 +298,7 @@ describe('workflowSnapshotSchema: gate contracts', () => { edges: [edge('a', 'first'), edge('a', 'second'), edge('first', 'second')], }, path: 'nodes.2.data.properties.decision.proposalSourceNodeId', + issue: { code: 'source_is_a_gate', value: 'first' }, }, { name: 'only the broken gate when another gate in the snapshot is fine', @@ -298,12 +311,12 @@ describe('workflowSnapshotSchema: gate contracts', () => { edges: [edge('a', 'g1'), edge('a', 'g2'), edge('g1', 'g2')], }, path: 'nodes.2.data.properties.decision.proposalSourceNodeId', + issue: { code: 'source_ambiguous' }, }, - ])('rejects $name', ({ snapshot, path }) => { - const paths = issuePaths(snapshot); + ])('rejects $name', ({ snapshot, path, issue }) => { + const sourceIssues = issuesOf(snapshot).filter((candidate) => candidate.path.endsWith('proposalSourceNodeId')); - expect(paths).toContain(path); - expect(paths.filter((candidate) => candidate.endsWith('proposalSourceNodeId'))).toEqual([path]); + expect(sourceIssues).toEqual([{ path, message: decisionIssueMessage(issue.code, issue.value) }]); }); }); diff --git a/apps/backend/src/domain/mapper/snapshot-schema.ts b/apps/backend/src/domain/mapper/snapshot-schema.ts index 09565a403..e61a390e5 100644 --- a/apps/backend/src/domain/mapper/snapshot-schema.ts +++ b/apps/backend/src/domain/mapper/snapshot-schema.ts @@ -8,6 +8,7 @@ import { z } from 'zod'; import { decisionContractSchema } from '../decision/decision-contract-schema'; +import { type DecisionIssueCode, decisionIssue } from '../decision/decision-issues'; import { type UnresolvedSourceReason, resolveProposalSource } from '../decision/proposal-source'; const frontendNodeSchema = z.object({ @@ -30,22 +31,12 @@ const frontendEdgeSchema = z.object({ sourceHandle: z.string().nullable().optional(), }); -function describeUnresolvedSource(reason: UnresolvedSourceReason, explicit?: string) { - switch (reason) { - case 'explicit_source_not_a_predecessor': { - return `proposalSourceNodeId '${explicit}' is not a direct predecessor of this node`; - } - case 'no_predecessor': { - return 'a rerun-source action needs a proposal source, but this node has no predecessor'; - } - case 'ambiguous_predecessor': { - return 'a rerun-source action needs one proposal source; several predecessors exist, set proposalSourceNodeId'; - } - case 'not_a_gate': { - return 'this node carries no decision contract'; - } - } -} +const SOURCE_ISSUE_BY_REASON = { + not_a_gate: 'source_not_a_gate', + explicit_source_not_a_predecessor: 'source_not_a_predecessor', + no_predecessor: 'source_missing', + ambiguous_predecessor: 'source_ambiguous', +} as const satisfies Record; export const workflowSnapshotSchema = z .object({ @@ -65,20 +56,12 @@ export const workflowSnapshotSchema = z const path = ['nodes', index, 'data', 'properties', 'decision', 'proposalSourceNodeId']; const resolution = resolveProposalSource(nodes, edges, node.id); if (resolution.error !== undefined) { - context.addIssue({ - code: 'custom', - message: describeUnresolvedSource(resolution.error, decision.proposalSourceNodeId), - path, - }); + context.addIssue(decisionIssue(SOURCE_ISSUE_BY_REASON[resolution.error], path, decision.proposalSourceNodeId)); continue; } const sourceIsGate = nodes.some((other) => other.id === resolution.sourceNodeId && other.decision !== undefined); if (declaresRerun && sourceIsGate) { - context.addIssue({ - code: 'custom', - message: `proposal source '${resolution.sourceNodeId}' is itself a gate and cannot be re-run`, - path, - }); + context.addIssue(decisionIssue('source_is_a_gate', path, resolution.sourceNodeId)); } } }); From 9ba535063b69973f2a40ca36fbb0348fbadfe680 Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Mon, 7 Sep 2026 15:13:11 +0200 Subject: [PATCH 05/25] feat(backend): lift the decision contract out of config onto BaseNode The mapper destructures decision beside errorPolicy and label, so a gate's contract reaches the engine as node.decision and never as ordinary config. The value is already validated and defaulted by the snapshot parse. WB-500 --- .../domain/mapper/from-integration-data.ts | 10 +++--- .../src/domain/mapper/snapshot-schema.test.ts | 35 +++++++++++++++++++ 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/apps/backend/src/domain/mapper/from-integration-data.ts b/apps/backend/src/domain/mapper/from-integration-data.ts index 803ff5de5..c69256514 100644 --- a/apps/backend/src/domain/mapper/from-integration-data.ts +++ b/apps/backend/src/domain/mapper/from-integration-data.ts @@ -28,11 +28,11 @@ export function mapToExecutionModel(workflowId: string, data: WorkflowSnapshot): return { workflowId, nodes, edges }; } -// Lifts the three fields an engine reads out of `data.properties`, where the SDK's -// `sharedProperties` put them. `role` comes from `data.isStartNode` instead, which sits -// beside the properties. `description` stays in `config`: no engine reads it. +// Lifts what an engine reads out of `data.properties`: `label`, `errorPolicy` (from the SDK's +// `sharedProperties`) and `decision` (validated and defaulted by the parse, unchecked here). +// `role` comes from `data.isStartNode` beside the properties; `description` stays in `config`. function mapNode(node: FrontendNode): BaseNode { - const { errorPolicy: rawErrorPolicy, label: rawLabel, ...config } = node.data.properties ?? {}; + const { errorPolicy: rawErrorPolicy, label: rawLabel, decision, ...config } = node.data.properties ?? {}; const errorPolicy = isErrorPolicy(rawErrorPolicy) ? rawErrorPolicy : undefined; const label = isNonEmptyString(rawLabel) ? rawLabel.trim() : undefined; const role: NodeRole | undefined = node.data.isStartNode === true ? 'start' : undefined; @@ -40,7 +40,7 @@ function mapNode(node: FrontendNode): BaseNode { id: node.id, type: node.data.type, config, - ...pickBy({ label, errorPolicy, role }, isDefined), + ...pickBy({ label, errorPolicy, decision, role }, isDefined), }; } diff --git a/apps/backend/src/domain/mapper/snapshot-schema.test.ts b/apps/backend/src/domain/mapper/snapshot-schema.test.ts index d6d8fffa4..82ab04689 100644 --- a/apps/backend/src/domain/mapper/snapshot-schema.test.ts +++ b/apps/backend/src/domain/mapper/snapshot-schema.test.ts @@ -502,4 +502,39 @@ describe('mapToExecutionModel', () => { expect(result.nodes[0]?.type).toBe('never-seen-before/v3'); }); + + it('lifts a validated decision contract out of `config` onto `decision`', () => { + const contract = { + version: 1, + actions: [{ name: 'approve', label: 'Approve', effect: 'resume' }], + schema: { type: 'object', properties: {} }, + }; + const snapshot = workflowSnapshotSchema.parse({ + nodes: [ + { id: 'gate', data: { type: 'product/any', properties: { label: 'Review', foo: 1, decision: contract } } }, + ], + edges: [], + }); + + const result = mapToExecutionModel('wf-1', snapshot); + + expect(result.nodes[0]!.decision).toEqual({ ...contract, actions: [{ ...contract.actions[0], port: 'approved' }] }); + expect(result.nodes[0]!.config).toEqual({ foo: 1 }); + expect(result.nodes[0]!.label).toBe('Review'); + }); + + it('gives a node without a contract no `decision` key', () => { + const snapshot = workflowSnapshotSchema.parse({ + nodes: [ + { id: 'n1', data: { type: 'product/any', properties: { foo: 1 } } }, + { id: 'n2', data: { type: 'product/any' } }, + ], + edges: [], + }); + + const result = mapToExecutionModel('wf-1', snapshot); + + expect(result.nodes[0]).not.toHaveProperty('decision'); + expect(result.nodes[1]).not.toHaveProperty('decision'); + }); }); From 1e83978133c03812e37126911e84a2b7ce99d96d Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Mon, 7 Sep 2026 15:28:29 +0200 Subject: [PATCH 06/25] feat(backend): validate the draft snapshot on publish Publish parses the draft through workflowSnapshotSchema before copying it and answers with the same invalid_snapshot 400 as execute; both go through one parseSnapshot helper in routes/snapshot-validation.ts so they cannot drift. A null draft keeps its old behaviour and is not validated. Draft save stays unvalidated, pinned by a test. WB-500 --- .../backend/src/routes/snapshot-validation.ts | 37 ++++++ apps/backend/src/routes/workflows.test.ts | 106 ++++++++++++++++++ apps/backend/src/routes/workflows.ts | 36 ++---- 3 files changed, 153 insertions(+), 26 deletions(-) create mode 100644 apps/backend/src/routes/snapshot-validation.ts diff --git a/apps/backend/src/routes/snapshot-validation.ts b/apps/backend/src/routes/snapshot-validation.ts new file mode 100644 index 000000000..87161cfa0 --- /dev/null +++ b/apps/backend/src/routes/snapshot-validation.ts @@ -0,0 +1,37 @@ +import type { Context } from 'hono'; +import { z } from 'zod'; + +import type { SourceVersion } from '@workflow-builder/types/workflow-execution/api'; + +import { type WorkflowSnapshot, workflowSnapshotSchema } from '../domain/mapper/snapshot-schema'; +import { logger as backendLogger } from '../logger'; + +const logger = backendLogger.child({ component: 'snapshot-validation' }); + +export function formatValidationDetails(error: z.ZodError) { + return error.issues.map((issue) => ({ + path: issue.path, + message: issue.message, + code: issue.code, + })); +} + +export type SnapshotParse = + | { snapshot: WorkflowSnapshot; response?: undefined } + | { snapshot?: undefined; response: Response }; + +// Publish and execute reject a snapshot the same way, so the two never drift. +export function parseSnapshot( + c: Context, + snapshotJson: unknown, + source: { workflowId: string; sourceVersion: SourceVersion }, +): SnapshotParse { + const parsed = z.safeParse(workflowSnapshotSchema, snapshotJson); + if (parsed.success) return { snapshot: parsed.data }; + + const details = formatValidationDetails(parsed.error); + logger.warn('snapshot invalid', { ...source, error: { issues: details } }); + return { + response: c.json({ code: 'invalid_snapshot', message: 'Workflow snapshot failed validation', details }, 400), + }; +} diff --git a/apps/backend/src/routes/workflows.test.ts b/apps/backend/src/routes/workflows.test.ts index 5e6bacb54..e33016094 100644 --- a/apps/backend/src/routes/workflows.test.ts +++ b/apps/backend/src/routes/workflows.test.ts @@ -268,3 +268,109 @@ describe('createWorkflowsRoutes - execute propagates tenant identity', () => { expect(engineMock.submit).toHaveBeenCalledWith(expect.objectContaining({ variables: {} })); }); }); + +// ---- snapshot validation on publish and execute ----------------------------- +// +// Publish validates the draft before copying it and execute validates the chosen +// version before submitting; both answer with the same `invalid_snapshot` body. +// Draft save never validates: a draft is legitimately mid-edit. + +function snapshotWithGateActions(actions: unknown[]) { + return { + nodes: [ + { id: 'src', data: { type: 'product/any' } }, + { + id: 'gate', + data: { + type: 'product/any', + properties: { decision: { version: 1, actions, schema: { type: 'object', properties: {} } } }, + }, + }, + ], + edges: [{ id: 'e1', source: 'src', target: 'gate' }], + }; +} + +const approve = { name: 'approve', label: 'Approve', effect: 'resume' }; +const validGateSnapshot = snapshotWithGateActions([approve]); +const twoResumesSnapshot = snapshotWithGateActions([approve, { ...approve, name: 'approve-2' }]); + +type InvalidSnapshotBody = { code: string; details: { path: (string | number)[] }[] }; + +function allowAllApp() { + return buildApp(allowAll(vi.fn(async () => true))); +} + +function publish(app: ReturnType) { + return app.request('/api/workflows/w-1/publish', { method: 'POST' }); +} + +function jsonRequest(app: ReturnType, path: string, method: string, body: unknown) { + return app.request(path, { method, body: JSON.stringify(body), headers: { 'content-type': 'application/json' } }); +} + +describe('createWorkflowsRoutes - snapshot validation on publish', () => { + it('rejects a draft with a broken contract and writes nothing', async () => { + databaseMock.select.mockReturnValue(chainResolving([{ ...fakeWorkflow, draftJson: twoResumesSnapshot }])); + + const response = await publish(allowAllApp()); + const body = (await response.json()) as InvalidSnapshotBody; + + expect(response.status).toBe(400); + expect(body.code).toBe('invalid_snapshot'); + expect(body.details.map((detail) => detail.path.join('.'))).toContain( + 'nodes.1.data.properties.decision.actions.1.effect', + ); + expect(databaseMock.update).not.toHaveBeenCalled(); + }); + + it('accepts a draft with a valid gate and returns the row', async () => { + const published = { ...fakeWorkflow, draftJson: validGateSnapshot, publishedJson: validGateSnapshot }; + databaseMock.select.mockReturnValue(chainResolving([{ ...fakeWorkflow, draftJson: validGateSnapshot }])); + databaseMock.update.mockReturnValue(chainResolving([published])); + + const response = await publish(allowAllApp()); + + expect(response.status).toBe(200); + expect(databaseMock.update).toHaveBeenCalledTimes(1); + expect(await response.json()).toMatchObject({ id: 'w-1', publishedJson: validGateSnapshot }); + }); + + it('still publishes a workflow without a draft, unvalidated', async () => { + databaseMock.select.mockReturnValue(chainResolving([{ ...fakeWorkflow, draftJson: null }])); + databaseMock.update.mockReturnValue(chainResolving([{ ...fakeWorkflow, draftJson: null }])); + + const response = await publish(allowAllApp()); + + expect(response.status).toBe(200); + expect(databaseMock.update).toHaveBeenCalledTimes(1); + }); + + it('answers with the same body execute gives for the same broken snapshot', async () => { + databaseMock.select.mockReturnValue(chainResolving([{ ...fakeWorkflow, draftJson: twoResumesSnapshot }])); + + const publishResponse = await publish(allowAllApp()); + const publishBody = await publishResponse.json(); + const executeResponse = await jsonRequest(allowAllApp(), '/api/workflows/w-1/execute', 'POST', { + sourceVersion: 'draft', + }); + + expect(executeResponse.status).toBe(400); + expect(await executeResponse.json()).toEqual(publishBody); + expect(databaseMock.insert).not.toHaveBeenCalled(); + expect(engineMock.submit).not.toHaveBeenCalled(); + }); +}); + +describe('createWorkflowsRoutes - draft save never validates the snapshot', () => { + it('stores a draft with a broken contract', async () => { + databaseMock.update.mockReturnValue(chainResolving([{ ...fakeWorkflow, draftJson: twoResumesSnapshot }])); + + const response = await jsonRequest(allowAllApp(), '/api/workflows/w-1/draft', 'PATCH', { + draftJson: twoResumesSnapshot, + }); + + expect(response.status).toBe(200); + expect(databaseMock.update).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/backend/src/routes/workflows.ts b/apps/backend/src/routes/workflows.ts index cb7906c85..3b1826d69 100644 --- a/apps/backend/src/routes/workflows.ts +++ b/apps/backend/src/routes/workflows.ts @@ -6,11 +6,11 @@ import type { AssertAuthorized, AuthVariables } from '../auth'; import { database } from '../db/client'; import { executions, workflows } from '../db/schema'; import { mapToExecutionModel } from '../domain/mapper/from-integration-data'; -import { workflowSnapshotSchema } from '../domain/mapper/snapshot-schema'; import { getWorkflowEngine } from '../engine'; import { logger as backendLogger } from '../logger'; import { guardExecution } from '../security/execution-guard'; import type { TenantVariables } from '../tenant'; +import { formatValidationDetails, parseSnapshot } from './snapshot-validation'; const logger = backendLogger.child({ component: 'workflows-route' }); @@ -28,14 +28,6 @@ const executeSchema = z.object({ triggerPayload: z.record(z.string(), z.unknown()).optional(), }); -function formatValidationDetails(error: z.ZodError) { - return error.issues.map((issue) => ({ - path: issue.path, - message: issue.message, - code: issue.code, - })); -} - export function createWorkflowsRoutes( assertAuthorized: AssertAuthorized, ): Hono<{ Variables: AuthVariables & TenantVariables }> { @@ -153,6 +145,12 @@ export function createWorkflowsRoutes( return c.json({ code: 'workflow_not_found', message: 'Workflow not found' }, 404); } + // A null draft is not validated; publishing it clears `publishedJson`, as before. + if (existing.draftJson !== null) { + const parsed = parseSnapshot(c, existing.draftJson, { workflowId, sourceVersion: 'draft' }); + if (parsed.response !== undefined) return parsed.response; + } + const [workflow] = await database .update(workflows) .set({ @@ -202,22 +200,8 @@ export function createWorkflowsRoutes( return c.json({ code: 'published_version_missing', message: `No ${body.sourceVersion} version available` }, 400); } - const snapshotParsed = z.safeParse(workflowSnapshotSchema, snapshotJson); - if (!snapshotParsed.success) { - logger.warn('snapshot invalid', { - workflowId, - sourceVersion: body.sourceVersion, - error: { issues: formatValidationDetails(snapshotParsed.error) }, - }); - return c.json( - { - code: 'invalid_snapshot', - message: 'Workflow snapshot failed validation', - details: formatValidationDetails(snapshotParsed.error), - }, - 400, - ); - } + const snapshotParse = parseSnapshot(c, snapshotJson, { workflowId, sourceVersion: body.sourceVersion }); + if (snapshotParse.response !== undefined) return snapshotParse.response; // Propagate tenant identity from the HTTP boundary onto the execution row. // The worker reads it back via subquery for event tagging (see worker @@ -248,7 +232,7 @@ export function createWorkflowsRoutes( sourceVersion: body.sourceVersion, }); - const definition = mapToExecutionModel(workflowId, snapshotParsed.data); + const definition = mapToExecutionModel(workflowId, snapshotParse.snapshot); await getWorkflowEngine().submit({ workflowId, From 63c7c8dcd32247943c4f4c17eb6bf31342f6823c Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Tue, 8 Sep 2026 06:54:11 +0200 Subject: [PATCH 07/25] refactor: drop the "gate" vocabulary for decision-carrying nodes A node that carries a decision contract is still a node; the product has no gate concept, and coining one invented a node kind the SDK never had. Identifiers, issue keys, messages, JSDoc, comments, test names and fixtures now say node, contract, or deciding node. The public JSDoc in packages/types changes with it, so the temporal dist was rebuilt and its tests re-run. WB-500 --- .../decision/decision-contract-schema.test.ts | 4 +- .../domain/decision/decision-issues.test.ts | 4 +- .../src/domain/decision/decision-issues.ts | 4 +- .../domain/decision/proposal-source.test.ts | 40 +++--- .../src/domain/decision/proposal-source.ts | 12 +- .../src/domain/mapper/snapshot-schema.test.ts | 117 ++++++++++-------- .../src/domain/mapper/snapshot-schema.ts | 12 +- apps/backend/src/routes/workflows.test.ts | 18 +-- .../workflow-execution/decision-contract.ts | 21 ++-- .../src/workflow-execution/execution-model.ts | 5 +- 10 files changed, 128 insertions(+), 109 deletions(-) diff --git a/apps/backend/src/domain/decision/decision-contract-schema.test.ts b/apps/backend/src/domain/decision/decision-contract-schema.test.ts index 357949d25..0c3ca6d56 100644 --- a/apps/backend/src/domain/decision/decision-contract-schema.test.ts +++ b/apps/backend/src/domain/decision/decision-contract-schema.test.ts @@ -54,7 +54,7 @@ describe('decisionContractSchema', () => { expect(decisionContractSchema.safeParse(workedExample()).success).toBe(true); }); - it('accepts a minimal gate: one resume action and an empty form', () => { + it('accepts a minimal contract: one resume action and an empty form', () => { const minimal = { version: 1, actions: [{ name: 'ok', label: 'OK', effect: 'resume' }], @@ -132,7 +132,7 @@ describe('decisionContractSchema', () => { expect(decisionContractSchema.safeParse(contract({ deadline: { after, policy: 'reject' } })).success).toBe(true); }); - it('accepts a gate without deadline, uiSchema or proposalSourceNodeId', () => { + it('accepts a contract without deadline, uiSchema or proposalSourceNodeId', () => { const { version, actions, schema } = workedExample(); expect(decisionContractSchema.safeParse({ version, actions, schema }).success).toBe(true); diff --git a/apps/backend/src/domain/decision/decision-issues.test.ts b/apps/backend/src/domain/decision/decision-issues.test.ts index d421f66b4..62b729af8 100644 --- a/apps/backend/src/domain/decision/decision-issues.test.ts +++ b/apps/backend/src/domain/decision/decision-issues.test.ts @@ -10,8 +10,8 @@ describe('decisionIssueMessage', () => { }); it('keeps replacement patterns in the value verbatim', () => { - expect(decisionIssueMessage('source_is_a_gate', '$&-$1')).toBe( - "proposal source '$&-$1' is itself a gate and cannot be re-run", + expect(decisionIssueMessage('source_has_decision', '$&-$1')).toBe( + "proposal source '$&-$1' carries its own decision contract and cannot be re-run", ); }); diff --git a/apps/backend/src/domain/decision/decision-issues.ts b/apps/backend/src/domain/decision/decision-issues.ts index 62fa5e0f1..44cae2e01 100644 --- a/apps/backend/src/domain/decision/decision-issues.ts +++ b/apps/backend/src/domain/decision/decision-issues.ts @@ -14,11 +14,11 @@ export const DECISION_ISSUE_MESSAGES = { required_field_undeclared: "required field '{value}' is not declared in properties", deadline_format: "must be a positive duration such as '30s', '24h' or '3d' (a number followed by ms, s, m, h or d)", deadline_policy: "policy must be 'reject'", - source_not_a_gate: 'this node carries no decision contract', + source_node_without_decision: 'this node carries no decision contract', source_not_a_predecessor: "proposalSourceNodeId '{value}' is not a direct predecessor of this node", source_missing: 'a rerun-source action needs a proposal source, but this node has no predecessor', source_ambiguous: 'several predecessors; set proposalSourceNodeId to say which one rerun-source re-runs', - source_is_a_gate: "proposal source '{value}' is itself a gate and cannot be re-run", + source_has_decision: "proposal source '{value}' carries its own decision contract and cannot be re-run", } as const; export type DecisionIssueCode = keyof typeof DECISION_ISSUE_MESSAGES; diff --git a/apps/backend/src/domain/decision/proposal-source.test.ts b/apps/backend/src/domain/decision/proposal-source.test.ts index 2749b664c..bd3d6b0b3 100644 --- a/apps/backend/src/domain/decision/proposal-source.test.ts +++ b/apps/backend/src/domain/decision/proposal-source.test.ts @@ -18,50 +18,50 @@ function edge(sourceNodeId: string, targetNodeId: string) { } describe('resolveProposalSource', () => { - it('reports a node without a contract, or an unknown id, as not a gate', () => { + it('reports a node without a contract, or an unknown id, as a node without decision', () => { const nodes = [{ id: 'plain' }]; - expect(resolveProposalSource(nodes, [], 'plain')).toEqual({ error: 'not_a_gate' }); - expect(resolveProposalSource(nodes, [], 'missing')).toEqual({ error: 'not_a_gate' }); + expect(resolveProposalSource(nodes, [], 'plain')).toEqual({ error: 'node_without_decision' }); + expect(resolveProposalSource(nodes, [], 'missing')).toEqual({ error: 'node_without_decision' }); }); it('returns an explicit source that is a direct predecessor', () => { - const nodes = [{ id: 'a' }, { id: 'b' }, { id: 'gate', decision: contract('a') }]; - const edges = [edge('a', 'gate'), edge('b', 'gate')]; + const nodes = [{ id: 'a' }, { id: 'b' }, { id: 'review', decision: contract('a') }]; + const edges = [edge('a', 'review'), edge('b', 'review')]; - expect(resolveProposalSource(nodes, edges, 'gate')).toEqual({ sourceNodeId: 'a' }); + expect(resolveProposalSource(nodes, edges, 'review')).toEqual({ sourceNodeId: 'a' }); }); it('rejects an explicit source that is not a direct predecessor, including a successor', () => { - const nodes = [{ id: 'a' }, { id: 'after' }, { id: 'gate', decision: contract('after') }]; - const edges = [edge('a', 'gate'), edge('gate', 'after')]; + const nodes = [{ id: 'a' }, { id: 'after' }, { id: 'review', decision: contract('after') }]; + const edges = [edge('a', 'review'), edge('review', 'after')]; - expect(resolveProposalSource(nodes, edges, 'gate')).toEqual({ error: 'explicit_source_not_a_predecessor' }); + expect(resolveProposalSource(nodes, edges, 'review')).toEqual({ error: 'explicit_source_not_a_predecessor' }); }); it('falls back to the only direct predecessor when no source is declared', () => { - const nodes = [{ id: 'a' }, { id: 'gate', decision: contract() }]; + const nodes = [{ id: 'a' }, { id: 'review', decision: contract() }]; - expect(resolveProposalSource(nodes, [edge('a', 'gate')], 'gate')).toEqual({ sourceNodeId: 'a' }); + expect(resolveProposalSource(nodes, [edge('a', 'review')], 'review')).toEqual({ sourceNodeId: 'a' }); }); it('counts parallel edges from one node as a single predecessor', () => { - const nodes = [{ id: 'a' }, { id: 'gate', decision: contract() }]; - const edges = [edge('a', 'gate'), edge('a', 'gate')]; + const nodes = [{ id: 'a' }, { id: 'review', decision: contract() }]; + const edges = [edge('a', 'review'), edge('a', 'review')]; - expect(resolveProposalSource(nodes, edges, 'gate')).toEqual({ sourceNodeId: 'a' }); + expect(resolveProposalSource(nodes, edges, 'review')).toEqual({ sourceNodeId: 'a' }); }); - it('reports no predecessor when the gate has only outgoing edges', () => { - const nodes = [{ id: 'gate', decision: contract() }, { id: 'after' }]; + it('reports no predecessor when the node has only outgoing edges', () => { + const nodes = [{ id: 'review', decision: contract() }, { id: 'after' }]; - expect(resolveProposalSource(nodes, [edge('gate', 'after')], 'gate')).toEqual({ error: 'no_predecessor' }); + expect(resolveProposalSource(nodes, [edge('review', 'after')], 'review')).toEqual({ error: 'no_predecessor' }); }); it('reports ambiguity when several predecessors exist and none is declared', () => { - const nodes = [{ id: 'a' }, { id: 'b' }, { id: 'gate', decision: contract() }]; - const edges = [edge('a', 'gate'), edge('b', 'gate')]; + const nodes = [{ id: 'a' }, { id: 'b' }, { id: 'review', decision: contract() }]; + const edges = [edge('a', 'review'), edge('b', 'review')]; - expect(resolveProposalSource(nodes, edges, 'gate')).toEqual({ error: 'ambiguous_predecessor' }); + expect(resolveProposalSource(nodes, edges, 'review')).toEqual({ error: 'ambiguous_predecessor' }); }); }); diff --git a/apps/backend/src/domain/decision/proposal-source.ts b/apps/backend/src/domain/decision/proposal-source.ts index 33785bcd2..24daa3629 100644 --- a/apps/backend/src/domain/decision/proposal-source.ts +++ b/apps/backend/src/domain/decision/proposal-source.ts @@ -6,7 +6,7 @@ type GraphNode = Pick; type GraphEdge = Pick; export type UnresolvedSourceReason = - | 'not_a_gate' + | 'node_without_decision' | 'explicit_source_not_a_predecessor' | 'no_predecessor' | 'ambiguous_predecessor'; @@ -22,13 +22,13 @@ export type ProposalSourceResolution = export function resolveProposalSource( nodes: readonly GraphNode[], edges: readonly GraphEdge[], - gateId: string, + nodeId: string, ): ProposalSourceResolution { - const gate = nodes.find((node) => node.id === gateId); - if (gate?.decision === undefined) return { error: 'not_a_gate' }; + const node = nodes.find((candidate) => candidate.id === nodeId); + if (node?.decision === undefined) return { error: 'node_without_decision' }; - const predecessors = unique(edges.filter((edge) => edge.targetNodeId === gateId).map((edge) => edge.sourceNodeId)); - const explicit = gate.decision.proposalSourceNodeId; + const predecessors = unique(edges.filter((edge) => edge.targetNodeId === nodeId).map((edge) => edge.sourceNodeId)); + const explicit = node.decision.proposalSourceNodeId; if (explicit !== undefined) { return predecessors.includes(explicit) ? { sourceNodeId: explicit } diff --git a/apps/backend/src/domain/mapper/snapshot-schema.test.ts b/apps/backend/src/domain/mapper/snapshot-schema.test.ts index 82ab04689..d57ec180c 100644 --- a/apps/backend/src/domain/mapper/snapshot-schema.test.ts +++ b/apps/backend/src/domain/mapper/snapshot-schema.test.ts @@ -135,22 +135,22 @@ function issuePaths(snapshot: unknown): string[] { return issuesOf(snapshot).map((issue) => issue.path); } -describe('workflowSnapshotSchema: gate contracts', () => { +describe('workflowSnapshotSchema: decision contracts', () => { const approve = { name: 'approve', label: 'Approve', effect: 'resume' }; const reRequest = { name: 're-request', label: 'Ask again', effect: 'rerun-source' }; const emptyForm = { type: 'object', properties: {} }; - function gate(id: string, decision: Record) { + function decisionNode(id: string, decision: Record) { return { id, data: { type: 'product/any', properties: { decision: { version: 1, schema: emptyForm, ...decision } } }, }; } - it('parses a gate and materialises the contract defaults inside properties', () => { + it('parses a decision contract and materialises its defaults inside properties', () => { const parsed = workflowSnapshotSchema.parse({ - nodes: [node('src'), gate('gate', { actions: [approve, reRequest] })], - edges: [edge('src', 'gate')], + nodes: [node('src'), decisionNode('review', { actions: [approve, reRequest] })], + edges: [edge('src', 'review')], }); expect(parsed.nodes[1]!.data.properties?.decision?.actions).toEqual([ @@ -169,14 +169,14 @@ describe('workflowSnapshotSchema: gate contracts', () => { it('points a contract issue at the node index and field', () => { const snapshot = { - nodes: [node('src'), gate('gate', { actions: [approve, { ...approve, name: 'approve-2' }] })], - edges: [edge('src', 'gate')], + nodes: [node('src'), decisionNode('review', { actions: [approve, { ...approve, name: 'approve-2' }] })], + edges: [edge('src', 'review')], }; expect(issuePaths(snapshot)).toContain('nodes.1.data.properties.decision.actions.1.effect'); }); - it('rejects `decision: null`; absent is the only way to not be a gate', () => { + it('rejects `decision: null`; absent is the only way to carry no decision', () => { const snapshot = { nodes: [node('n1', { decision: null })], edges: [] }; expect(issuePaths(snapshot)).toContain('nodes.0.data.properties.decision'); @@ -186,55 +186,62 @@ describe('workflowSnapshotSchema: gate contracts', () => { { name: 'an explicit source that is a direct predecessor', snapshot: { - nodes: [node('a'), node('b'), gate('gate', { actions: [approve], proposalSourceNodeId: 'a' })], - edges: [edge('a', 'gate'), edge('b', 'gate')], + nodes: [node('a'), node('b'), decisionNode('review', { actions: [approve], proposalSourceNodeId: 'a' })], + edges: [edge('a', 'review'), edge('b', 'review')], }, }, { - name: 'a rerun-source gate with exactly one predecessor and no explicit source', - snapshot: { nodes: [node('a'), gate('gate', { actions: [approve, reRequest] })], edges: [edge('a', 'gate')] }, + name: 'a rerun-source node with exactly one predecessor and no explicit source', + snapshot: { + nodes: [node('a'), decisionNode('review', { actions: [approve, reRequest] })], + edges: [edge('a', 'review')], + }, }, { - name: 'a rerun-source gate with several predecessors when the explicit source picks one', + name: 'a rerun-source node with several predecessors when the explicit source picks one', snapshot: { - nodes: [node('a'), node('b'), gate('gate', { actions: [approve, reRequest], proposalSourceNodeId: 'b' })], - edges: [edge('a', 'gate'), edge('b', 'gate')], + nodes: [ + node('a'), + node('b'), + decisionNode('review', { actions: [approve, reRequest], proposalSourceNodeId: 'b' }), + ], + edges: [edge('a', 'review'), edge('b', 'review')], }, }, { - name: 'a rerun-source gate whose single predecessor connects through two handles', + name: 'a rerun-source node whose single predecessor connects through two handles', snapshot: { - nodes: [node('a'), gate('gate', { actions: [approve, reRequest] })], - edges: [edge('a', 'gate', 'left'), edge('a', 'gate', 'right')], + nodes: [node('a'), decisionNode('review', { actions: [approve, reRequest] })], + edges: [edge('a', 'review', 'left'), edge('a', 'review', 'right')], }, }, { - name: 'a gate without rerun-source and with several predecessors and no explicit source', + name: 'a node without rerun-source and with several predecessors and no explicit source', snapshot: { - nodes: [node('a'), node('b'), gate('gate', { actions: [approve] })], - edges: [edge('a', 'gate'), edge('b', 'gate')], + nodes: [node('a'), node('b'), decisionNode('review', { actions: [approve] })], + edges: [edge('a', 'review'), edge('b', 'review')], }, }, { - name: 'a gate without rerun-source whose explicit source is another gate', + name: 'a node without rerun-source whose explicit source carries its own decision', snapshot: { nodes: [ - gate('first', { actions: [approve] }), - gate('second', { actions: [approve], proposalSourceNodeId: 'first' }), + decisionNode('first', { actions: [approve] }), + decisionNode('second', { actions: [approve], proposalSourceNodeId: 'first' }), ], edges: [edge('first', 'second')], }, }, { - name: 'two independent gates in one snapshot', + name: 'two independent deciding nodes in one snapshot', snapshot: { nodes: [ node('a'), - gate('g1', { actions: [approve, reRequest] }), + decisionNode('review-1', { actions: [approve, reRequest] }), node('b'), - gate('g2', { actions: [approve, reRequest] }), + decisionNode('review-2', { actions: [approve, reRequest] }), ], - edges: [edge('a', 'g1'), edge('g1', 'b'), edge('b', 'g2')], + edges: [edge('a', 'review-1'), edge('review-1', 'b'), edge('b', 'review-2')], }, }, ])('accepts $name', ({ snapshot }) => { @@ -243,10 +250,10 @@ describe('workflowSnapshotSchema: gate contracts', () => { it.each<{ name: string; snapshot: unknown; path: string; issue: { code: DecisionIssueCode; value?: string } }>([ { - name: 'an explicit source with no edge into the gate', + name: 'an explicit source with no edge into the deciding node', snapshot: { - nodes: [node('a'), node('b'), gate('gate', { actions: [approve], proposalSourceNodeId: 'b' })], - edges: [edge('a', 'gate')], + nodes: [node('a'), node('b'), decisionNode('review', { actions: [approve], proposalSourceNodeId: 'b' })], + edges: [edge('a', 'review')], }, path: 'nodes.2.data.properties.decision.proposalSourceNodeId', issue: { code: 'source_not_a_predecessor', value: 'b' }, @@ -254,61 +261,69 @@ describe('workflowSnapshotSchema: gate contracts', () => { { name: 'an explicit source that is a successor, not a predecessor', snapshot: { - nodes: [node('a'), gate('gate', { actions: [approve], proposalSourceNodeId: 'after' }), node('after')], - edges: [edge('a', 'gate'), edge('gate', 'after')], + nodes: [ + node('a'), + decisionNode('review', { actions: [approve], proposalSourceNodeId: 'after' }), + node('after'), + ], + edges: [edge('a', 'review'), edge('review', 'after')], }, path: 'nodes.1.data.properties.decision.proposalSourceNodeId', issue: { code: 'source_not_a_predecessor', value: 'after' }, }, { - name: 'a rerun-source gate with no predecessor', + name: 'a rerun-source node with no predecessor', snapshot: { - nodes: [gate('gate', { actions: [approve, reRequest] }), node('after')], - edges: [edge('gate', 'after')], + nodes: [decisionNode('review', { actions: [approve, reRequest] }), node('after')], + edges: [edge('review', 'after')], }, path: 'nodes.0.data.properties.decision.proposalSourceNodeId', issue: { code: 'source_missing' }, }, { - name: 'a rerun-source gate with several predecessors and no explicit source', + name: 'a rerun-source node with several predecessors and no explicit source', snapshot: { - nodes: [node('a'), node('b'), gate('gate', { actions: [approve, reRequest] })], - edges: [edge('a', 'gate'), edge('b', 'gate')], + nodes: [node('a'), node('b'), decisionNode('review', { actions: [approve, reRequest] })], + edges: [edge('a', 'review'), edge('b', 'review')], }, path: 'nodes.2.data.properties.decision.proposalSourceNodeId', issue: { code: 'source_ambiguous' }, }, { - name: 'a rerun-source gate whose implicit source is itself a gate', + name: 'a rerun-source node whose implicit source carries its own decision', snapshot: { - nodes: [node('a'), gate('first', { actions: [approve] }), gate('second', { actions: [approve, reRequest] })], + nodes: [ + node('a'), + decisionNode('first', { actions: [approve] }), + decisionNode('second', { actions: [approve, reRequest] }), + ], edges: [edge('a', 'first'), edge('first', 'second')], }, path: 'nodes.2.data.properties.decision.proposalSourceNodeId', - issue: { code: 'source_is_a_gate', value: 'first' }, + issue: { code: 'source_has_decision', value: 'first' }, }, { - name: 'a rerun-source gate whose explicit source is itself a gate', + name: 'a rerun-source node whose explicit source carries its own decision', snapshot: { nodes: [ node('a'), - gate('first', { actions: [approve] }), - gate('second', { actions: [approve, reRequest], proposalSourceNodeId: 'first' }), + decisionNode('first', { actions: [approve] }), + decisionNode('second', { actions: [approve, reRequest], proposalSourceNodeId: 'first' }), ], edges: [edge('a', 'first'), edge('a', 'second'), edge('first', 'second')], }, path: 'nodes.2.data.properties.decision.proposalSourceNodeId', - issue: { code: 'source_is_a_gate', value: 'first' }, + issue: { code: 'source_has_decision', value: 'first' }, }, { - name: 'only the broken gate when another gate in the snapshot is fine', + name: 'only the broken node when another deciding node in the snapshot is fine', snapshot: { nodes: [ node('a'), - gate('g1', { actions: [approve, reRequest] }), - gate('g2', { actions: [approve, reRequest] }), + decisionNode('review-1', { actions: [approve, reRequest] }), + decisionNode('review-2', { actions: [approve, reRequest] }), ], - edges: [edge('a', 'g1'), edge('a', 'g2'), edge('g1', 'g2')], + edges: [edge('a', 'review-1'), edge('a', 'review-2'), edge('review-1', 'review-2')], }, path: 'nodes.2.data.properties.decision.proposalSourceNodeId', issue: { code: 'source_ambiguous' }, @@ -511,7 +526,7 @@ describe('mapToExecutionModel', () => { }; const snapshot = workflowSnapshotSchema.parse({ nodes: [ - { id: 'gate', data: { type: 'product/any', properties: { label: 'Review', foo: 1, decision: contract } } }, + { id: 'review', data: { type: 'product/any', properties: { label: 'Review', foo: 1, decision: contract } } }, ], edges: [], }); diff --git a/apps/backend/src/domain/mapper/snapshot-schema.ts b/apps/backend/src/domain/mapper/snapshot-schema.ts index e61a390e5..7bdca9083 100644 --- a/apps/backend/src/domain/mapper/snapshot-schema.ts +++ b/apps/backend/src/domain/mapper/snapshot-schema.ts @@ -1,7 +1,7 @@ // Validates the workflow snapshot at the HTTP boundary structurally only: // every node has `id` and `data.type`; every edge has `id`, `source`, `target`. // `data.properties` is opaque here except for the reserved `decision` key, which -// marks a gate and is validated as a contract. The backend does not know any +// is validated as a decision contract. The backend does not know any // product's node vocabulary; per-type validation belongs to whichever worker // registers executors for it, and an unknown node type surfaces at runtime as // a `node_failed` event with the missing-executor message. @@ -32,7 +32,7 @@ const frontendEdgeSchema = z.object({ }); const SOURCE_ISSUE_BY_REASON = { - not_a_gate: 'source_not_a_gate', + node_without_decision: 'source_node_without_decision', explicit_source_not_a_predecessor: 'source_not_a_predecessor', no_predecessor: 'source_missing', ambiguous_predecessor: 'source_ambiguous', @@ -59,9 +59,11 @@ export const workflowSnapshotSchema = z context.addIssue(decisionIssue(SOURCE_ISSUE_BY_REASON[resolution.error], path, decision.proposalSourceNodeId)); continue; } - const sourceIsGate = nodes.some((other) => other.id === resolution.sourceNodeId && other.decision !== undefined); - if (declaresRerun && sourceIsGate) { - context.addIssue(decisionIssue('source_is_a_gate', path, resolution.sourceNodeId)); + const sourceHasDecision = nodes.some( + (other) => other.id === resolution.sourceNodeId && other.decision !== undefined, + ); + if (declaresRerun && sourceHasDecision) { + context.addIssue(decisionIssue('source_has_decision', path, resolution.sourceNodeId)); } } }); diff --git a/apps/backend/src/routes/workflows.test.ts b/apps/backend/src/routes/workflows.test.ts index e33016094..1894b0fd3 100644 --- a/apps/backend/src/routes/workflows.test.ts +++ b/apps/backend/src/routes/workflows.test.ts @@ -275,25 +275,25 @@ describe('createWorkflowsRoutes - execute propagates tenant identity', () => { // version before submitting; both answer with the same `invalid_snapshot` body. // Draft save never validates: a draft is legitimately mid-edit. -function snapshotWithGateActions(actions: unknown[]) { +function snapshotWithDecisionActions(actions: unknown[]) { return { nodes: [ { id: 'src', data: { type: 'product/any' } }, { - id: 'gate', + id: 'review', data: { type: 'product/any', properties: { decision: { version: 1, actions, schema: { type: 'object', properties: {} } } }, }, }, ], - edges: [{ id: 'e1', source: 'src', target: 'gate' }], + edges: [{ id: 'e1', source: 'src', target: 'review' }], }; } const approve = { name: 'approve', label: 'Approve', effect: 'resume' }; -const validGateSnapshot = snapshotWithGateActions([approve]); -const twoResumesSnapshot = snapshotWithGateActions([approve, { ...approve, name: 'approve-2' }]); +const validDecisionSnapshot = snapshotWithDecisionActions([approve]); +const twoResumesSnapshot = snapshotWithDecisionActions([approve, { ...approve, name: 'approve-2' }]); type InvalidSnapshotBody = { code: string; details: { path: (string | number)[] }[] }; @@ -324,16 +324,16 @@ describe('createWorkflowsRoutes - snapshot validation on publish', () => { expect(databaseMock.update).not.toHaveBeenCalled(); }); - it('accepts a draft with a valid gate and returns the row', async () => { - const published = { ...fakeWorkflow, draftJson: validGateSnapshot, publishedJson: validGateSnapshot }; - databaseMock.select.mockReturnValue(chainResolving([{ ...fakeWorkflow, draftJson: validGateSnapshot }])); + it('accepts a draft with a valid decision contract and returns the row', async () => { + const published = { ...fakeWorkflow, draftJson: validDecisionSnapshot, publishedJson: validDecisionSnapshot }; + databaseMock.select.mockReturnValue(chainResolving([{ ...fakeWorkflow, draftJson: validDecisionSnapshot }])); databaseMock.update.mockReturnValue(chainResolving([published])); const response = await publish(allowAllApp()); expect(response.status).toBe(200); expect(databaseMock.update).toHaveBeenCalledTimes(1); - expect(await response.json()).toMatchObject({ id: 'w-1', publishedJson: validGateSnapshot }); + expect(await response.json()).toMatchObject({ id: 'w-1', publishedJson: validDecisionSnapshot }); }); it('still publishes a workflow without a draft, unvalidated', async () => { diff --git a/packages/types/src/workflow-execution/decision-contract.ts b/packages/types/src/workflow-execution/decision-contract.ts index a210f72da..5694ca58e 100644 --- a/packages/types/src/workflow-execution/decision-contract.ts +++ b/packages/types/src/workflow-execution/decision-contract.ts @@ -1,5 +1,5 @@ /** - * Effects a gate may declare on its actions. `resume-with-edits` is deliberately absent: + * Effects a decision contract may declare on its actions. `resume-with-edits` is deliberately absent: * it is never declared; the backend derives it when a `resume` call carries edits. */ export const DECLARABLE_DECISION_EFFECTS = ['resume', 'reject', 'rerun-source'] as const; @@ -12,7 +12,7 @@ export type DecisionEffect = DeclarableDecisionEffect | 'resume-with-edits'; type DecisionActionBase = { /** - * What a submitted decision names. Unique within the gate. Any string: the client's + * What a submitted decision names. Unique within the contract. Any string: the client's * vocabulary, not an engine keyword. */ name: string; @@ -20,14 +20,14 @@ type DecisionActionBase = { label: string; }; -/** Accepts the proposal, edited or not: the run continues on `port`. Exactly one per gate. */ +/** Accepts the proposal, edited or not: the run continues on `port`. Exactly one per contract. */ export type ResumeDecisionAction = DecisionActionBase & { effect: 'resume'; /** Output handle the run continues on. Defaults to `approved`. Never `errorRoute`. */ port: string; }; -/** Rejects the proposal: the run continues on `port`. At most one per gate. */ +/** Rejects the proposal: the run continues on `port`. At most one per contract. */ export type RejectDecisionAction = DecisionActionBase & { effect: 'reject'; /** Output handle the run continues on. Defaults to `rejected`. Must differ from the resume port. */ @@ -36,7 +36,7 @@ export type RejectDecisionAction = DecisionActionBase & { reasonRequired: boolean; }; -/** Re-runs the proposal source with the decider's comment. At most one per gate. */ +/** Re-runs the proposal source with the decider's comment. At most one per contract. */ export type RerunSourceDecisionAction = DecisionActionBase & { effect: 'rerun-source'; /** Upper bound on re-runs of the proposal source. Integer of at least 1. Defaults to `3`. */ @@ -50,10 +50,10 @@ export type RerunSourceDecisionAction = DecisionActionBase & { */ export type DecisionAction = ResumeDecisionAction | RejectDecisionAction | RerunSourceDecisionAction; -/** Time limit on a parked gate. */ +/** Time limit on a node waiting for the decision. */ export type DecisionDeadline = { /** - * Counted from the moment the gate parks. A number followed by `ms`, `s`, `m`, `h` or `d`, + * Counted from the moment the node parks. A number followed by `ms`, `s`, `m`, `h` or `d`, * such as `'30s'` or `'3d'`. */ after: string; @@ -64,8 +64,7 @@ export type DecisionDeadline = { /** * The human decision a node asks for before the run continues. Authored under * `data.properties.decision` in the editor snapshot and lifted to `BaseNode.decision`. - * Any node type may carry one; a node that does is a gate. Unknown keys at every level - * are preserved. + * Any node type may carry one. Unknown keys at every level are preserved. */ export type DecisionContract = { /** Shape version of the contract. A future shape change bumps it. */ @@ -81,9 +80,9 @@ export type DecisionContract = { uiSchema?: Record; /** * The proposal source: the node whose output the decider judges. Must be a direct - * predecessor of the gate; absent means the gate's only predecessor. + * predecessor of the deciding node; absent means its only predecessor. */ proposalSourceNodeId?: string; - /** Absent means the gate waits forever. */ + /** Absent means the node waits forever. */ deadline?: DecisionDeadline; }; diff --git a/packages/types/src/workflow-execution/execution-model.ts b/packages/types/src/workflow-execution/execution-model.ts index 3c923d4a4..982e37bf9 100644 --- a/packages/types/src/workflow-execution/execution-model.ts +++ b/packages/types/src/workflow-execution/execution-model.ts @@ -34,7 +34,10 @@ export type BaseNode = { // without knowing any product's vocabulary. label?: string; errorPolicy?: NodeErrorPolicy; - /** Present on a gate: the decision a human takes before the run continues. Nothing detects a gate by `type`. */ + /** + * The decision a human takes at this node before the run continues. This field's + * presence, never `type`, marks a node as one that waits for a decision. + */ decision?: DecisionContract; role?: NodeRole; }; From ad9e63d2bc4f0c866cdd4c131691e48cbf2a10ba Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Tue, 8 Sep 2026 06:54:32 +0200 Subject: [PATCH 08/25] feat(backend): validateSubmittedDecision builds a Decision from a submission A pure function the decision endpoint will call: it matches the submitted action against the contract, requires a reason on reject when the contract says so and a comment on rerun-source, checks every edited field for being declared, editable and, if required, not emptied, and derives resume-with-edits when a resume carries edits. The accepted result is a Decision carrying the matched action, the effect and what was submitted; refusals carry a code from the shared issue dictionary and a path into the request. Value types are a later validator. WB-500 --- .../src/domain/decision/decision-issues.ts | 25 ++- .../validate-submitted-decision.test.ts | 204 ++++++++++++++++++ .../decision/validate-submitted-decision.ts | 85 ++++++++ 3 files changed, 312 insertions(+), 2 deletions(-) create mode 100644 apps/backend/src/domain/decision/validate-submitted-decision.test.ts create mode 100644 apps/backend/src/domain/decision/validate-submitted-decision.ts diff --git a/apps/backend/src/domain/decision/decision-issues.ts b/apps/backend/src/domain/decision/decision-issues.ts index 44cae2e01..c2161ab4f 100644 --- a/apps/backend/src/domain/decision/decision-issues.ts +++ b/apps/backend/src/domain/decision/decision-issues.ts @@ -23,9 +23,30 @@ export const DECISION_ISSUE_MESSAGES = { export type DecisionIssueCode = keyof typeof DECISION_ISSUE_MESSAGES; -export function decisionIssueMessage(code: DecisionIssueCode, value?: string): string { +// Every way a submitted decision can be refused against the node's contract. Value +// types are not checked here (follow-up: decision-edit-value-validation) +export const SUBMITTED_DECISION_ERRORS = { + unknown_action: "the contract offers no action named '{value}'", + reason_required: "action '{value}' requires a reason", + comment_required: "action '{value}' requires a comment", + unknown_field: "field '{value}' is not in the decision schema", + field_not_editable: "field '{value}' is read-only", + required_field_missing: "required field '{value}' must not be emptied", +} as const; + +export type SubmittedDecisionErrorCode = keyof typeof SUBMITTED_DECISION_ERRORS; + +function fill(template: string, value: string | undefined): string { // A function replacer, so a value containing `$&` or `$1` lands verbatim. - return DECISION_ISSUE_MESSAGES[code].replace('{value}', () => value ?? ''); + return template.replace('{value}', () => value ?? ''); +} + +export function decisionIssueMessage(code: DecisionIssueCode, value?: string): string { + return fill(DECISION_ISSUE_MESSAGES[code], value); +} + +export function submittedDecisionErrorMessage(code: SubmittedDecisionErrorCode, value?: string): string { + return fill(SUBMITTED_DECISION_ERRORS[code], value); } export function decisionIssue(code: DecisionIssueCode, path: PropertyKey[], value?: string) { diff --git a/apps/backend/src/domain/decision/validate-submitted-decision.test.ts b/apps/backend/src/domain/decision/validate-submitted-decision.test.ts new file mode 100644 index 000000000..c56aa7474 --- /dev/null +++ b/apps/backend/src/domain/decision/validate-submitted-decision.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from 'vitest'; + +import type { DecisionContract } from '@workflow-builder/types/workflow-execution/decision-contract'; + +import { type SubmittedDecisionErrorCode, submittedDecisionErrorMessage } from './decision-issues'; +import { type SubmittedDecision, validateSubmittedDecision } from './validate-submitted-decision'; + +const approve = { name: 'approve', label: 'Approve', effect: 'resume', port: 'approved' } as const; +const reject = { name: 'reject', label: 'Reject', effect: 'reject', port: 'rejected', reasonRequired: false } as const; +const reRequest = { name: 're-request', label: 'Ask again', effect: 'rerun-source', maxIterations: 3 } as const; + +// As the parser leaves it: defaults present, every action explicit. +function contractWith(overrides: Partial = {}): DecisionContract { + return { + version: 1, + actions: [approve, reject, reRequest], + schema: { + type: 'object', + properties: { + orderDate: { type: 'string', readOnly: true }, + customerEmail: { type: 'string', readOnly: true, 'x-pii': true }, + refundAmount: { type: 'number' }, + emailDraft: { type: 'string', readOnly: false }, + note: { type: 'string' }, + }, + required: ['refundAmount'], + }, + ...overrides, + }; +} + +describe('validateSubmittedDecision', () => { + it.each<{ name: string; contract?: DecisionContract; call: SubmittedDecision; effect: string }>([ + { name: 'a resume without edits resumes', call: { action: 'approve' }, effect: 'resume' }, + { name: 'a resume with empty edits resumes', call: { action: 'approve', edits: {} }, effect: 'resume' }, + { + name: 'a resume with an edit on an editable field resumes with edits', + call: { action: 'approve', edits: { refundAmount: 42 } }, + effect: 'resume-with-edits', + }, + { + name: 'an explicit readOnly: false is editable', + call: { action: 'approve', edits: { emailDraft: 'Dear customer' } }, + effect: 'resume-with-edits', + }, + { + name: 'a required field set to a value is fine', + call: { action: 'approve', edits: { refundAmount: 0 } }, + effect: 'resume-with-edits', + }, + { + name: 'an optional field may be emptied', + call: { action: 'approve', edits: { note: '' } }, + effect: 'resume-with-edits', + }, + { name: 'a reject without a reason when none is required', call: { action: 'reject' }, effect: 'reject' }, + { + name: 'a reject with a reason when one is required', + contract: contractWith({ actions: [approve, { ...reject, reasonRequired: true }] }), + call: { action: 'reject', reason: 'Amount exceeds policy' }, + effect: 'reject', + }, + { + name: 'a rerun with a comment', + call: { action: 're-request', comment: 'Use the discounted price' }, + effect: 'rerun-source', + }, + ])('accepts $name', ({ contract = contractWith(), call, effect }) => { + const result = validateSubmittedDecision(contract, call); + + expect(result.error).toBeUndefined(); + expect(result.decision?.effect).toBe(effect); + expect(result.decision?.action.name).toBe(call.action); + }); + + it.each<{ + name: string; + contract?: DecisionContract; + call: SubmittedDecision; + code: SubmittedDecisionErrorCode; + value: string; + path: string[]; + }>([ + { + name: 'an action the contract does not offer', + call: { action: 'escalate' }, + code: 'unknown_action', + value: 'escalate', + path: ['action'], + }, + { + name: 'a reject on a contract without a reject action', + contract: contractWith({ actions: [approve] }), + call: { action: 'reject', reason: 'no' }, + code: 'unknown_action', + value: 'reject', + path: ['action'], + }, + { + name: 'a rerun on a contract without a rerun-source action', + contract: contractWith({ actions: [approve, reject] }), + call: { action: 're-request', comment: 'again' }, + code: 'unknown_action', + value: 're-request', + path: ['action'], + }, + { + name: 'a reject without a reason when one is required', + contract: contractWith({ actions: [approve, { ...reject, reasonRequired: true }] }), + call: { action: 'reject' }, + code: 'reason_required', + value: 'reject', + path: ['reason'], + }, + { + name: 'a reject with a blank reason when one is required', + contract: contractWith({ actions: [approve, { ...reject, reasonRequired: true }] }), + call: { action: 'reject', reason: ' ' }, + code: 'reason_required', + value: 'reject', + path: ['reason'], + }, + { + name: 'a rerun without a comment', + call: { action: 're-request' }, + code: 'comment_required', + value: 're-request', + path: ['comment'], + }, + { + name: 'a rerun with a whitespace-only comment', + call: { action: 're-request', comment: ' \n ' }, + code: 'comment_required', + value: 're-request', + path: ['comment'], + }, + { + name: 'an edit on a read-only field', + call: { action: 'approve', edits: { orderDate: '2026-01-01' } }, + code: 'field_not_editable', + value: 'orderDate', + path: ['edits', 'orderDate'], + }, + { + name: 'an edit on a field the schema does not declare', + call: { action: 'approve', edits: { discount: 10 } }, + code: 'unknown_field', + value: 'discount', + path: ['edits', 'discount'], + }, + { + name: 'an edit on a field that exists only on Object.prototype', + call: { action: 'approve', edits: { constructor: 1 } }, + code: 'unknown_field', + value: 'constructor', + path: ['edits', 'constructor'], + }, + { + name: 'a required field emptied with an empty string', + call: { action: 'approve', edits: { refundAmount: '' } }, + code: 'required_field_missing', + value: 'refundAmount', + path: ['edits', 'refundAmount'], + }, + { + name: 'a required field emptied with null', + call: { action: 'approve', edits: { refundAmount: null } }, + code: 'required_field_missing', + value: 'refundAmount', + path: ['edits', 'refundAmount'], + }, + { + name: 'a required field emptied with undefined', + call: { action: 'approve', edits: { refundAmount: undefined } }, + code: 'required_field_missing', + value: 'refundAmount', + path: ['edits', 'refundAmount'], + }, + ])('refuses $name', ({ contract = contractWith(), call, code, value, path }) => { + expect(validateSubmittedDecision(contract, call)).toEqual({ + error: { code, message: submittedDecisionErrorMessage(code, value), path }, + }); + }); + + it('builds the decision from the matched action and what was submitted', () => { + const submitted = { action: 'approve', edits: { refundAmount: 12 }, comment: 'rounded down' }; + + expect(validateSubmittedDecision(contractWith(), submitted).decision).toEqual({ + action: approve, + effect: 'resume-with-edits', + edits: { refundAmount: 12 }, + comment: 'rounded down', + }); + }); + + it('defaults edits to an empty object when none were submitted', () => { + expect(validateSubmittedDecision(contractWith(), { action: 'reject', reason: 'late' }).decision).toEqual({ + action: reject, + effect: 'reject', + edits: {}, + reason: 'late', + }); + }); +}); diff --git a/apps/backend/src/domain/decision/validate-submitted-decision.ts b/apps/backend/src/domain/decision/validate-submitted-decision.ts new file mode 100644 index 000000000..9c51cd013 --- /dev/null +++ b/apps/backend/src/domain/decision/validate-submitted-decision.ts @@ -0,0 +1,85 @@ +import type { + DecisionAction, + DecisionContract, + DecisionEffect, +} from '@workflow-builder/types/workflow-execution/decision-contract'; + +import { type SubmittedDecisionErrorCode, submittedDecisionErrorMessage } from './decision-issues'; + +// What the decider sent, before anything has checked it. Provisional: the decision +// endpoint owns the public request shape and may rename these. +export type SubmittedDecision = { + action: string; + edits?: Record; + reason?: string; + comment?: string; +}; + +// A submission the contract accepts. The matched action carries the port to route on; +// `effect` is `resume-with-edits` when a resume came with edits. +export type Decision = { + action: DecisionAction; + effect: DecisionEffect; + edits: Record; + reason?: string; + comment?: string; +}; + +export type SubmittedDecisionError = { code: SubmittedDecisionErrorCode; message: string; path?: string[] }; + +export type SubmittedDecisionResult = + | { decision: Decision; error?: undefined } + | { decision?: undefined; error: SubmittedDecisionError }; + +function refuse(code: SubmittedDecisionErrorCode, value: string, path: string[]): SubmittedDecisionResult { + return { error: { code, message: submittedDecisionErrorMessage(code, value), path } }; +} + +function isBlank(text: string | undefined): boolean { + return text === undefined || text.trim().length === 0; +} + +// "Emptied" means the decider cleared the field, not that they typed something invalid. +function isEmptied(value: unknown): boolean { + return value === undefined || value === null || (typeof value === 'string' && value.trim().length === 0); +} + +// The contract arrived through the parser, so `schema` has the shape checked there; +// the reads below only narrow what `Record` hides. +function formProperties(contract: DecisionContract): Record { + return (contract.schema['properties'] ?? {}) as Record; +} + +function requiredFields(contract: DecisionContract): string[] { + return (contract.schema['required'] ?? []) as string[]; +} + +// Presence and editability only. Whether an edited value fits its declared type is a +// later concern with its own validator. +export function validateSubmittedDecision( + contract: DecisionContract, + submitted: SubmittedDecision, +): SubmittedDecisionResult { + const action = contract.actions.find((candidate) => candidate.name === submitted.action); + if (action === undefined) return refuse('unknown_action', submitted.action, ['action']); + + if (action.effect === 'reject' && action.reasonRequired && isBlank(submitted.reason)) { + return refuse('reason_required', action.name, ['reason']); + } + if (action.effect === 'rerun-source' && isBlank(submitted.comment)) { + return refuse('comment_required', action.name, ['comment']); + } + + const properties = formProperties(contract); + const required = new Set(requiredFields(contract)); + const edits = submitted.edits ?? {}; + for (const [field, value] of Object.entries(edits)) { + if (!Object.hasOwn(properties, field)) return refuse('unknown_field', field, ['edits', field]); + if (properties[field].readOnly === true) return refuse('field_not_editable', field, ['edits', field]); + if (required.has(field) && isEmptied(value)) return refuse('required_field_missing', field, ['edits', field]); + } + + const withEdits = Object.keys(edits).length > 0; + const effect: DecisionEffect = action.effect === 'resume' && withEdits ? 'resume-with-edits' : action.effect; + return { decision: { action, effect, edits, reason: submitted.reason, comment: submitted.comment } }; +} From d07bcaa65b549359d264644e4828b33b02b0010c Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Tue, 8 Sep 2026 07:34:46 +0200 Subject: [PATCH 09/25] refactor: name the node's decision contract a DecisionRequest The field on a node holds what the node asks a human to decide, not a decision, so it is now data.properties.decisionRequest and BaseNode.decisionRequest, typed DecisionRequest. That frees the word decision for the lifecycle it now names end to end: DecisionRequest is what the node asks, SubmittedDecision is what the decider sends, Decision is what validation accepts. Issue keys follow (node_without_decision_request, source_has_decision_request). The example rerun action is ask-again, so request means one thing in an authored snapshot. WB-500 --- .../domain/decision/decision-issues.test.ts | 4 +- .../src/domain/decision/decision-issues.ts | 10 +- ...est.ts => decision-request-schema.test.ts} | 114 +++++++++--------- ...t-schema.ts => decision-request-schema.ts} | 12 +- .../domain/decision/proposal-source.test.ts | 22 ++-- .../src/domain/decision/proposal-source.ts | 8 +- .../validate-submitted-decision.test.ts | 54 ++++----- .../decision/validate-submitted-decision.ts | 24 ++-- .../domain/mapper/from-integration-data.ts | 6 +- .../src/domain/mapper/snapshot-schema.test.ts | 97 ++++++++------- .../src/domain/mapper/snapshot-schema.ts | 35 +++--- apps/backend/src/routes/workflows.test.ts | 10 +- ...cision-contract.ts => decision-request.ts} | 20 +-- .../src/workflow-execution/execution-model.ts | 6 +- 14 files changed, 217 insertions(+), 205 deletions(-) rename apps/backend/src/domain/decision/{decision-contract-schema.test.ts => decision-request-schema.test.ts} (65%) rename apps/backend/src/domain/decision/{decision-contract-schema.ts => decision-request-schema.ts} (93%) rename packages/types/src/workflow-execution/{decision-contract.ts => decision-request.ts} (83%) diff --git a/apps/backend/src/domain/decision/decision-issues.test.ts b/apps/backend/src/domain/decision/decision-issues.test.ts index 62b729af8..98022c8b8 100644 --- a/apps/backend/src/domain/decision/decision-issues.test.ts +++ b/apps/backend/src/domain/decision/decision-issues.test.ts @@ -10,8 +10,8 @@ describe('decisionIssueMessage', () => { }); it('keeps replacement patterns in the value verbatim', () => { - expect(decisionIssueMessage('source_has_decision', '$&-$1')).toBe( - "proposal source '$&-$1' carries its own decision contract and cannot be re-run", + expect(decisionIssueMessage('source_has_decision_request', '$&-$1')).toBe( + "proposal source '$&-$1' carries its own decision request and cannot be re-run", ); }); diff --git a/apps/backend/src/domain/decision/decision-issues.ts b/apps/backend/src/domain/decision/decision-issues.ts index c2161ab4f..7224cd368 100644 --- a/apps/backend/src/domain/decision/decision-issues.ts +++ b/apps/backend/src/domain/decision/decision-issues.ts @@ -1,4 +1,4 @@ -// Every message the decision-contract validation can produce. `{value}` is the one +// Every message the decision-request validation can produce. `{value}` is the one // interpolation slot. Structural failures (wrong type, missing key) keep zod's wording. export const DECISION_ISSUE_MESSAGES = { actions_empty: 'at least one action is required', @@ -14,19 +14,19 @@ export const DECISION_ISSUE_MESSAGES = { required_field_undeclared: "required field '{value}' is not declared in properties", deadline_format: "must be a positive duration such as '30s', '24h' or '3d' (a number followed by ms, s, m, h or d)", deadline_policy: "policy must be 'reject'", - source_node_without_decision: 'this node carries no decision contract', + source_node_without_decision_request: 'this node carries no decision request', source_not_a_predecessor: "proposalSourceNodeId '{value}' is not a direct predecessor of this node", source_missing: 'a rerun-source action needs a proposal source, but this node has no predecessor', source_ambiguous: 'several predecessors; set proposalSourceNodeId to say which one rerun-source re-runs', - source_has_decision: "proposal source '{value}' carries its own decision contract and cannot be re-run", + source_has_decision_request: "proposal source '{value}' carries its own decision request and cannot be re-run", } as const; export type DecisionIssueCode = keyof typeof DECISION_ISSUE_MESSAGES; -// Every way a submitted decision can be refused against the node's contract. Value +// Every way a submitted decision can be refused against the node's decision request. Value // types are not checked here (follow-up: decision-edit-value-validation) export const SUBMITTED_DECISION_ERRORS = { - unknown_action: "the contract offers no action named '{value}'", + unknown_action: "the decision request offers no action named '{value}'", reason_required: "action '{value}' requires a reason", comment_required: "action '{value}' requires a comment", unknown_field: "field '{value}' is not in the decision schema", diff --git a/apps/backend/src/domain/decision/decision-contract-schema.test.ts b/apps/backend/src/domain/decision/decision-request-schema.test.ts similarity index 65% rename from apps/backend/src/domain/decision/decision-contract-schema.test.ts rename to apps/backend/src/domain/decision/decision-request-schema.test.ts index 0c3ca6d56..7afd6f830 100644 --- a/apps/backend/src/domain/decision/decision-contract-schema.test.ts +++ b/apps/backend/src/domain/decision/decision-request-schema.test.ts @@ -3,15 +3,15 @@ import type { z } from 'zod'; import { DECLARABLE_DECISION_EFFECTS, - type DecisionContract, -} from '@workflow-builder/types/workflow-execution/decision-contract'; + type DecisionRequest, +} from '@workflow-builder/types/workflow-execution/decision-request'; -import { decisionContractSchema } from './decision-contract-schema'; import { type DecisionIssueCode, decisionIssueMessage } from './decision-issues'; +import { decisionRequestSchema } from './decision-request-schema'; const approve = { name: 'approve', label: 'Approve', effect: 'resume', port: 'approved' }; const reject = { name: 'reject', label: 'Reject', effect: 'reject', port: 'rejected', reasonRequired: false }; -const reRequest = { name: 're-request', label: 'Ask again', effect: 'rerun-source', maxIterations: 3 }; +const askAgain = { name: 'ask-again', label: 'Ask again', effect: 'rerun-source', maxIterations: 3 }; const refundForm = { type: 'object', @@ -28,7 +28,7 @@ const refundForm = { function workedExample() { return { version: 1, - actions: [approve, reject, reRequest], + actions: [approve, reject, askAgain], schema: refundForm, uiSchema: { type: 'VerticalLayout', elements: [] }, proposalSourceNodeId: 'source-1', @@ -36,12 +36,12 @@ function workedExample() { }; } -function contract(overrides: Record = {}): unknown { +function request(overrides: Record = {}): unknown { return { ...workedExample(), ...overrides }; } function issuesOf(input: unknown): { path: string; message: string }[] { - const result = decisionContractSchema.safeParse(input); + const result = decisionRequestSchema.safeParse(input); return result.success ? [] : result.error.issues.map((issue) => ({ path: issue.path.join('.'), message: issue.message })); @@ -49,35 +49,35 @@ function issuesOf(input: unknown): { path: string; message: string }[] { const declarableEffects = DECLARABLE_DECISION_EFFECTS.join(', '); -describe('decisionContractSchema', () => { +describe('decisionRequestSchema', () => { it('accepts the refund worked example', () => { - expect(decisionContractSchema.safeParse(workedExample()).success).toBe(true); + expect(decisionRequestSchema.safeParse(workedExample()).success).toBe(true); }); - it('accepts a minimal contract: one resume action and an empty form', () => { + it('accepts a minimal request: one resume action and an empty form', () => { const minimal = { version: 1, actions: [{ name: 'ok', label: 'OK', effect: 'resume' }], schema: { type: 'object', properties: {} }, }; - expect(decisionContractSchema.safeParse(minimal).success).toBe(true); + expect(decisionRequestSchema.safeParse(minimal).success).toBe(true); }); it.each([...DECLARABLE_DECISION_EFFECTS])('accepts a declared %s action', (effect) => { const resume = { name: 'ok', label: 'OK', effect: 'resume' }; const actions = effect === 'resume' ? [resume] : [resume, { name: 'other', label: 'Other', effect }]; - expect(decisionContractSchema.safeParse(contract({ actions })).success).toBe(true); + expect(decisionRequestSchema.safeParse(request({ actions })).success).toBe(true); }); it('materialises the defaults for port, reasonRequired and maxIterations', () => { - const parsed = decisionContractSchema.parse( - contract({ + const parsed = decisionRequestSchema.parse( + request({ actions: [ { name: 'approve', label: 'Approve', effect: 'resume' }, { name: 'reject', label: 'Reject', effect: 'reject' }, - { name: 're-request', label: 'Ask again', effect: 'rerun-source' }, + { name: 'ask-again', label: 'Ask again', effect: 'rerun-source' }, ], }), ); @@ -85,7 +85,7 @@ describe('decisionContractSchema', () => { expect(parsed.actions).toEqual([ { name: 'approve', label: 'Approve', effect: 'resume', port: 'approved' }, { name: 'reject', label: 'Reject', effect: 'reject', port: 'rejected', reasonRequired: false }, - { name: 're-request', label: 'Ask again', effect: 'rerun-source', maxIterations: 3 }, + { name: 'ask-again', label: 'Ask again', effect: 'rerun-source', maxIterations: 3 }, ]); }); @@ -106,7 +106,7 @@ describe('decisionContractSchema', () => { deadline: { after: '3d', policy: 'reject', warnAfter: '2d' }, }; - const parsed = decisionContractSchema.parse(input); + const parsed = decisionRequestSchema.parse(input); expect(parsed).toMatchObject({ audience: 'finance', @@ -123,210 +123,210 @@ describe('decisionContractSchema', () => { it('accepts an explicit readOnly: false', () => { const schema = { type: 'object', properties: { amount: { type: 'number', readOnly: false } } }; - const parsed = decisionContractSchema.parse(contract({ schema })); + const parsed = decisionRequestSchema.parse(request({ schema })); expect(parsed.schema.properties['amount']).toEqual({ type: 'number', readOnly: false }); }); it.each(['100ms', '30s', '10m', '1.5h', '24h', '3d', '7d'])('accepts a deadline of %s', (after) => { - expect(decisionContractSchema.safeParse(contract({ deadline: { after, policy: 'reject' } })).success).toBe(true); + expect(decisionRequestSchema.safeParse(request({ deadline: { after, policy: 'reject' } })).success).toBe(true); }); - it('accepts a contract without deadline, uiSchema or proposalSourceNodeId', () => { + it('accepts a request without deadline, uiSchema or proposalSourceNodeId', () => { const { version, actions, schema } = workedExample(); - expect(decisionContractSchema.safeParse({ version, actions, schema }).success).toBe(true); + expect(decisionRequestSchema.safeParse({ version, actions, schema }).success).toBe(true); }); // `issue` names the dictionary entry expected at `path`; rows without one fail on zod's // own structural check. it.each<{ name: string; input: unknown; path: string; issue?: { code: DecisionIssueCode; value?: string } }>([ - { name: 'a version other than 1', input: contract({ version: 2 }), path: 'version' }, + { name: 'a version other than 1', input: request({ version: 2 }), path: 'version' }, { name: 'an empty action list', - input: contract({ actions: [] }), + input: request({ actions: [] }), path: 'actions', issue: { code: 'actions_empty' }, }, { name: 'a duplicate action name', - input: contract({ actions: [approve, { ...reject, name: 'approve' }] }), + input: request({ actions: [approve, { ...reject, name: 'approve' }] }), path: 'actions.1.name', issue: { code: 'duplicate_action_name', value: 'approve' }, }, { name: 'an effect outside the declarable set', - input: contract({ actions: [{ ...approve, effect: 'escalate' }] }), + input: request({ actions: [{ ...approve, effect: 'escalate' }] }), path: 'actions.0.effect', issue: { code: 'unknown_effect', value: declarableEffects }, }, { name: "a declared 'resume-with-edits'", - input: contract({ actions: [approve, { ...reject, effect: 'resume-with-edits' }] }), + input: request({ actions: [approve, { ...reject, effect: 'resume-with-edits' }] }), path: 'actions.1.effect', issue: { code: 'unknown_effect', value: declarableEffects }, }, { name: 'no resume action', - input: contract({ actions: [reject] }), + input: request({ actions: [reject] }), path: 'actions', issue: { code: 'resume_required' }, }, { name: 'two resume actions', - input: contract({ actions: [approve, { ...approve, name: 'approve-2' }] }), + input: request({ actions: [approve, { ...approve, name: 'approve-2' }] }), path: 'actions.1.effect', issue: { code: 'duplicate_effect', value: 'resume' }, }, { name: 'two reject actions', - input: contract({ actions: [approve, reject, { ...reject, name: 'decline' }] }), + input: request({ actions: [approve, reject, { ...reject, name: 'decline' }] }), path: 'actions.2.effect', issue: { code: 'duplicate_effect', value: 'reject' }, }, { name: 'two rerun-source actions', - input: contract({ actions: [approve, reRequest, { ...reRequest, name: 'retry' }] }), + input: request({ actions: [approve, askAgain, { ...askAgain, name: 'retry' }] }), path: 'actions.2.effect', issue: { code: 'duplicate_effect', value: 'rerun-source' }, }, { name: 'an empty action name', - input: contract({ actions: [{ ...approve, name: '' }] }), + input: request({ actions: [{ ...approve, name: '' }] }), path: 'actions.0.name', issue: { code: 'name_empty' }, }, { name: 'an empty action label', - input: contract({ actions: [{ ...approve, label: '' }] }), + input: request({ actions: [{ ...approve, label: '' }] }), path: 'actions.0.label', issue: { code: 'label_empty' }, }, { name: 'an empty resume port', - input: contract({ actions: [{ ...approve, port: '' }] }), + input: request({ actions: [{ ...approve, port: '' }] }), path: 'actions.0.port', issue: { code: 'port_empty' }, }, { name: "a resume port of 'errorRoute'", - input: contract({ actions: [{ ...approve, port: 'errorRoute' }] }), + input: request({ actions: [{ ...approve, port: 'errorRoute' }] }), path: 'actions.0.port', issue: { code: 'port_reserved' }, }, { name: "a reject port of 'errorRoute'", - input: contract({ actions: [approve, { ...reject, port: 'errorRoute' }] }), + input: request({ actions: [approve, { ...reject, port: 'errorRoute' }] }), path: 'actions.1.port', issue: { code: 'port_reserved' }, }, { name: 'a reject port equal to the resume port', - input: contract({ actions: [approve, { ...reject, port: 'approved' }] }), + input: request({ actions: [approve, { ...reject, port: 'approved' }] }), path: 'actions.1.port', issue: { code: 'reject_port_equals_resume_port', value: 'approved' }, }, { name: 'a non-boolean reasonRequired', - input: contract({ actions: [approve, { ...reject, reasonRequired: 'yes' }] }), + input: request({ actions: [approve, { ...reject, reasonRequired: 'yes' }] }), path: 'actions.1.reasonRequired', }, { name: 'maxIterations below 1', - input: contract({ actions: [approve, { ...reRequest, maxIterations: 0 }] }), + input: request({ actions: [approve, { ...askAgain, maxIterations: 0 }] }), path: 'actions.1.maxIterations', }, { name: 'a fractional maxIterations', - input: contract({ actions: [approve, { ...reRequest, maxIterations: 1.5 }] }), + input: request({ actions: [approve, { ...askAgain, maxIterations: 1.5 }] }), path: 'actions.1.maxIterations', }, { name: "a form schema whose type is not 'object'", - input: contract({ schema: { ...refundForm, type: 'array' } }), + input: request({ schema: { ...refundForm, type: 'array' } }), path: 'schema.type', }, { name: 'a form schema without properties', - input: contract({ schema: { type: 'object' } }), + input: request({ schema: { type: 'object' } }), path: 'schema.properties', }, { name: 'a form property without a type', - input: contract({ schema: { type: 'object', properties: { refundAmount: { title: 'Refund amount' } } } }), + input: request({ schema: { type: 'object', properties: { refundAmount: { title: 'Refund amount' } } } }), path: 'schema.properties.refundAmount.type', }, { name: 'a non-boolean readOnly', - input: contract({ schema: { type: 'object', properties: { orderDate: { type: 'string', readOnly: 'true' } } } }), + input: request({ schema: { type: 'object', properties: { orderDate: { type: 'string', readOnly: 'true' } } } }), path: 'schema.properties.orderDate.readOnly', }, { name: 'a non-boolean x-pii', - input: contract({ schema: { type: 'object', properties: { email: { type: 'string', 'x-pii': 'yes' } } } }), + input: request({ schema: { type: 'object', properties: { email: { type: 'string', 'x-pii': 'yes' } } } }), path: 'schema.properties.email.x-pii', }, { name: 'a required field that is not declared', - input: contract({ schema: { ...refundForm, required: ['discount'] } }), + input: request({ schema: { ...refundForm, required: ['discount'] } }), path: 'schema.required.0', issue: { code: 'required_field_undeclared', value: 'discount' }, }, { name: 'a required field that exists only on Object.prototype', - input: contract({ schema: { ...refundForm, required: ['constructor'] } }), + input: request({ schema: { ...refundForm, required: ['constructor'] } }), path: 'schema.required.0', issue: { code: 'required_field_undeclared', value: 'constructor' }, }, { name: 'a deadline without a unit', - input: contract({ deadline: { after: '3', policy: 'reject' } }), + input: request({ deadline: { after: '3', policy: 'reject' } }), path: 'deadline.after', issue: { code: 'deadline_format' }, }, { name: 'a deadline of zero', - input: contract({ deadline: { after: '0s', policy: 'reject' } }), + input: request({ deadline: { after: '0s', policy: 'reject' } }), path: 'deadline.after', issue: { code: 'deadline_format' }, }, { name: 'a negative deadline', - input: contract({ deadline: { after: '-5m', policy: 'reject' } }), + input: request({ deadline: { after: '-5m', policy: 'reject' } }), path: 'deadline.after', issue: { code: 'deadline_format' }, }, { name: 'a deadline beyond the protobuf Duration range', - input: contract({ deadline: { after: '3652501d', policy: 'reject' } }), + input: request({ deadline: { after: '3652501d', policy: 'reject' } }), path: 'deadline.after', issue: { code: 'deadline_format' }, }, - { name: 'a deadline without a policy', input: contract({ deadline: { after: '3d' } }), path: 'deadline.policy' }, + { name: 'a deadline without a policy', input: request({ deadline: { after: '3d' } }), path: 'deadline.policy' }, { name: "a deadline policy other than 'reject'", - input: contract({ deadline: { after: '3d', policy: 'escalate' } }), + input: request({ deadline: { after: '3d', policy: 'escalate' } }), path: 'deadline.policy', issue: { code: 'deadline_policy' }, }, - { name: 'a uiSchema that is not an object', input: contract({ uiSchema: 'vertical' }), path: 'uiSchema' }, + { name: 'a uiSchema that is not an object', input: request({ uiSchema: 'vertical' }), path: 'uiSchema' }, { name: 'a non-string proposalSourceNodeId', - input: contract({ proposalSourceNodeId: 42 }), + input: request({ proposalSourceNodeId: 42 }), path: 'proposalSourceNodeId', }, ])('rejects $name', ({ input, path, issue }) => { const issues = issuesOf(input); const atPath = issues.filter((candidate) => candidate.path === path); - expect(decisionContractSchema.safeParse(input).success).toBe(false); + expect(decisionRequestSchema.safeParse(input).success).toBe(false); expect(atPath.length).toBeGreaterThan(0); if (issue !== undefined) { expect(atPath.map((candidate) => candidate.message)).toContain(decisionIssueMessage(issue.code, issue.value)); } }); - it('parses into a value assignable to DecisionContract', () => { - expectTypeOf>().toMatchTypeOf(); + it('parses into a value assignable to DecisionRequest', () => { + expectTypeOf>().toMatchTypeOf(); }); }); diff --git a/apps/backend/src/domain/decision/decision-contract-schema.ts b/apps/backend/src/domain/decision/decision-request-schema.ts similarity index 93% rename from apps/backend/src/domain/decision/decision-contract-schema.ts rename to apps/backend/src/domain/decision/decision-request-schema.ts index b5b28e831..172850395 100644 --- a/apps/backend/src/domain/decision/decision-contract-schema.ts +++ b/apps/backend/src/domain/decision/decision-request-schema.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; -import { DECLARABLE_DECISION_EFFECTS } from '@workflow-builder/types/workflow-execution/decision-contract'; +import { DECLARABLE_DECISION_EFFECTS } from '@workflow-builder/types/workflow-execution/decision-request'; import { decisionIssue, decisionIssueMessage } from './decision-issues'; @@ -88,7 +88,7 @@ const deadlineSchema = z.looseObject({ policy: z.string().refine((policy) => policy === 'reject', decisionIssueMessage('deadline_policy')), }); -export const decisionContractSchema = z +export const decisionRequestSchema = z .looseObject({ version: z.literal(1), actions: z.array(decisionActionSchema).min(1, decisionIssueMessage('actions_empty')), @@ -97,11 +97,11 @@ export const decisionContractSchema = z proposalSourceNodeId: z.string().optional(), deadline: deadlineSchema.optional(), }) - .superRefine((contract, context) => { + .superRefine((request, context) => { const seenNames = new Set(); const firstIndexByEffect = new Map(); - for (const [index, action] of contract.actions.entries()) { + for (const [index, action] of request.actions.entries()) { if (seenNames.has(action.name)) { context.addIssue(decisionIssue('duplicate_action_name', ['actions', index, 'name'], action.name)); } @@ -122,8 +122,8 @@ export const decisionContractSchema = z const rejectIndex = firstIndexByEffect.get('reject'); if (rejectIndex === undefined) return; - const resume = contract.actions[resumeIndex]; - const reject = contract.actions[rejectIndex]; + const resume = request.actions[resumeIndex]; + const reject = request.actions[rejectIndex]; if (resume.effect === 'resume' && reject.effect === 'reject' && resume.port === reject.port) { context.addIssue(decisionIssue('reject_port_equals_resume_port', ['actions', rejectIndex, 'port'], reject.port)); } diff --git a/apps/backend/src/domain/decision/proposal-source.test.ts b/apps/backend/src/domain/decision/proposal-source.test.ts index bd3d6b0b3..3cfacee39 100644 --- a/apps/backend/src/domain/decision/proposal-source.test.ts +++ b/apps/backend/src/domain/decision/proposal-source.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from 'vitest'; -import type { DecisionContract } from '@workflow-builder/types/workflow-execution/decision-contract'; +import type { DecisionRequest } from '@workflow-builder/types/workflow-execution/decision-request'; import { resolveProposalSource } from './proposal-source'; -function contract(proposalSourceNodeId?: string): DecisionContract { +function request(proposalSourceNodeId?: string): DecisionRequest { return { version: 1, actions: [{ name: 'approve', label: 'Approve', effect: 'resume', port: 'approved' }], @@ -18,48 +18,48 @@ function edge(sourceNodeId: string, targetNodeId: string) { } describe('resolveProposalSource', () => { - it('reports a node without a contract, or an unknown id, as a node without decision', () => { + it('reports a node without a request, or an unknown id, as a node without a decision request', () => { const nodes = [{ id: 'plain' }]; - expect(resolveProposalSource(nodes, [], 'plain')).toEqual({ error: 'node_without_decision' }); - expect(resolveProposalSource(nodes, [], 'missing')).toEqual({ error: 'node_without_decision' }); + expect(resolveProposalSource(nodes, [], 'plain')).toEqual({ error: 'node_without_decision_request' }); + expect(resolveProposalSource(nodes, [], 'missing')).toEqual({ error: 'node_without_decision_request' }); }); it('returns an explicit source that is a direct predecessor', () => { - const nodes = [{ id: 'a' }, { id: 'b' }, { id: 'review', decision: contract('a') }]; + const nodes = [{ id: 'a' }, { id: 'b' }, { id: 'review', decisionRequest: request('a') }]; const edges = [edge('a', 'review'), edge('b', 'review')]; expect(resolveProposalSource(nodes, edges, 'review')).toEqual({ sourceNodeId: 'a' }); }); it('rejects an explicit source that is not a direct predecessor, including a successor', () => { - const nodes = [{ id: 'a' }, { id: 'after' }, { id: 'review', decision: contract('after') }]; + const nodes = [{ id: 'a' }, { id: 'after' }, { id: 'review', decisionRequest: request('after') }]; const edges = [edge('a', 'review'), edge('review', 'after')]; expect(resolveProposalSource(nodes, edges, 'review')).toEqual({ error: 'explicit_source_not_a_predecessor' }); }); it('falls back to the only direct predecessor when no source is declared', () => { - const nodes = [{ id: 'a' }, { id: 'review', decision: contract() }]; + const nodes = [{ id: 'a' }, { id: 'review', decisionRequest: request() }]; expect(resolveProposalSource(nodes, [edge('a', 'review')], 'review')).toEqual({ sourceNodeId: 'a' }); }); it('counts parallel edges from one node as a single predecessor', () => { - const nodes = [{ id: 'a' }, { id: 'review', decision: contract() }]; + const nodes = [{ id: 'a' }, { id: 'review', decisionRequest: request() }]; const edges = [edge('a', 'review'), edge('a', 'review')]; expect(resolveProposalSource(nodes, edges, 'review')).toEqual({ sourceNodeId: 'a' }); }); it('reports no predecessor when the node has only outgoing edges', () => { - const nodes = [{ id: 'review', decision: contract() }, { id: 'after' }]; + const nodes = [{ id: 'review', decisionRequest: request() }, { id: 'after' }]; expect(resolveProposalSource(nodes, [edge('review', 'after')], 'review')).toEqual({ error: 'no_predecessor' }); }); it('reports ambiguity when several predecessors exist and none is declared', () => { - const nodes = [{ id: 'a' }, { id: 'b' }, { id: 'review', decision: contract() }]; + const nodes = [{ id: 'a' }, { id: 'b' }, { id: 'review', decisionRequest: request() }]; const edges = [edge('a', 'review'), edge('b', 'review')]; expect(resolveProposalSource(nodes, edges, 'review')).toEqual({ error: 'ambiguous_predecessor' }); diff --git a/apps/backend/src/domain/decision/proposal-source.ts b/apps/backend/src/domain/decision/proposal-source.ts index 24daa3629..da4a6b4cf 100644 --- a/apps/backend/src/domain/decision/proposal-source.ts +++ b/apps/backend/src/domain/decision/proposal-source.ts @@ -2,11 +2,11 @@ import { unique } from 'remeda'; import type { BaseNode, WorkflowEdgeDefinition } from '@workflow-builder/types/workflow-execution/execution-model'; -type GraphNode = Pick; +type GraphNode = Pick; type GraphEdge = Pick; export type UnresolvedSourceReason = - | 'node_without_decision' + | 'node_without_decision_request' | 'explicit_source_not_a_predecessor' | 'no_predecessor' | 'ambiguous_predecessor'; @@ -25,10 +25,10 @@ export function resolveProposalSource( nodeId: string, ): ProposalSourceResolution { const node = nodes.find((candidate) => candidate.id === nodeId); - if (node?.decision === undefined) return { error: 'node_without_decision' }; + if (node?.decisionRequest === undefined) return { error: 'node_without_decision_request' }; const predecessors = unique(edges.filter((edge) => edge.targetNodeId === nodeId).map((edge) => edge.sourceNodeId)); - const explicit = node.decision.proposalSourceNodeId; + const explicit = node.decisionRequest.proposalSourceNodeId; if (explicit !== undefined) { return predecessors.includes(explicit) ? { sourceNodeId: explicit } diff --git a/apps/backend/src/domain/decision/validate-submitted-decision.test.ts b/apps/backend/src/domain/decision/validate-submitted-decision.test.ts index c56aa7474..c469b2273 100644 --- a/apps/backend/src/domain/decision/validate-submitted-decision.test.ts +++ b/apps/backend/src/domain/decision/validate-submitted-decision.test.ts @@ -1,19 +1,19 @@ import { describe, expect, it } from 'vitest'; -import type { DecisionContract } from '@workflow-builder/types/workflow-execution/decision-contract'; +import type { DecisionRequest } from '@workflow-builder/types/workflow-execution/decision-request'; import { type SubmittedDecisionErrorCode, submittedDecisionErrorMessage } from './decision-issues'; import { type SubmittedDecision, validateSubmittedDecision } from './validate-submitted-decision'; const approve = { name: 'approve', label: 'Approve', effect: 'resume', port: 'approved' } as const; const reject = { name: 'reject', label: 'Reject', effect: 'reject', port: 'rejected', reasonRequired: false } as const; -const reRequest = { name: 're-request', label: 'Ask again', effect: 'rerun-source', maxIterations: 3 } as const; +const askAgain = { name: 'ask-again', label: 'Ask again', effect: 'rerun-source', maxIterations: 3 } as const; // As the parser leaves it: defaults present, every action explicit. -function contractWith(overrides: Partial = {}): DecisionContract { +function requestWith(overrides: Partial = {}): DecisionRequest { return { version: 1, - actions: [approve, reject, reRequest], + actions: [approve, reject, askAgain], schema: { type: 'object', properties: { @@ -30,7 +30,7 @@ function contractWith(overrides: Partial = {}): DecisionContra } describe('validateSubmittedDecision', () => { - it.each<{ name: string; contract?: DecisionContract; call: SubmittedDecision; effect: string }>([ + it.each<{ name: string; request?: DecisionRequest; call: SubmittedDecision; effect: string }>([ { name: 'a resume without edits resumes', call: { action: 'approve' }, effect: 'resume' }, { name: 'a resume with empty edits resumes', call: { action: 'approve', edits: {} }, effect: 'resume' }, { @@ -56,17 +56,17 @@ describe('validateSubmittedDecision', () => { { name: 'a reject without a reason when none is required', call: { action: 'reject' }, effect: 'reject' }, { name: 'a reject with a reason when one is required', - contract: contractWith({ actions: [approve, { ...reject, reasonRequired: true }] }), + request: requestWith({ actions: [approve, { ...reject, reasonRequired: true }] }), call: { action: 'reject', reason: 'Amount exceeds policy' }, effect: 'reject', }, { name: 'a rerun with a comment', - call: { action: 're-request', comment: 'Use the discounted price' }, + call: { action: 'ask-again', comment: 'Use the discounted price' }, effect: 'rerun-source', }, - ])('accepts $name', ({ contract = contractWith(), call, effect }) => { - const result = validateSubmittedDecision(contract, call); + ])('accepts $name', ({ request = requestWith(), call, effect }) => { + const result = validateSubmittedDecision(request, call); expect(result.error).toBeUndefined(); expect(result.decision?.effect).toBe(effect); @@ -75,38 +75,38 @@ describe('validateSubmittedDecision', () => { it.each<{ name: string; - contract?: DecisionContract; + request?: DecisionRequest; call: SubmittedDecision; code: SubmittedDecisionErrorCode; value: string; path: string[]; }>([ { - name: 'an action the contract does not offer', + name: 'an action the request does not offer', call: { action: 'escalate' }, code: 'unknown_action', value: 'escalate', path: ['action'], }, { - name: 'a reject on a contract without a reject action', - contract: contractWith({ actions: [approve] }), + name: 'a reject on a request without a reject action', + request: requestWith({ actions: [approve] }), call: { action: 'reject', reason: 'no' }, code: 'unknown_action', value: 'reject', path: ['action'], }, { - name: 'a rerun on a contract without a rerun-source action', - contract: contractWith({ actions: [approve, reject] }), - call: { action: 're-request', comment: 'again' }, + name: 'a rerun on a request without a rerun-source action', + request: requestWith({ actions: [approve, reject] }), + call: { action: 'ask-again', comment: 'again' }, code: 'unknown_action', - value: 're-request', + value: 'ask-again', path: ['action'], }, { name: 'a reject without a reason when one is required', - contract: contractWith({ actions: [approve, { ...reject, reasonRequired: true }] }), + request: requestWith({ actions: [approve, { ...reject, reasonRequired: true }] }), call: { action: 'reject' }, code: 'reason_required', value: 'reject', @@ -114,7 +114,7 @@ describe('validateSubmittedDecision', () => { }, { name: 'a reject with a blank reason when one is required', - contract: contractWith({ actions: [approve, { ...reject, reasonRequired: true }] }), + request: requestWith({ actions: [approve, { ...reject, reasonRequired: true }] }), call: { action: 'reject', reason: ' ' }, code: 'reason_required', value: 'reject', @@ -122,16 +122,16 @@ describe('validateSubmittedDecision', () => { }, { name: 'a rerun without a comment', - call: { action: 're-request' }, + call: { action: 'ask-again' }, code: 'comment_required', - value: 're-request', + value: 'ask-again', path: ['comment'], }, { name: 'a rerun with a whitespace-only comment', - call: { action: 're-request', comment: ' \n ' }, + call: { action: 'ask-again', comment: ' \n ' }, code: 'comment_required', - value: 're-request', + value: 'ask-again', path: ['comment'], }, { @@ -176,8 +176,8 @@ describe('validateSubmittedDecision', () => { value: 'refundAmount', path: ['edits', 'refundAmount'], }, - ])('refuses $name', ({ contract = contractWith(), call, code, value, path }) => { - expect(validateSubmittedDecision(contract, call)).toEqual({ + ])('refuses $name', ({ request = requestWith(), call, code, value, path }) => { + expect(validateSubmittedDecision(request, call)).toEqual({ error: { code, message: submittedDecisionErrorMessage(code, value), path }, }); }); @@ -185,7 +185,7 @@ describe('validateSubmittedDecision', () => { it('builds the decision from the matched action and what was submitted', () => { const submitted = { action: 'approve', edits: { refundAmount: 12 }, comment: 'rounded down' }; - expect(validateSubmittedDecision(contractWith(), submitted).decision).toEqual({ + expect(validateSubmittedDecision(requestWith(), submitted).decision).toEqual({ action: approve, effect: 'resume-with-edits', edits: { refundAmount: 12 }, @@ -194,7 +194,7 @@ describe('validateSubmittedDecision', () => { }); it('defaults edits to an empty object when none were submitted', () => { - expect(validateSubmittedDecision(contractWith(), { action: 'reject', reason: 'late' }).decision).toEqual({ + expect(validateSubmittedDecision(requestWith(), { action: 'reject', reason: 'late' }).decision).toEqual({ action: reject, effect: 'reject', edits: {}, diff --git a/apps/backend/src/domain/decision/validate-submitted-decision.ts b/apps/backend/src/domain/decision/validate-submitted-decision.ts index 9c51cd013..54678e270 100644 --- a/apps/backend/src/domain/decision/validate-submitted-decision.ts +++ b/apps/backend/src/domain/decision/validate-submitted-decision.ts @@ -1,8 +1,8 @@ import type { DecisionAction, - DecisionContract, DecisionEffect, -} from '@workflow-builder/types/workflow-execution/decision-contract'; + DecisionRequest, +} from '@workflow-builder/types/workflow-execution/decision-request'; import { type SubmittedDecisionErrorCode, submittedDecisionErrorMessage } from './decision-issues'; @@ -15,7 +15,7 @@ export type SubmittedDecision = { comment?: string; }; -// A submission the contract accepts. The matched action carries the port to route on; +// A submission the request accepts. The matched action carries the port to route on; // `effect` is `resume-with-edits` when a resume came with edits. export type Decision = { action: DecisionAction; @@ -44,23 +44,23 @@ function isEmptied(value: unknown): boolean { return value === undefined || value === null || (typeof value === 'string' && value.trim().length === 0); } -// The contract arrived through the parser, so `schema` has the shape checked there; +// The request arrived through the parser, so `schema` has the shape checked there; // the reads below only narrow what `Record` hides. -function formProperties(contract: DecisionContract): Record { - return (contract.schema['properties'] ?? {}) as Record; +function formProperties(request: DecisionRequest): Record { + return (request.schema['properties'] ?? {}) as Record; } -function requiredFields(contract: DecisionContract): string[] { - return (contract.schema['required'] ?? []) as string[]; +function requiredFields(request: DecisionRequest): string[] { + return (request.schema['required'] ?? []) as string[]; } // Presence and editability only. Whether an edited value fits its declared type is a // later concern with its own validator. export function validateSubmittedDecision( - contract: DecisionContract, + request: DecisionRequest, submitted: SubmittedDecision, ): SubmittedDecisionResult { - const action = contract.actions.find((candidate) => candidate.name === submitted.action); + const action = request.actions.find((candidate) => candidate.name === submitted.action); if (action === undefined) return refuse('unknown_action', submitted.action, ['action']); if (action.effect === 'reject' && action.reasonRequired && isBlank(submitted.reason)) { @@ -70,8 +70,8 @@ export function validateSubmittedDecision( return refuse('comment_required', action.name, ['comment']); } - const properties = formProperties(contract); - const required = new Set(requiredFields(contract)); + const properties = formProperties(request); + const required = new Set(requiredFields(request)); const edits = submitted.edits ?? {}; for (const [field, value] of Object.entries(edits)) { if (!Object.hasOwn(properties, field)) return refuse('unknown_field', field, ['edits', field]); diff --git a/apps/backend/src/domain/mapper/from-integration-data.ts b/apps/backend/src/domain/mapper/from-integration-data.ts index c69256514..0a389ec8e 100644 --- a/apps/backend/src/domain/mapper/from-integration-data.ts +++ b/apps/backend/src/domain/mapper/from-integration-data.ts @@ -29,10 +29,10 @@ export function mapToExecutionModel(workflowId: string, data: WorkflowSnapshot): } // Lifts what an engine reads out of `data.properties`: `label`, `errorPolicy` (from the SDK's -// `sharedProperties`) and `decision` (validated and defaulted by the parse, unchecked here). +// `sharedProperties`) and `decisionRequest` (validated and defaulted by the parse, unchecked here). // `role` comes from `data.isStartNode` beside the properties; `description` stays in `config`. function mapNode(node: FrontendNode): BaseNode { - const { errorPolicy: rawErrorPolicy, label: rawLabel, decision, ...config } = node.data.properties ?? {}; + const { errorPolicy: rawErrorPolicy, label: rawLabel, decisionRequest, ...config } = node.data.properties ?? {}; const errorPolicy = isErrorPolicy(rawErrorPolicy) ? rawErrorPolicy : undefined; const label = isNonEmptyString(rawLabel) ? rawLabel.trim() : undefined; const role: NodeRole | undefined = node.data.isStartNode === true ? 'start' : undefined; @@ -40,7 +40,7 @@ function mapNode(node: FrontendNode): BaseNode { id: node.id, type: node.data.type, config, - ...pickBy({ label, errorPolicy, decision, role }, isDefined), + ...pickBy({ label, errorPolicy, decisionRequest, role }, isDefined), }; } diff --git a/apps/backend/src/domain/mapper/snapshot-schema.test.ts b/apps/backend/src/domain/mapper/snapshot-schema.test.ts index d57ec180c..df75464c0 100644 --- a/apps/backend/src/domain/mapper/snapshot-schema.test.ts +++ b/apps/backend/src/domain/mapper/snapshot-schema.test.ts @@ -135,31 +135,34 @@ function issuePaths(snapshot: unknown): string[] { return issuesOf(snapshot).map((issue) => issue.path); } -describe('workflowSnapshotSchema: decision contracts', () => { +describe('workflowSnapshotSchema: decision requests', () => { const approve = { name: 'approve', label: 'Approve', effect: 'resume' }; - const reRequest = { name: 're-request', label: 'Ask again', effect: 'rerun-source' }; + const askAgain = { name: 'ask-again', label: 'Ask again', effect: 'rerun-source' }; const emptyForm = { type: 'object', properties: {} }; - function decisionNode(id: string, decision: Record) { + function decisionNode(id: string, decisionRequest: Record) { return { id, - data: { type: 'product/any', properties: { decision: { version: 1, schema: emptyForm, ...decision } } }, + data: { + type: 'product/any', + properties: { decisionRequest: { version: 1, schema: emptyForm, ...decisionRequest } }, + }, }; } - it('parses a decision contract and materialises its defaults inside properties', () => { + it('parses a decision request and materialises its defaults inside properties', () => { const parsed = workflowSnapshotSchema.parse({ - nodes: [node('src'), decisionNode('review', { actions: [approve, reRequest] })], + nodes: [node('src'), decisionNode('review', { actions: [approve, askAgain] })], edges: [edge('src', 'review')], }); - expect(parsed.nodes[1]!.data.properties?.decision?.actions).toEqual([ + expect(parsed.nodes[1]!.data.properties?.decisionRequest?.actions).toEqual([ { ...approve, port: 'approved' }, - { ...reRequest, maxIterations: 3 }, + { ...askAgain, maxIterations: 3 }, ]); }); - it('leaves the properties of a node without a contract untouched', () => { + it('leaves the properties of a node without a request untouched', () => { const properties = { label: 'Plain', decisionBranches: [{ x: 1 }], meta: { deep: { nested: true } } }; const parsed = workflowSnapshotSchema.parse({ nodes: [node('n1', properties)], edges: [] }); @@ -167,19 +170,19 @@ describe('workflowSnapshotSchema: decision contracts', () => { expect(parsed.nodes[0]!.data.properties).toEqual(properties); }); - it('points a contract issue at the node index and field', () => { + it('points a request issue at the node index and field', () => { const snapshot = { nodes: [node('src'), decisionNode('review', { actions: [approve, { ...approve, name: 'approve-2' }] })], edges: [edge('src', 'review')], }; - expect(issuePaths(snapshot)).toContain('nodes.1.data.properties.decision.actions.1.effect'); + expect(issuePaths(snapshot)).toContain('nodes.1.data.properties.decisionRequest.actions.1.effect'); }); - it('rejects `decision: null`; absent is the only way to carry no decision', () => { - const snapshot = { nodes: [node('n1', { decision: null })], edges: [] }; + it('rejects `decisionRequest: null`; absent is the only way to carry no request', () => { + const snapshot = { nodes: [node('n1', { decisionRequest: null })], edges: [] }; - expect(issuePaths(snapshot)).toContain('nodes.0.data.properties.decision'); + expect(issuePaths(snapshot)).toContain('nodes.0.data.properties.decisionRequest'); }); it.each<{ name: string; snapshot: unknown }>([ @@ -193,7 +196,7 @@ describe('workflowSnapshotSchema: decision contracts', () => { { name: 'a rerun-source node with exactly one predecessor and no explicit source', snapshot: { - nodes: [node('a'), decisionNode('review', { actions: [approve, reRequest] })], + nodes: [node('a'), decisionNode('review', { actions: [approve, askAgain] })], edges: [edge('a', 'review')], }, }, @@ -203,7 +206,7 @@ describe('workflowSnapshotSchema: decision contracts', () => { nodes: [ node('a'), node('b'), - decisionNode('review', { actions: [approve, reRequest], proposalSourceNodeId: 'b' }), + decisionNode('review', { actions: [approve, askAgain], proposalSourceNodeId: 'b' }), ], edges: [edge('a', 'review'), edge('b', 'review')], }, @@ -211,7 +214,7 @@ describe('workflowSnapshotSchema: decision contracts', () => { { name: 'a rerun-source node whose single predecessor connects through two handles', snapshot: { - nodes: [node('a'), decisionNode('review', { actions: [approve, reRequest] })], + nodes: [node('a'), decisionNode('review', { actions: [approve, askAgain] })], edges: [edge('a', 'review', 'left'), edge('a', 'review', 'right')], }, }, @@ -223,7 +226,7 @@ describe('workflowSnapshotSchema: decision contracts', () => { }, }, { - name: 'a node without rerun-source whose explicit source carries its own decision', + name: 'a node without rerun-source whose explicit source carries its own decision request', snapshot: { nodes: [ decisionNode('first', { actions: [approve] }), @@ -237,9 +240,9 @@ describe('workflowSnapshotSchema: decision contracts', () => { snapshot: { nodes: [ node('a'), - decisionNode('review-1', { actions: [approve, reRequest] }), + decisionNode('review-1', { actions: [approve, askAgain] }), node('b'), - decisionNode('review-2', { actions: [approve, reRequest] }), + decisionNode('review-2', { actions: [approve, askAgain] }), ], edges: [edge('a', 'review-1'), edge('review-1', 'b'), edge('b', 'review-2')], }, @@ -255,7 +258,7 @@ describe('workflowSnapshotSchema: decision contracts', () => { nodes: [node('a'), node('b'), decisionNode('review', { actions: [approve], proposalSourceNodeId: 'b' })], edges: [edge('a', 'review')], }, - path: 'nodes.2.data.properties.decision.proposalSourceNodeId', + path: 'nodes.2.data.properties.decisionRequest.proposalSourceNodeId', issue: { code: 'source_not_a_predecessor', value: 'b' }, }, { @@ -268,64 +271,64 @@ describe('workflowSnapshotSchema: decision contracts', () => { ], edges: [edge('a', 'review'), edge('review', 'after')], }, - path: 'nodes.1.data.properties.decision.proposalSourceNodeId', + path: 'nodes.1.data.properties.decisionRequest.proposalSourceNodeId', issue: { code: 'source_not_a_predecessor', value: 'after' }, }, { name: 'a rerun-source node with no predecessor', snapshot: { - nodes: [decisionNode('review', { actions: [approve, reRequest] }), node('after')], + nodes: [decisionNode('review', { actions: [approve, askAgain] }), node('after')], edges: [edge('review', 'after')], }, - path: 'nodes.0.data.properties.decision.proposalSourceNodeId', + path: 'nodes.0.data.properties.decisionRequest.proposalSourceNodeId', issue: { code: 'source_missing' }, }, { name: 'a rerun-source node with several predecessors and no explicit source', snapshot: { - nodes: [node('a'), node('b'), decisionNode('review', { actions: [approve, reRequest] })], + nodes: [node('a'), node('b'), decisionNode('review', { actions: [approve, askAgain] })], edges: [edge('a', 'review'), edge('b', 'review')], }, - path: 'nodes.2.data.properties.decision.proposalSourceNodeId', + path: 'nodes.2.data.properties.decisionRequest.proposalSourceNodeId', issue: { code: 'source_ambiguous' }, }, { - name: 'a rerun-source node whose implicit source carries its own decision', + name: 'a rerun-source node whose implicit source carries its own decision request', snapshot: { nodes: [ node('a'), decisionNode('first', { actions: [approve] }), - decisionNode('second', { actions: [approve, reRequest] }), + decisionNode('second', { actions: [approve, askAgain] }), ], edges: [edge('a', 'first'), edge('first', 'second')], }, - path: 'nodes.2.data.properties.decision.proposalSourceNodeId', - issue: { code: 'source_has_decision', value: 'first' }, + path: 'nodes.2.data.properties.decisionRequest.proposalSourceNodeId', + issue: { code: 'source_has_decision_request', value: 'first' }, }, { - name: 'a rerun-source node whose explicit source carries its own decision', + name: 'a rerun-source node whose explicit source carries its own decision request', snapshot: { nodes: [ node('a'), decisionNode('first', { actions: [approve] }), - decisionNode('second', { actions: [approve, reRequest], proposalSourceNodeId: 'first' }), + decisionNode('second', { actions: [approve, askAgain], proposalSourceNodeId: 'first' }), ], edges: [edge('a', 'first'), edge('a', 'second'), edge('first', 'second')], }, - path: 'nodes.2.data.properties.decision.proposalSourceNodeId', - issue: { code: 'source_has_decision', value: 'first' }, + path: 'nodes.2.data.properties.decisionRequest.proposalSourceNodeId', + issue: { code: 'source_has_decision_request', value: 'first' }, }, { name: 'only the broken node when another deciding node in the snapshot is fine', snapshot: { nodes: [ node('a'), - decisionNode('review-1', { actions: [approve, reRequest] }), - decisionNode('review-2', { actions: [approve, reRequest] }), + decisionNode('review-1', { actions: [approve, askAgain] }), + decisionNode('review-2', { actions: [approve, askAgain] }), ], edges: [edge('a', 'review-1'), edge('a', 'review-2'), edge('review-1', 'review-2')], }, - path: 'nodes.2.data.properties.decision.proposalSourceNodeId', + path: 'nodes.2.data.properties.decisionRequest.proposalSourceNodeId', issue: { code: 'source_ambiguous' }, }, ])('rejects $name', ({ snapshot, path, issue }) => { @@ -518,27 +521,33 @@ describe('mapToExecutionModel', () => { expect(result.nodes[0]?.type).toBe('never-seen-before/v3'); }); - it('lifts a validated decision contract out of `config` onto `decision`', () => { - const contract = { + it('lifts a validated decision request out of `config` onto `decisionRequest`', () => { + const request = { version: 1, actions: [{ name: 'approve', label: 'Approve', effect: 'resume' }], schema: { type: 'object', properties: {} }, }; const snapshot = workflowSnapshotSchema.parse({ nodes: [ - { id: 'review', data: { type: 'product/any', properties: { label: 'Review', foo: 1, decision: contract } } }, + { + id: 'review', + data: { type: 'product/any', properties: { label: 'Review', foo: 1, decisionRequest: request } }, + }, ], edges: [], }); const result = mapToExecutionModel('wf-1', snapshot); - expect(result.nodes[0]!.decision).toEqual({ ...contract, actions: [{ ...contract.actions[0], port: 'approved' }] }); + expect(result.nodes[0]!.decisionRequest).toEqual({ + ...request, + actions: [{ ...request.actions[0], port: 'approved' }], + }); expect(result.nodes[0]!.config).toEqual({ foo: 1 }); expect(result.nodes[0]!.label).toBe('Review'); }); - it('gives a node without a contract no `decision` key', () => { + it('gives a node without a request no `decisionRequest` key', () => { const snapshot = workflowSnapshotSchema.parse({ nodes: [ { id: 'n1', data: { type: 'product/any', properties: { foo: 1 } } }, @@ -549,7 +558,7 @@ describe('mapToExecutionModel', () => { const result = mapToExecutionModel('wf-1', snapshot); - expect(result.nodes[0]).not.toHaveProperty('decision'); - expect(result.nodes[1]).not.toHaveProperty('decision'); + expect(result.nodes[0]).not.toHaveProperty('decisionRequest'); + expect(result.nodes[1]).not.toHaveProperty('decisionRequest'); }); }); diff --git a/apps/backend/src/domain/mapper/snapshot-schema.ts b/apps/backend/src/domain/mapper/snapshot-schema.ts index 7bdca9083..c6cb751da 100644 --- a/apps/backend/src/domain/mapper/snapshot-schema.ts +++ b/apps/backend/src/domain/mapper/snapshot-schema.ts @@ -1,14 +1,14 @@ // Validates the workflow snapshot at the HTTP boundary structurally only: // every node has `id` and `data.type`; every edge has `id`, `source`, `target`. -// `data.properties` is opaque here except for the reserved `decision` key, which -// is validated as a decision contract. The backend does not know any +// `data.properties` is opaque here except for the reserved `decisionRequest` key, +// which is validated as a decision request. The backend does not know any // product's node vocabulary; per-type validation belongs to whichever worker // registers executors for it, and an unknown node type surfaces at runtime as // a `node_failed` event with the missing-executor message. import { z } from 'zod'; -import { decisionContractSchema } from '../decision/decision-contract-schema'; import { type DecisionIssueCode, decisionIssue } from '../decision/decision-issues'; +import { decisionRequestSchema } from '../decision/decision-request-schema'; import { type UnresolvedSourceReason, resolveProposalSource } from '../decision/proposal-source'; const frontendNodeSchema = z.object({ @@ -20,7 +20,7 @@ const frontendNodeSchema = z.object({ // starts from. The editor's node kind (`start-node`, `node`, ...) is a // rendering detail and deliberately not read here. isStartNode: z.boolean().optional(), - properties: z.looseObject({ decision: decisionContractSchema.optional() }).optional(), + properties: z.looseObject({ decisionRequest: decisionRequestSchema.optional() }).optional(), }), }); @@ -32,7 +32,7 @@ const frontendEdgeSchema = z.object({ }); const SOURCE_ISSUE_BY_REASON = { - node_without_decision: 'source_node_without_decision', + node_without_decision_request: 'source_node_without_decision_request', explicit_source_not_a_predecessor: 'source_not_a_predecessor', no_predecessor: 'source_missing', ambiguous_predecessor: 'source_ambiguous', @@ -44,26 +44,29 @@ export const workflowSnapshotSchema = z edges: z.array(frontendEdgeSchema), }) .superRefine((snapshot, context) => { - const nodes = snapshot.nodes.map((node) => ({ id: node.id, decision: node.data.properties?.decision })); + const nodes = snapshot.nodes.map((node) => ({ + id: node.id, + decisionRequest: node.data.properties?.decisionRequest, + })); const edges = snapshot.edges.map((edge) => ({ sourceNodeId: edge.source, targetNodeId: edge.target })); for (const [index, node] of snapshot.nodes.entries()) { - const decision = node.data.properties?.decision; - if (decision === undefined) continue; - const declaresRerun = decision.actions.some((action) => action.effect === 'rerun-source'); - if (decision.proposalSourceNodeId === undefined && !declaresRerun) continue; + const request = node.data.properties?.decisionRequest; + if (request === undefined) continue; + const declaresRerun = request.actions.some((action) => action.effect === 'rerun-source'); + if (request.proposalSourceNodeId === undefined && !declaresRerun) continue; - const path = ['nodes', index, 'data', 'properties', 'decision', 'proposalSourceNodeId']; + const path = ['nodes', index, 'data', 'properties', 'decisionRequest', 'proposalSourceNodeId']; const resolution = resolveProposalSource(nodes, edges, node.id); if (resolution.error !== undefined) { - context.addIssue(decisionIssue(SOURCE_ISSUE_BY_REASON[resolution.error], path, decision.proposalSourceNodeId)); + context.addIssue(decisionIssue(SOURCE_ISSUE_BY_REASON[resolution.error], path, request.proposalSourceNodeId)); continue; } - const sourceHasDecision = nodes.some( - (other) => other.id === resolution.sourceNodeId && other.decision !== undefined, + const sourceHasRequest = nodes.some( + (other) => other.id === resolution.sourceNodeId && other.decisionRequest !== undefined, ); - if (declaresRerun && sourceHasDecision) { - context.addIssue(decisionIssue('source_has_decision', path, resolution.sourceNodeId)); + if (declaresRerun && sourceHasRequest) { + context.addIssue(decisionIssue('source_has_decision_request', path, resolution.sourceNodeId)); } } }); diff --git a/apps/backend/src/routes/workflows.test.ts b/apps/backend/src/routes/workflows.test.ts index 1894b0fd3..5eb5de42f 100644 --- a/apps/backend/src/routes/workflows.test.ts +++ b/apps/backend/src/routes/workflows.test.ts @@ -283,7 +283,7 @@ function snapshotWithDecisionActions(actions: unknown[]) { id: 'review', data: { type: 'product/any', - properties: { decision: { version: 1, actions, schema: { type: 'object', properties: {} } } }, + properties: { decisionRequest: { version: 1, actions, schema: { type: 'object', properties: {} } } }, }, }, ], @@ -310,7 +310,7 @@ function jsonRequest(app: ReturnType, path: string, method: str } describe('createWorkflowsRoutes - snapshot validation on publish', () => { - it('rejects a draft with a broken contract and writes nothing', async () => { + it('rejects a draft with a broken decision request and writes nothing', async () => { databaseMock.select.mockReturnValue(chainResolving([{ ...fakeWorkflow, draftJson: twoResumesSnapshot }])); const response = await publish(allowAllApp()); @@ -319,12 +319,12 @@ describe('createWorkflowsRoutes - snapshot validation on publish', () => { expect(response.status).toBe(400); expect(body.code).toBe('invalid_snapshot'); expect(body.details.map((detail) => detail.path.join('.'))).toContain( - 'nodes.1.data.properties.decision.actions.1.effect', + 'nodes.1.data.properties.decisionRequest.actions.1.effect', ); expect(databaseMock.update).not.toHaveBeenCalled(); }); - it('accepts a draft with a valid decision contract and returns the row', async () => { + it('accepts a draft with a valid decision request and returns the row', async () => { const published = { ...fakeWorkflow, draftJson: validDecisionSnapshot, publishedJson: validDecisionSnapshot }; databaseMock.select.mockReturnValue(chainResolving([{ ...fakeWorkflow, draftJson: validDecisionSnapshot }])); databaseMock.update.mockReturnValue(chainResolving([published])); @@ -363,7 +363,7 @@ describe('createWorkflowsRoutes - snapshot validation on publish', () => { }); describe('createWorkflowsRoutes - draft save never validates the snapshot', () => { - it('stores a draft with a broken contract', async () => { + it('stores a draft with a broken decision request', async () => { databaseMock.update.mockReturnValue(chainResolving([{ ...fakeWorkflow, draftJson: twoResumesSnapshot }])); const response = await jsonRequest(allowAllApp(), '/api/workflows/w-1/draft', 'PATCH', { diff --git a/packages/types/src/workflow-execution/decision-contract.ts b/packages/types/src/workflow-execution/decision-request.ts similarity index 83% rename from packages/types/src/workflow-execution/decision-contract.ts rename to packages/types/src/workflow-execution/decision-request.ts index 5694ca58e..443139b61 100644 --- a/packages/types/src/workflow-execution/decision-contract.ts +++ b/packages/types/src/workflow-execution/decision-request.ts @@ -1,5 +1,5 @@ /** - * Effects a decision contract may declare on its actions. `resume-with-edits` is deliberately absent: + * Effects a decision request may declare on its actions. `resume-with-edits` is deliberately absent: * it is never declared; the backend derives it when a `resume` call carries edits. */ export const DECLARABLE_DECISION_EFFECTS = ['resume', 'reject', 'rerun-source'] as const; @@ -12,7 +12,7 @@ export type DecisionEffect = DeclarableDecisionEffect | 'resume-with-edits'; type DecisionActionBase = { /** - * What a submitted decision names. Unique within the contract. Any string: the client's + * What a submitted decision names. Unique within the request. Any string: the client's * vocabulary, not an engine keyword. */ name: string; @@ -20,14 +20,14 @@ type DecisionActionBase = { label: string; }; -/** Accepts the proposal, edited or not: the run continues on `port`. Exactly one per contract. */ +/** Accepts the proposal, edited or not: the run continues on `port`. Exactly one per request. */ export type ResumeDecisionAction = DecisionActionBase & { effect: 'resume'; /** Output handle the run continues on. Defaults to `approved`. Never `errorRoute`. */ port: string; }; -/** Rejects the proposal: the run continues on `port`. At most one per contract. */ +/** Rejects the proposal: the run continues on `port`. At most one per request. */ export type RejectDecisionAction = DecisionActionBase & { effect: 'reject'; /** Output handle the run continues on. Defaults to `rejected`. Must differ from the resume port. */ @@ -36,7 +36,7 @@ export type RejectDecisionAction = DecisionActionBase & { reasonRequired: boolean; }; -/** Re-runs the proposal source with the decider's comment. At most one per contract. */ +/** Re-runs the proposal source with the decider's comment. At most one per request. */ export type RerunSourceDecisionAction = DecisionActionBase & { effect: 'rerun-source'; /** Upper bound on re-runs of the proposal source. Integer of at least 1. Defaults to `3`. */ @@ -46,7 +46,7 @@ export type RerunSourceDecisionAction = DecisionActionBase & { /** * One action the decider can take, discriminated on `effect`. `port`, `reasonRequired` and * `maxIterations` may be omitted in authored JSON; the backend parser materialises their - * defaults, so a parsed contract always carries them. + * defaults, so a parsed request always carries them. */ export type DecisionAction = ResumeDecisionAction | RejectDecisionAction | RerunSourceDecisionAction; @@ -62,12 +62,12 @@ export type DecisionDeadline = { }; /** - * The human decision a node asks for before the run continues. Authored under - * `data.properties.decision` in the editor snapshot and lifted to `BaseNode.decision`. + * What a node asks a human to decide before the run continues. Authored under + * `data.properties.decisionRequest` in the editor snapshot and lifted to `BaseNode.decisionRequest`. * Any node type may carry one. Unknown keys at every level are preserved. */ -export type DecisionContract = { - /** Shape version of the contract. A future shape change bumps it. */ +export type DecisionRequest = { + /** Shape version of the request. A future shape change bumps it. */ version: 1; /** Actions offered to the decider: exactly one `resume`, at most one `reject`, at most one `rerun-source`. */ actions: DecisionAction[]; diff --git a/packages/types/src/workflow-execution/execution-model.ts b/packages/types/src/workflow-execution/execution-model.ts index 982e37bf9..5e7c08fd8 100644 --- a/packages/types/src/workflow-execution/execution-model.ts +++ b/packages/types/src/workflow-execution/execution-model.ts @@ -1,4 +1,4 @@ -import type { DecisionContract } from './decision-contract'; +import type { DecisionRequest } from './decision-request'; // Runner-level decision applied when a node throws. // `fail` aborts the whole execution (default); `continue` absorbs the error into @@ -35,10 +35,10 @@ export type BaseNode = { label?: string; errorPolicy?: NodeErrorPolicy; /** - * The decision a human takes at this node before the run continues. This field's + * What this node asks a human to decide before the run continues. This field's * presence, never `type`, marks a node as one that waits for a decision. */ - decision?: DecisionContract; + decisionRequest?: DecisionRequest; role?: NodeRole; }; From f98c566d9df110abd0cf4e5c7954813c83d41614 Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Tue, 8 Sep 2026 08:46:09 +0200 Subject: [PATCH 10/25] docs(backend): decision log and README section for the decision request The log keeps only what the code cannot say: why the request is data on a node rather than a node kind, why name and effect are split, why edit is not an action, when validation runs, the lifecycle names and the alternatives that were rejected. The README points at where the request lives, when it is validated and how a broken one is reported. WB-500 --- apps/backend/README.md | 8 +++ apps/backend/decision-request.decision-log.md | 54 +++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 apps/backend/decision-request.decision-log.md diff --git a/apps/backend/README.md b/apps/backend/README.md index fdd487c41..3e66f8d9b 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -29,6 +29,14 @@ Frontend (React) - **Domain** (`packages/execution-core`) — pure graph runner + ports + node executors. No Temporal, no HTTP. See the [execution-core README](../../packages/execution-core/README.md). - **Frontend** (`apps/ai-studio`) — full AI workflow product. Composes `@workflowbuilder/sdk` directly via JSX, with a slim plugin only for per-node execution markers. Owns Play/Stop controls, log panel, node detail, and execution highlighting. +## Decision request on a node + +A node asks a human for a decision by carrying `data.properties.decisionRequest`: the actions offered, the JSON Schema of the form, the node whose output is judged, and an optional deadline. Any node type may carry one; its presence, never `type`, is what makes the run park there. The mapper lifts it to `BaseNode.decisionRequest`, out of `config`. + +The request is validated on `POST /:id/publish` and `POST /:id/execute`, never on `PATCH /:id/draft`: a draft is legitimately mid-edit. A broken request answers with the existing `invalid_snapshot` 400, whose `details[].path` points at the node index and field, for example `nodes.1.data.properties.decisionRequest.actions.1.effect`. Every domain message the validation can produce is listed in `src/domain/decision/decision-issues.ts`. + +A submitted decision is checked against the request by `validateSubmittedDecision` in `src/domain/decision/`; the decision endpoint that calls it is a separate change. Shape, rules and the reasoning are in [`decision-request.decision-log.md`](./decision-request.decision-log.md). + ## Running individual processes For debugging, the parts that `pnpm dev:ai-studio` orchestrates can also be run separately: diff --git a/apps/backend/decision-request.decision-log.md b/apps/backend/decision-request.decision-log.md new file mode 100644 index 000000000..71034a4fd --- /dev/null +++ b/apps/backend/decision-request.decision-log.md @@ -0,0 +1,54 @@ +### Title: Decision request as versioned data on a node + +### Proposed by: Piotr Błaszczyk + +### Date: 07.09.2026 (shape), 08.09.2026 (names) + +## Context + +A run can park at a node until a person decides. The backend needs to know what a decision at that node looks like: the actions offered, the fields the decider sees and may correct, whose output is judged, how long to wait. Products bring their own vocabulary; the engine has a closed set of things it can do; the backend knows no product's node types. + +The shape itself is documented on the type (`packages/types/src/workflow-execution/decision-request.ts`) and enforced by `decisionRequestSchema` in `apps/backend/src/domain/decision/`. This log keeps only what the code cannot say. + +## Decision + +1. **Data on the node, not a node kind.** The request lives under the reserved key `data.properties.decisionRequest` and is lifted to `BaseNode.decisionRequest`, as `errorPolicy` is. Its presence is the only marker; nothing detects such a node by `type`. A client adding its own node type never has to teach the backend about it. +2. **Name and effect are split.** `name` and `label` are the client's words ("Escalate to finance"); `effect` is the engine's closed set. A new business vocabulary is data, not a code change. +3. **Edit is not an action.** The decider corrects fields and approves. Whether a field may be edited is already said by `readOnly` in the schema; a second switch would be a second source of truth. `resume-with-edits` is therefore derived, never declared. +4. **JSON Schema for the form**, validated for shape only. The SDK already renders and validates JSON Schema, so the decision form comes for free. A real validator arrives with the first consumer that checks edited values `(follow-up: decision-edit-value-validation)`. +5. **Validated on publish and execute, never on draft save.** A draft is legitimately mid-edit; validating it would lose the author's work on every autosave. Both routes go through one `parseSnapshot` helper and the existing `invalid_snapshot` 400, so they cannot drift. +6. **One definition of the proposal source.** `resolveProposalSource` is the only place that says which node's output a decision judges. The pending-decision resource and the rerun loop must call it rather than re-derive the rule. +7. **Read requests through the parser, never from raw JSON.** Only the parsed form carries the defaults (`port`, `reasonRequired`, `maxIterations`). The stored snapshot stays raw. +8. **Three names for the lifecycle.** `DecisionRequest` is what the node asks. `SubmittedDecision` is what the decider sends, still unchecked. `Decision` is what validation accepts and what will later be stored and audited. The field is not called `decision` because it holds the question, not the answer, and not `decisionContract` because that reads as configuration rather than as a request to a person. +9. **Results carry `error?: undefined`, not an `ok` flag.** `{ value; error?: undefined } | { value?: undefined; error }` reads as plain error handling and the compiler still forbids both-set and neither-set. The flag only repeated what the presence of `error` says. +10. **Vocabulary.** A node carrying a request is a node; no separate noun names it. The rerun effect is named for what it does, `rerun-source`, never for what the source is. Action names in examples (`approve`, `reject`, `ask-again`) are the client's and await a sync with design. + +## Rejected + +- Detecting the node by its type string: the backend would have to learn every product's vocabulary. +- A home-grown field list instead of JSON Schema: a second standard to render and validate. +- An `ignore` verb: a disguised "abandon the run". +- Defaulting `deadline.policy` to `reject`: a timer that rejects is audit-relevant and must be written down, not implied. +- Exporting the duration pattern from the Temporal plugin: a published API widened for one regex; duplicated with a pointer instead `(follow-up: shared-duration-format)`. + +## Known gaps + +- A workflow with a `null` draft still publishes `null`, unvalidated, as it did before. Changing that is its own decision. +- The submission validator returns the first refusal, not a list. +- The snapshot schema does not check that edge endpoints exist, so an explicit source with a dangling edge passes. This predates the change. + +## Open points + +Taken conservatively; confirm or change when the decision endpoint lands. + +- A whitespace-only `reason` counts as missing when `reasonRequired` is set, like a blank comment. +- "Emptied" for a required field means `undefined`, `null` or a whitespace-only string; empty arrays and objects are value validation. +- Edits on a non-`resume` submission are checked but do not change the effect; refusing them with a dedicated code is the recommended alternative. + +## Not in this change + +Further request fields (condition, four-eyes, several decisions), identity and `x-pii` masking, the decision endpoint, the pending-decision resource, the rerun loop, the deadline timer, and authoring the request in the editor `(follow-up: decision-request-properties-ui)`. + +## Status + +Accepted From b292c21a0ae9566e197624a6654962e8a336e24f Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Tue, 8 Sep 2026 12:50:33 +0200 Subject: [PATCH 11/25] fix(backend): reject an own __proto__ key anywhere in a snapshot before parsing JSON.parse turns "__proto__" into an ordinary own key and zod's loose objects copy unknown keys with a plain assignment, which for that key swaps the output's prototype. A draft could smuggle an unvalidated decision request through properties.__proto__: the snapshot schema saw nothing, the mapper copied the inherited value into a real field on the way to the engine, and a malformed variant made safeParse throw a TypeError (500 on publish and execute). workflowSnapshotSchema is now wrapped in a preprocess that walks the raw value and refuses the key at its path with the usual invalid_snapshot 400. Draft save is unchanged. WB-500 --- apps/backend/decision-request.decision-log.md | 3 + .../src/domain/mapper/own-proto-key.test.ts | 34 +++++++++++ .../src/domain/mapper/own-proto-key.ts | 31 ++++++++++ .../src/domain/mapper/snapshot-schema.test.ts | 33 ++++++++++ .../src/domain/mapper/snapshot-schema.ts | 61 ++++++++++--------- apps/backend/src/routes/workflows.test.ts | 35 +++++++++++ 6 files changed, 168 insertions(+), 29 deletions(-) create mode 100644 apps/backend/src/domain/mapper/own-proto-key.test.ts create mode 100644 apps/backend/src/domain/mapper/own-proto-key.ts diff --git a/apps/backend/decision-request.decision-log.md b/apps/backend/decision-request.decision-log.md index 71034a4fd..c6c091648 100644 --- a/apps/backend/decision-request.decision-log.md +++ b/apps/backend/decision-request.decision-log.md @@ -23,6 +23,8 @@ The shape itself is documented on the type (`packages/types/src/workflow-executi 9. **Results carry `error?: undefined`, not an `ok` flag.** `{ value; error?: undefined } | { value?: undefined; error }` reads as plain error handling and the compiler still forbids both-set and neither-set. The flag only repeated what the presence of `error` says. 10. **Vocabulary.** A node carrying a request is a node; no separate noun names it. The rerun effect is named for what it does, `rerun-source`, never for what the source is. Action names in examples (`approve`, `reject`, `ask-again`) are the client's and await a sync with design. +11. **An own `__proto__` key anywhere in a snapshot is refused before parsing.** `JSON.parse` makes it an ordinary key, and zod's loose objects copy unknown keys with a plain assignment, which for that key swaps the output's prototype: everything under it then reads back as validated, and the mapper would copy an inherited request into a real field on the way to the engine. `workflowSnapshotSchema`, the one boundary raw JSON crosses, is wrapped in a preprocess that rejects the key at its path with the usual `invalid_snapshot` 400. + ## Rejected - Detecting the node by its type string: the backend would have to learn every product's vocabulary. @@ -34,6 +36,7 @@ The shape itself is documented on the type (`packages/types/src/workflow-executi ## Known gaps - A workflow with a `null` draft still publishes `null`, unvalidated, as it did before. Changing that is its own decision. +- A draft may store an own `__proto__` key; it goes nowhere but the database, and publish and execute refuse it. Rejecting it at save time was judged not worth touching the draft route. - The submission validator returns the first refusal, not a list. - The snapshot schema does not check that edge endpoints exist, so an explicit source with a dangling edge passes. This predates the change. diff --git a/apps/backend/src/domain/mapper/own-proto-key.test.ts b/apps/backend/src/domain/mapper/own-proto-key.test.ts new file mode 100644 index 000000000..ec6558c88 --- /dev/null +++ b/apps/backend/src/domain/mapper/own-proto-key.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +import { findOwnProtoKey, rejectingOwnProtoKey } from './own-proto-key'; + +describe('findOwnProtoKey', () => { + it('reports the path of an own key created by JSON.parse, through objects and arrays', () => { + expect(findOwnProtoKey({ a: { b: [1, { c: null }] } })).toBeUndefined(); + expect(findOwnProtoKey(JSON.parse('{"a": {"b": {"__proto__": {}}}}'))).toEqual(['a', 'b', '__proto__']); + expect(findOwnProtoKey(JSON.parse('{"items": [1, {"__proto__": {}}]}'))).toEqual(['items', 1, '__proto__']); + }); + + it('terminates on a cyclic object', () => { + const cyclic: Record = { a: 1 }; + cyclic['self'] = cyclic; + + expect(findOwnProtoKey(cyclic)).toBeUndefined(); + }); +}); + +describe('rejectingOwnProtoKey', () => { + const schema = rejectingOwnProtoKey(z.looseObject({ a: z.number(), deadline: z.string().optional() })); + + it('passes a clean value through and rejects an own __proto__ key at its path', () => { + expect(schema.parse({ a: 1, extra: true })).toEqual({ a: 1, extra: true }); + + const result = schema.safeParse(JSON.parse('{"a": 1, "__proto__": {"deadline": "smuggled"}}')); + + expect(result.success).toBe(false); + expect(result.success ? [] : result.error.issues.map((issue) => [issue.path.join('.'), issue.message])).toEqual([ + ['__proto__', "the key '__proto__' is not allowed"], + ]); + }); +}); diff --git a/apps/backend/src/domain/mapper/own-proto-key.ts b/apps/backend/src/domain/mapper/own-proto-key.ts new file mode 100644 index 000000000..f173b41d9 --- /dev/null +++ b/apps/backend/src/domain/mapper/own-proto-key.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; + +const OWN_PROTO_KEY_MESSAGE = "the key '__proto__' is not allowed"; + +// `JSON.parse` turns "__proto__" into an ordinary own key. zod's loose objects copy unknown +// keys with a plain assignment, which for that key swaps the output's prototype instead, +// so anything under it is read back as if validated. Refuse it before parsing. +export function findOwnProtoKey( + value: unknown, + path: PropertyKey[] = [], + seen = new Set(), +): PropertyKey[] | undefined { + if (typeof value !== 'object' || value === null || seen.has(value)) return undefined; + seen.add(value); + if (Object.hasOwn(value, '__proto__')) return [...path, '__proto__']; + const entries = Array.isArray(value) ? value.entries() : Object.entries(value); + for (const [key, child] of entries) { + const found = findOwnProtoKey(child, [...path, key], seen); + if (found !== undefined) return found; + } + return undefined; +} + +export function rejectingOwnProtoKey(schema: T) { + return z.preprocess((value, context) => { + const path = findOwnProtoKey(value); + if (path === undefined) return value; + context.addIssue({ code: 'custom', message: OWN_PROTO_KEY_MESSAGE, path }); + return z.NEVER; + }, schema); +} diff --git a/apps/backend/src/domain/mapper/snapshot-schema.test.ts b/apps/backend/src/domain/mapper/snapshot-schema.test.ts index df75464c0..28c28cb1a 100644 --- a/apps/backend/src/domain/mapper/snapshot-schema.test.ts +++ b/apps/backend/src/domain/mapper/snapshot-schema.test.ts @@ -562,3 +562,36 @@ describe('mapToExecutionModel', () => { expect(result.nodes[1]).not.toHaveProperty('decisionRequest'); }); }); + +function snapshotJson(properties: string) { + return JSON.parse(`{"nodes":[{"id":"n1","data":{"type":"product/any","properties":${properties}}}],"edges":[]}`); +} + +describe('workflowSnapshotSchema: own __proto__ keys', () => { + it('rejects a well-shaped decision request smuggled through properties.__proto__', () => { + const smuggled = snapshotJson( + '{"label":"ok","__proto__":{"decisionRequest":{"version":99,"actions":[{"effect":"bogus"}],"schema":"x"}}}', + ); + + expect(issuesOf(smuggled)).toEqual([ + { path: 'nodes.0.data.properties.__proto__', message: "the key '__proto__' is not allowed" }, + ]); + }); + + it('rejects one inside a decision request instead of inheriting the deadline it smuggles', () => { + const poisoned = snapshotJson( + '{"decisionRequest":{"version":1,"actions":[{"name":"approve","label":"Approve","effect":"resume"}],' + + '"schema":{"type":"object","properties":{}},' + + '"__proto__":{"deadline":{"after":"garbage","policy":"nuke"},"uiSchema":"x"}}}', + ); + + expect(issuePaths(poisoned)).toEqual(['nodes.0.data.properties.decisionRequest.__proto__']); + }); + + it('answers with an issue, not a throw, when the smuggled request has no actions array', () => { + const smuggled = snapshotJson('{"__proto__":{"decisionRequest":{"actions":"x"}}}'); + + expect(() => workflowSnapshotSchema.safeParse(smuggled)).not.toThrow(); + expect(issuePaths(smuggled)).toEqual(['nodes.0.data.properties.__proto__']); + }); +}); diff --git a/apps/backend/src/domain/mapper/snapshot-schema.ts b/apps/backend/src/domain/mapper/snapshot-schema.ts index c6cb751da..333c4aa76 100644 --- a/apps/backend/src/domain/mapper/snapshot-schema.ts +++ b/apps/backend/src/domain/mapper/snapshot-schema.ts @@ -10,6 +10,7 @@ import { z } from 'zod'; import { type DecisionIssueCode, decisionIssue } from '../decision/decision-issues'; import { decisionRequestSchema } from '../decision/decision-request-schema'; import { type UnresolvedSourceReason, resolveProposalSource } from '../decision/proposal-source'; +import { rejectingOwnProtoKey } from './own-proto-key'; const frontendNodeSchema = z.object({ id: z.string(), @@ -38,37 +39,39 @@ const SOURCE_ISSUE_BY_REASON = { ambiguous_predecessor: 'source_ambiguous', } as const satisfies Record; -export const workflowSnapshotSchema = z - .object({ - nodes: z.array(frontendNodeSchema), - edges: z.array(frontendEdgeSchema), - }) - .superRefine((snapshot, context) => { - const nodes = snapshot.nodes.map((node) => ({ - id: node.id, - decisionRequest: node.data.properties?.decisionRequest, - })); - const edges = snapshot.edges.map((edge) => ({ sourceNodeId: edge.source, targetNodeId: edge.target })); +export const workflowSnapshotSchema = rejectingOwnProtoKey( + z + .object({ + nodes: z.array(frontendNodeSchema), + edges: z.array(frontendEdgeSchema), + }) + .superRefine((snapshot, context) => { + const nodes = snapshot.nodes.map((node) => ({ + id: node.id, + decisionRequest: node.data.properties?.decisionRequest, + })); + const edges = snapshot.edges.map((edge) => ({ sourceNodeId: edge.source, targetNodeId: edge.target })); - for (const [index, node] of snapshot.nodes.entries()) { - const request = node.data.properties?.decisionRequest; - if (request === undefined) continue; - const declaresRerun = request.actions.some((action) => action.effect === 'rerun-source'); - if (request.proposalSourceNodeId === undefined && !declaresRerun) continue; + for (const [index, node] of snapshot.nodes.entries()) { + const request = node.data.properties?.decisionRequest; + if (request === undefined) continue; + const declaresRerun = request.actions.some((action) => action.effect === 'rerun-source'); + if (request.proposalSourceNodeId === undefined && !declaresRerun) continue; - const path = ['nodes', index, 'data', 'properties', 'decisionRequest', 'proposalSourceNodeId']; - const resolution = resolveProposalSource(nodes, edges, node.id); - if (resolution.error !== undefined) { - context.addIssue(decisionIssue(SOURCE_ISSUE_BY_REASON[resolution.error], path, request.proposalSourceNodeId)); - continue; + const path = ['nodes', index, 'data', 'properties', 'decisionRequest', 'proposalSourceNodeId']; + const resolution = resolveProposalSource(nodes, edges, node.id); + if (resolution.error !== undefined) { + context.addIssue(decisionIssue(SOURCE_ISSUE_BY_REASON[resolution.error], path, request.proposalSourceNodeId)); + continue; + } + const sourceHasRequest = nodes.some( + (other) => other.id === resolution.sourceNodeId && other.decisionRequest !== undefined, + ); + if (declaresRerun && sourceHasRequest) { + context.addIssue(decisionIssue('source_has_decision_request', path, resolution.sourceNodeId)); + } } - const sourceHasRequest = nodes.some( - (other) => other.id === resolution.sourceNodeId && other.decisionRequest !== undefined, - ); - if (declaresRerun && sourceHasRequest) { - context.addIssue(decisionIssue('source_has_decision_request', path, resolution.sourceNodeId)); - } - } - }); + }), +); export type WorkflowSnapshot = z.infer; diff --git a/apps/backend/src/routes/workflows.test.ts b/apps/backend/src/routes/workflows.test.ts index 5eb5de42f..6406f4a6c 100644 --- a/apps/backend/src/routes/workflows.test.ts +++ b/apps/backend/src/routes/workflows.test.ts @@ -374,3 +374,38 @@ describe('createWorkflowsRoutes - draft save never validates the snapshot', () = expect(databaseMock.update).toHaveBeenCalledTimes(1); }); }); + +// ---- own __proto__ keys in a stored draft ------------------------------------- +// +// A draft is stored as sent, so it can carry an own `__proto__` key. Publish and +// execute refuse it before the parser could turn it into the snapshot's prototype. + +const poisonedDraft = JSON.parse( + '{"nodes":[{"id":"n1","data":{"type":"product/any","properties":' + + '{"__proto__":{"decisionRequest":{"version":99,"actions":[{"effect":"bogus"}],"schema":"x"}}}}}],"edges":[]}', +); + +describe('createWorkflowsRoutes - own __proto__ key in the draft', () => { + it('publish answers 400 invalid_snapshot pointing at the key and writes nothing', async () => { + databaseMock.select.mockReturnValue(chainResolving([{ ...fakeWorkflow, draftJson: poisonedDraft }])); + + const response = await publish(allowAllApp()); + const body = (await response.json()) as InvalidSnapshotBody; + + expect(response.status).toBe(400); + expect(body.code).toBe('invalid_snapshot'); + expect(body.details.map((detail) => detail.path.join('.'))).toEqual(['nodes.0.data.properties.__proto__']); + expect(databaseMock.update).not.toHaveBeenCalled(); + }); + + it('draft save still stores it; only publish and execute refuse', async () => { + databaseMock.update.mockReturnValue(chainResolving([{ ...fakeWorkflow, draftJson: poisonedDraft }])); + + const response = await jsonRequest(allowAllApp(), '/api/workflows/w-1/draft', 'PATCH', { + draftJson: poisonedDraft, + }); + + expect(response.status).toBe(200); + expect(databaseMock.update).toHaveBeenCalledTimes(1); + }); +}); From db84b1329691ebeb2973f18859d698361ef4f699 Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Tue, 8 Sep 2026 13:00:55 +0200 Subject: [PATCH 12/25] fix(backend): parse the submitted decision's shape before checking its rules validateSubmittedDecision took a typed SubmittedDecision but nothing enforced the shape at runtime: edits as an array or a number were accepted into Decision.edits, and a non-string reason threw from trim(). The shape now has one source, submittedDecisionSchema, from which the type is derived; the decision endpoint parses a body with it before calling, and the function checks only the rules. WB-500 --- apps/backend/decision-request.decision-log.md | 2 +- .../validate-submitted-decision.test.ts | 34 ++++++++++++++++++- .../decision/validate-submitted-decision.ts | 21 +++++++----- 3 files changed, 47 insertions(+), 10 deletions(-) diff --git a/apps/backend/decision-request.decision-log.md b/apps/backend/decision-request.decision-log.md index c6c091648..ed896216a 100644 --- a/apps/backend/decision-request.decision-log.md +++ b/apps/backend/decision-request.decision-log.md @@ -19,7 +19,7 @@ The shape itself is documented on the type (`packages/types/src/workflow-executi 5. **Validated on publish and execute, never on draft save.** A draft is legitimately mid-edit; validating it would lose the author's work on every autosave. Both routes go through one `parseSnapshot` helper and the existing `invalid_snapshot` 400, so they cannot drift. 6. **One definition of the proposal source.** `resolveProposalSource` is the only place that says which node's output a decision judges. The pending-decision resource and the rerun loop must call it rather than re-derive the rule. 7. **Read requests through the parser, never from raw JSON.** Only the parsed form carries the defaults (`port`, `reasonRequired`, `maxIterations`). The stored snapshot stays raw. -8. **Three names for the lifecycle.** `DecisionRequest` is what the node asks. `SubmittedDecision` is what the decider sends, still unchecked. `Decision` is what validation accepts and what will later be stored and audited. The field is not called `decision` because it holds the question, not the answer, and not `decisionContract` because that reads as configuration rather than as a request to a person. +8. **Three names for the lifecycle.** `DecisionRequest` is what the node asks. `SubmittedDecision` is what the decider sends, still unchecked. `Decision` is what validation accepts and what will later be stored and audited. The shape of a submission is parsed with `submittedDecisionSchema` at the endpoint; `validateSubmittedDecision` assumes it and checks only the rules. The field is not called `decision` because it holds the question, not the answer, and not `decisionContract` because that reads as configuration rather than as a request to a person. 9. **Results carry `error?: undefined`, not an `ok` flag.** `{ value; error?: undefined } | { value?: undefined; error }` reads as plain error handling and the compiler still forbids both-set and neither-set. The flag only repeated what the presence of `error` says. 10. **Vocabulary.** A node carrying a request is a node; no separate noun names it. The rerun effect is named for what it does, `rerun-source`, never for what the source is. Action names in examples (`approve`, `reject`, `ask-again`) are the client's and await a sync with design. diff --git a/apps/backend/src/domain/decision/validate-submitted-decision.test.ts b/apps/backend/src/domain/decision/validate-submitted-decision.test.ts index c469b2273..09c55724e 100644 --- a/apps/backend/src/domain/decision/validate-submitted-decision.test.ts +++ b/apps/backend/src/domain/decision/validate-submitted-decision.test.ts @@ -3,7 +3,11 @@ import { describe, expect, it } from 'vitest'; import type { DecisionRequest } from '@workflow-builder/types/workflow-execution/decision-request'; import { type SubmittedDecisionErrorCode, submittedDecisionErrorMessage } from './decision-issues'; -import { type SubmittedDecision, validateSubmittedDecision } from './validate-submitted-decision'; +import { + type SubmittedDecision, + submittedDecisionSchema, + validateSubmittedDecision, +} from './validate-submitted-decision'; const approve = { name: 'approve', label: 'Approve', effect: 'resume', port: 'approved' } as const; const reject = { name: 'reject', label: 'Reject', effect: 'reject', port: 'rejected', reasonRequired: false } as const; @@ -202,3 +206,31 @@ describe('validateSubmittedDecision', () => { }); }); }); + +describe('submittedDecisionSchema', () => { + it('accepts a full submission and strips keys it does not know', () => { + const parsed = submittedDecisionSchema.parse({ + action: 'approve', + edits: { a: 1 }, + reason: 'r', + comment: 'c', + extra: true, + }); + + expect(parsed).toEqual({ action: 'approve', edits: { a: 1 }, reason: 'r', comment: 'c' }); + }); + + it.each<{ name: string; body: unknown; path: string }>([ + { name: 'edits as an array', body: { action: 'approve', edits: [] }, path: 'edits' }, + { name: 'edits as a number', body: { action: 'approve', edits: 42 }, path: 'edits' }, + { name: 'edits as null', body: { action: 'approve', edits: null }, path: 'edits' }, + { name: 'a non-string reason', body: { action: 'reject', reason: 42 }, path: 'reason' }, + { name: 'a non-string comment', body: { action: 'ask-again', comment: {} }, path: 'comment' }, + { name: 'a missing action', body: { edits: {} }, path: 'action' }, + ])('rejects $name before the rules ever run', ({ body, path }) => { + const result = submittedDecisionSchema.safeParse(body); + + expect(result.success).toBe(false); + expect(result.success ? [] : result.error.issues.map((issue) => issue.path.join('.'))).toEqual([path]); + }); +}); diff --git a/apps/backend/src/domain/decision/validate-submitted-decision.ts b/apps/backend/src/domain/decision/validate-submitted-decision.ts index 54678e270..7b525e5c4 100644 --- a/apps/backend/src/domain/decision/validate-submitted-decision.ts +++ b/apps/backend/src/domain/decision/validate-submitted-decision.ts @@ -1,3 +1,5 @@ +import { z } from 'zod'; + import type { DecisionAction, DecisionEffect, @@ -6,14 +8,17 @@ import type { import { type SubmittedDecisionErrorCode, submittedDecisionErrorMessage } from './decision-issues'; -// What the decider sent, before anything has checked it. Provisional: the decision -// endpoint owns the public request shape and may rename these. -export type SubmittedDecision = { - action: string; - edits?: Record; - reason?: string; - comment?: string; -}; +// The shape of what the decider sent. The caller parses a body with this before calling +// `validateSubmittedDecision`, which assumes the shape and checks only the rules. +// Provisional: the decision endpoint owns the public request shape and may rename fields. +export const submittedDecisionSchema = z.object({ + action: z.string(), + edits: z.record(z.string(), z.unknown()).optional(), + reason: z.string().optional(), + comment: z.string().optional(), +}); + +export type SubmittedDecision = z.infer; // A submission the request accepts. The matched action carries the port to route on; // `effect` is `resume-with-edits` when a resume came with edits. From 4632441bd1a90f49a4376d8ea68bf9af6beac42c Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Tue, 8 Sep 2026 13:10:03 +0200 Subject: [PATCH 13/25] test(backend): close matrix gaps found in review Pins four behaviours that were live but untested: an own __proto__ key in a submission's edits is dropped by the record parser without touching the prototype; edits on a reject or rerun-source submission keep the declared effect and ride along (the open point in the decision log); a request with no schema at all is refused at schema; addressing an action by its label instead of its name is unknown_action. WB-500 --- .../decision/decision-request-schema.test.ts | 1 + .../validate-submitted-decision.test.ts | 28 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/apps/backend/src/domain/decision/decision-request-schema.test.ts b/apps/backend/src/domain/decision/decision-request-schema.test.ts index 7afd6f830..7c73ac046 100644 --- a/apps/backend/src/domain/decision/decision-request-schema.test.ts +++ b/apps/backend/src/domain/decision/decision-request-schema.test.ts @@ -246,6 +246,7 @@ describe('decisionRequestSchema', () => { input: request({ schema: { ...refundForm, type: 'array' } }), path: 'schema.type', }, + { name: 'no form schema at all', input: request({ schema: undefined }), path: 'schema' }, { name: 'a form schema without properties', input: request({ schema: { type: 'object' } }), diff --git a/apps/backend/src/domain/decision/validate-submitted-decision.test.ts b/apps/backend/src/domain/decision/validate-submitted-decision.test.ts index 09c55724e..bfe82f2fb 100644 --- a/apps/backend/src/domain/decision/validate-submitted-decision.test.ts +++ b/apps/backend/src/domain/decision/validate-submitted-decision.test.ts @@ -77,6 +77,18 @@ describe('validateSubmittedDecision', () => { expect(result.decision?.action.name).toBe(call.action); }); + // Open point in the decision log: until decided otherwise, edits on a non-resume action + // are checked field by field, kept on the decision, and leave the effect alone. + it.each<{ call: SubmittedDecision; effect: string }>([ + { call: { action: 'reject', reason: 'late', edits: { refundAmount: 0 } }, effect: 'reject' }, + { call: { action: 'ask-again', comment: 'again', edits: { note: 'x' } }, effect: 'rerun-source' }, + ])('keeps the declared effect $effect and carries the edits of a non-resume submission', ({ call, effect }) => { + const result = validateSubmittedDecision(requestWith(), call); + + expect(result.decision?.effect).toBe(effect); + expect(result.decision?.edits).toEqual(call.edits); + }); + it.each<{ name: string; request?: DecisionRequest; @@ -92,6 +104,13 @@ describe('validateSubmittedDecision', () => { value: 'escalate', path: ['action'], }, + { + name: "an action addressed by its label instead of its name ('Approve')", + call: { action: 'Approve' }, + code: 'unknown_action', + value: 'Approve', + path: ['action'], + }, { name: 'a reject on a request without a reject action', request: requestWith({ actions: [approve] }), @@ -220,6 +239,15 @@ describe('submittedDecisionSchema', () => { expect(parsed).toEqual({ action: 'approve', edits: { a: 1 }, reason: 'r', comment: 'c' }); }); + it('drops an own __proto__ key in edits instead of making it the prototype', () => { + const parsed = submittedDecisionSchema.parse( + JSON.parse('{"action":"approve","edits":{"__proto__":{"refundAmount":1}}}'), + ); + + expect(parsed.edits).toEqual({}); + expect(Object.getPrototypeOf(parsed.edits)).toBe(Object.prototype); + }); + it.each<{ name: string; body: unknown; path: string }>([ { name: 'edits as an array', body: { action: 'approve', edits: [] }, path: 'edits' }, { name: 'edits as a number', body: { action: 'approve', edits: 42 }, path: 'edits' }, From 81d1214beb299d183ec2d62c11519b23dca6c835 Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Tue, 8 Sep 2026 13:19:00 +0200 Subject: [PATCH 14/25] fix(backend): review minors for the decision request Blank names, labels and ports are refused, not just empty ones. A self-loop no longer counts as a predecessor when resolving the proposal source. The deadline message names the upper bound. Comments trimmed to the repo rule; the decision log records that decisionRequest is present or absent and never null, points at the worked example fixture, and lists node-id uniqueness as a pre-existing gap. README notes that structural issues surface before graph rules. WB-500 --- apps/backend/README.md | 2 +- apps/backend/decision-request.decision-log.md | 5 +++-- .../domain/decision/decision-issues.test.ts | 2 +- .../src/domain/decision/decision-issues.ts | 12 ++++++------ .../decision/decision-request-schema.test.ts | 18 ++++++++++++++++++ .../domain/decision/decision-request-schema.ts | 13 ++++++++----- .../domain/decision/proposal-source.test.ts | 12 ++++++++++++ .../src/domain/decision/proposal-source.ts | 10 +++++++--- .../decision/validate-submitted-decision.ts | 2 +- .../src/domain/mapper/snapshot-schema.ts | 10 +++------- 10 files changed, 60 insertions(+), 26 deletions(-) diff --git a/apps/backend/README.md b/apps/backend/README.md index 3e66f8d9b..35b8c0a55 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -33,7 +33,7 @@ Frontend (React) A node asks a human for a decision by carrying `data.properties.decisionRequest`: the actions offered, the JSON Schema of the form, the node whose output is judged, and an optional deadline. Any node type may carry one; its presence, never `type`, is what makes the run park there. The mapper lifts it to `BaseNode.decisionRequest`, out of `config`. -The request is validated on `POST /:id/publish` and `POST /:id/execute`, never on `PATCH /:id/draft`: a draft is legitimately mid-edit. A broken request answers with the existing `invalid_snapshot` 400, whose `details[].path` points at the node index and field, for example `nodes.1.data.properties.decisionRequest.actions.1.effect`. Every domain message the validation can produce is listed in `src/domain/decision/decision-issues.ts`. +The request is validated on `POST /:id/publish` and `POST /:id/execute`, never on `PATCH /:id/draft`: a draft is legitimately mid-edit. A broken request answers with the existing `invalid_snapshot` 400, whose `details[].path` points at the node index and field, for example `nodes.1.data.properties.decisionRequest.actions.1.effect`. Structural issues come first; the graph rules (proposal source, predecessors) run once the structure parses, so a second round of issues can follow a fix. Every domain message the validation can produce is listed in `src/domain/decision/decision-issues.ts`. A submitted decision is checked against the request by `validateSubmittedDecision` in `src/domain/decision/`; the decision endpoint that calls it is a separate change. Shape, rules and the reasoning are in [`decision-request.decision-log.md`](./decision-request.decision-log.md). diff --git a/apps/backend/decision-request.decision-log.md b/apps/backend/decision-request.decision-log.md index ed896216a..fcbe69f07 100644 --- a/apps/backend/decision-request.decision-log.md +++ b/apps/backend/decision-request.decision-log.md @@ -8,11 +8,11 @@ A run can park at a node until a person decides. The backend needs to know what a decision at that node looks like: the actions offered, the fields the decider sees and may correct, whose output is judged, how long to wait. Products bring their own vocabulary; the engine has a closed set of things it can do; the backend knows no product's node types. -The shape itself is documented on the type (`packages/types/src/workflow-execution/decision-request.ts`) and enforced by `decisionRequestSchema` in `apps/backend/src/domain/decision/`. This log keeps only what the code cannot say. +The shape itself is documented on the type (`packages/types/src/workflow-execution/decision-request.ts`) and enforced by `decisionRequestSchema` in `apps/backend/src/domain/decision/`. This log keeps only what the code cannot say. A complete example, the refund story from the design workshop, is the `workedExample()` fixture in `decision-request-schema.test.ts`. ## Decision -1. **Data on the node, not a node kind.** The request lives under the reserved key `data.properties.decisionRequest` and is lifted to `BaseNode.decisionRequest`, as `errorPolicy` is. Its presence is the only marker; nothing detects such a node by `type`. A client adding its own node type never has to teach the backend about it. +1. **Data on the node, not a node kind.** The request lives under the reserved key `data.properties.decisionRequest` and is lifted to `BaseNode.decisionRequest`, as `errorPolicy` is. Its presence is the only marker; nothing detects such a node by `type`. Present or absent, never `null`: a `null` value is refused, so an editor that clears the request must remove the key. A client adding its own node type never has to teach the backend about it. 2. **Name and effect are split.** `name` and `label` are the client's words ("Escalate to finance"); `effect` is the engine's closed set. A new business vocabulary is data, not a code change. 3. **Edit is not an action.** The decider corrects fields and approves. Whether a field may be edited is already said by `readOnly` in the schema; a second switch would be a second source of truth. `resume-with-edits` is therefore derived, never declared. 4. **JSON Schema for the form**, validated for shape only. The SDK already renders and validates JSON Schema, so the decision form comes for free. A real validator arrives with the first consumer that checks edited values `(follow-up: decision-edit-value-validation)`. @@ -39,6 +39,7 @@ The shape itself is documented on the type (`packages/types/src/workflow-executi - A draft may store an own `__proto__` key; it goes nowhere but the database, and publish and execute refuse it. Rejecting it at save time was judged not worth touching the draft route. - The submission validator returns the first refusal, not a list. - The snapshot schema does not check that edge endpoints exist, so an explicit source with a dangling edge passes. This predates the change. +- Node ids are not checked for uniqueness either; with a duplicate, the graph rules see the first node of that id. Also pre-existing `(follow-up: snapshot-node-id-uniqueness)`. ## Open points diff --git a/apps/backend/src/domain/decision/decision-issues.test.ts b/apps/backend/src/domain/decision/decision-issues.test.ts index 98022c8b8..40ac16f2b 100644 --- a/apps/backend/src/domain/decision/decision-issues.test.ts +++ b/apps/backend/src/domain/decision/decision-issues.test.ts @@ -22,7 +22,7 @@ describe('decisionIssueMessage', () => { it('builds the issue shape a superRefine adds', () => { expect(decisionIssue('port_empty', ['actions', 0, 'port'])).toEqual({ code: 'custom', - message: 'port must not be empty', + message: 'port must not be blank', path: ['actions', 0, 'port'], }); }); diff --git a/apps/backend/src/domain/decision/decision-issues.ts b/apps/backend/src/domain/decision/decision-issues.ts index 7224cd368..6dcea825c 100644 --- a/apps/backend/src/domain/decision/decision-issues.ts +++ b/apps/backend/src/domain/decision/decision-issues.ts @@ -2,17 +2,18 @@ // interpolation slot. Structural failures (wrong type, missing key) keep zod's wording. export const DECISION_ISSUE_MESSAGES = { actions_empty: 'at least one action is required', - name_empty: 'name must not be empty', - label_empty: 'label must not be empty', + name_empty: 'name must not be blank', + label_empty: 'label must not be blank', unknown_effect: 'effect must be one of {value}', duplicate_action_name: "action name '{value}' is used more than once", duplicate_effect: "only one action may have effect '{value}'", resume_required: "an action with effect 'resume' is required", - port_empty: 'port must not be empty', + port_empty: 'port must not be blank', port_reserved: "port must not be the reserved 'errorRoute'", reject_port_equals_resume_port: "reject port '{value}' must differ from the resume port", required_field_undeclared: "required field '{value}' is not declared in properties", - deadline_format: "must be a positive duration such as '30s', '24h' or '3d' (a number followed by ms, s, m, h or d)", + deadline_format: + "must be a duration such as '30s' or '3d' (number plus ms, s, m, h or d), above zero and at most '3652500d'", deadline_policy: "policy must be 'reject'", source_node_without_decision_request: 'this node carries no decision request', source_not_a_predecessor: "proposalSourceNodeId '{value}' is not a direct predecessor of this node", @@ -23,8 +24,7 @@ export const DECISION_ISSUE_MESSAGES = { export type DecisionIssueCode = keyof typeof DECISION_ISSUE_MESSAGES; -// Every way a submitted decision can be refused against the node's decision request. Value -// types are not checked here (follow-up: decision-edit-value-validation) +// Every way a submitted decision can be refused against the node's decision request. export const SUBMITTED_DECISION_ERRORS = { unknown_action: "the decision request offers no action named '{value}'", reason_required: "action '{value}' requires a reason", diff --git a/apps/backend/src/domain/decision/decision-request-schema.test.ts b/apps/backend/src/domain/decision/decision-request-schema.test.ts index 7c73ac046..bb5abb7b7 100644 --- a/apps/backend/src/domain/decision/decision-request-schema.test.ts +++ b/apps/backend/src/domain/decision/decision-request-schema.test.ts @@ -190,6 +190,24 @@ describe('decisionRequestSchema', () => { path: 'actions.2.effect', issue: { code: 'duplicate_effect', value: 'rerun-source' }, }, + { + name: 'a whitespace-only action name', + input: request({ actions: [{ ...approve, name: ' ' }] }), + path: 'actions.0.name', + issue: { code: 'name_empty' }, + }, + { + name: 'a whitespace-only action label', + input: request({ actions: [{ ...approve, label: ' ' }] }), + path: 'actions.0.label', + issue: { code: 'label_empty' }, + }, + { + name: 'a whitespace-only resume port', + input: request({ actions: [{ ...approve, port: '\t' }] }), + path: 'actions.0.port', + issue: { code: 'port_empty' }, + }, { name: 'an empty action name', input: request({ actions: [{ ...approve, name: '' }] }), diff --git a/apps/backend/src/domain/decision/decision-request-schema.ts b/apps/backend/src/domain/decision/decision-request-schema.ts index 172850395..68ccf3350 100644 --- a/apps/backend/src/domain/decision/decision-request-schema.ts +++ b/apps/backend/src/domain/decision/decision-request-schema.ts @@ -21,14 +21,18 @@ function isDurationString(value: string): boolean { const durationSchema = z.string().refine(isDurationString, decisionIssueMessage('deadline_format')); // 'errorRoute' is the handle the runner reserves for the error policy. +function isNotBlank(text: string): boolean { + return text.trim().length > 0; +} + const portSchema = z .string() - .min(1, decisionIssueMessage('port_empty')) + .refine(isNotBlank, decisionIssueMessage('port_empty')) .refine((port) => port !== 'errorRoute', decisionIssueMessage('port_reserved')); const actionBase = { - name: z.string().min(1, decisionIssueMessage('name_empty')), - label: z.string().min(1, decisionIssueMessage('label_empty')), + name: z.string().refine(isNotBlank, decisionIssueMessage('name_empty')), + label: z.string().refine(isNotBlank, decisionIssueMessage('label_empty')), }; const resumeActionSchema = z.looseObject({ @@ -67,8 +71,7 @@ const formPropertySchema = z.looseObject({ 'x-pii': z.boolean().optional(), }); -// Shape only. Validating values against the schema needs a JSON Schema validator the -// backend does not have yet (follow-up: decision-edit-value-validation) +// Shape only; a JSON Schema validator arrives with the first consumer that checks edited values. const formSchema = z .looseObject({ type: z.literal('object'), diff --git a/apps/backend/src/domain/decision/proposal-source.test.ts b/apps/backend/src/domain/decision/proposal-source.test.ts index 3cfacee39..86d77abd5 100644 --- a/apps/backend/src/domain/decision/proposal-source.test.ts +++ b/apps/backend/src/domain/decision/proposal-source.test.ts @@ -52,6 +52,18 @@ describe('resolveProposalSource', () => { expect(resolveProposalSource(nodes, edges, 'review')).toEqual({ sourceNodeId: 'a' }); }); + it('does not count a self-loop as a predecessor, explicit or implicit', () => { + const explicitSelf = [{ id: 'a' }, { id: 'review', decisionRequest: request('review') }]; + const implicitSelf = [{ id: 'review', decisionRequest: request() }]; + + expect(resolveProposalSource(explicitSelf, [edge('a', 'review'), edge('review', 'review')], 'review')).toEqual({ + error: 'explicit_source_not_a_predecessor', + }); + expect(resolveProposalSource(implicitSelf, [edge('review', 'review')], 'review')).toEqual({ + error: 'no_predecessor', + }); + }); + it('reports no predecessor when the node has only outgoing edges', () => { const nodes = [{ id: 'review', decisionRequest: request() }, { id: 'after' }]; diff --git a/apps/backend/src/domain/decision/proposal-source.ts b/apps/backend/src/domain/decision/proposal-source.ts index da4a6b4cf..755842470 100644 --- a/apps/backend/src/domain/decision/proposal-source.ts +++ b/apps/backend/src/domain/decision/proposal-source.ts @@ -11,8 +11,7 @@ export type UnresolvedSourceReason = | 'no_predecessor' | 'ambiguous_predecessor'; -// `error?: undefined` on the success member lets a caller narrow with a plain -// `if (resolution.error !== undefined)` while both-set and neither-set stay unrepresentable. +// Result shape: apps/backend/decision-request.decision-log.md, decision 9. export type ProposalSourceResolution = | { sourceNodeId: string; error?: undefined } | { sourceNodeId?: undefined; error: UnresolvedSourceReason }; @@ -27,7 +26,12 @@ export function resolveProposalSource( const node = nodes.find((candidate) => candidate.id === nodeId); if (node?.decisionRequest === undefined) return { error: 'node_without_decision_request' }; - const predecessors = unique(edges.filter((edge) => edge.targetNodeId === nodeId).map((edge) => edge.sourceNodeId)); + // A self-loop is not a predecessor. + const predecessors = unique( + edges + .filter((edge) => edge.targetNodeId === nodeId && edge.sourceNodeId !== nodeId) + .map((edge) => edge.sourceNodeId), + ); const explicit = node.decisionRequest.proposalSourceNodeId; if (explicit !== undefined) { return predecessors.includes(explicit) diff --git a/apps/backend/src/domain/decision/validate-submitted-decision.ts b/apps/backend/src/domain/decision/validate-submitted-decision.ts index 7b525e5c4..2236684c6 100644 --- a/apps/backend/src/domain/decision/validate-submitted-decision.ts +++ b/apps/backend/src/domain/decision/validate-submitted-decision.ts @@ -60,7 +60,7 @@ function requiredFields(request: DecisionRequest): string[] { } // Presence and editability only. Whether an edited value fits its declared type is a -// later concern with its own validator. +// later concern with its own validator (follow-up: decision-edit-value-validation) export function validateSubmittedDecision( request: DecisionRequest, submitted: SubmittedDecision, diff --git a/apps/backend/src/domain/mapper/snapshot-schema.ts b/apps/backend/src/domain/mapper/snapshot-schema.ts index 333c4aa76..27802e837 100644 --- a/apps/backend/src/domain/mapper/snapshot-schema.ts +++ b/apps/backend/src/domain/mapper/snapshot-schema.ts @@ -1,10 +1,6 @@ -// Validates the workflow snapshot at the HTTP boundary structurally only: -// every node has `id` and `data.type`; every edge has `id`, `source`, `target`. -// `data.properties` is opaque here except for the reserved `decisionRequest` key, -// which is validated as a decision request. The backend does not know any -// product's node vocabulary; per-type validation belongs to whichever worker -// registers executors for it, and an unknown node type surfaces at runtime as -// a `node_failed` event with the missing-executor message. +// Structural validation of the editor snapshot at the HTTP boundary. `data.properties` is +// opaque except for the reserved `decisionRequest` key: the backend knows no product's node +// vocabulary, so an unknown node type fails at runtime as `node_failed`, not here. import { z } from 'zod'; import { type DecisionIssueCode, decisionIssue } from '../decision/decision-issues'; From b6d3c05f54a2c9d8eca69af292e614b4ec384348 Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Tue, 8 Sep 2026 13:41:22 +0200 Subject: [PATCH 15/25] fix(backend): second review pass on the decision request The exported request schema states that it parses a request already inside a guarded snapshot; raw JSON goes through workflowSnapshotSchema. The errorRoute comment sits on portSchema again, the decision log names the guarded parser precisely, a test name says only what it exercises, and three matrix edges are pinned: the inclusive deadline ceiling, a string in edits, a non-string action. WB-500 --- apps/backend/decision-request.decision-log.md | 2 +- .../src/domain/decision/decision-request-schema.test.ts | 2 +- apps/backend/src/domain/decision/decision-request-schema.ts | 6 ++++-- .../src/domain/decision/validate-submitted-decision.test.ts | 2 ++ apps/backend/src/routes/workflows.test.ts | 2 +- 5 files changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/backend/decision-request.decision-log.md b/apps/backend/decision-request.decision-log.md index fcbe69f07..5c63be31d 100644 --- a/apps/backend/decision-request.decision-log.md +++ b/apps/backend/decision-request.decision-log.md @@ -23,7 +23,7 @@ The shape itself is documented on the type (`packages/types/src/workflow-executi 9. **Results carry `error?: undefined`, not an `ok` flag.** `{ value; error?: undefined } | { value?: undefined; error }` reads as plain error handling and the compiler still forbids both-set and neither-set. The flag only repeated what the presence of `error` says. 10. **Vocabulary.** A node carrying a request is a node; no separate noun names it. The rerun effect is named for what it does, `rerun-source`, never for what the source is. Action names in examples (`approve`, `reject`, `ask-again`) are the client's and await a sync with design. -11. **An own `__proto__` key anywhere in a snapshot is refused before parsing.** `JSON.parse` makes it an ordinary key, and zod's loose objects copy unknown keys with a plain assignment, which for that key swaps the output's prototype: everything under it then reads back as validated, and the mapper would copy an inherited request into a real field on the way to the engine. `workflowSnapshotSchema`, the one boundary raw JSON crosses, is wrapped in a preprocess that rejects the key at its path with the usual `invalid_snapshot` 400. +11. **An own `__proto__` key anywhere in a snapshot is refused before parsing.** `JSON.parse` makes it an ordinary key, and zod's loose objects copy unknown keys with a plain assignment, which for that key swaps the output's prototype: everything under it then reads back as validated, and the mapper would copy an inherited request into a real field on the way to the engine. `workflowSnapshotSchema`, the one parser that preserves unknown keys (loose objects), is wrapped in a preprocess that rejects the key at its path with the usual `invalid_snapshot` 400. ## Rejected diff --git a/apps/backend/src/domain/decision/decision-request-schema.test.ts b/apps/backend/src/domain/decision/decision-request-schema.test.ts index bb5abb7b7..8f694d432 100644 --- a/apps/backend/src/domain/decision/decision-request-schema.test.ts +++ b/apps/backend/src/domain/decision/decision-request-schema.test.ts @@ -128,7 +128,7 @@ describe('decisionRequestSchema', () => { expect(parsed.schema.properties['amount']).toEqual({ type: 'number', readOnly: false }); }); - it.each(['100ms', '30s', '10m', '1.5h', '24h', '3d', '7d'])('accepts a deadline of %s', (after) => { + it.each(['100ms', '30s', '10m', '1.5h', '24h', '3d', '7d', '3652500d'])('accepts a deadline of %s', (after) => { expect(decisionRequestSchema.safeParse(request({ deadline: { after, policy: 'reject' } })).success).toBe(true); }); diff --git a/apps/backend/src/domain/decision/decision-request-schema.ts b/apps/backend/src/domain/decision/decision-request-schema.ts index 68ccf3350..8ec549eac 100644 --- a/apps/backend/src/domain/decision/decision-request-schema.ts +++ b/apps/backend/src/domain/decision/decision-request-schema.ts @@ -20,11 +20,11 @@ function isDurationString(value: string): boolean { const durationSchema = z.string().refine(isDurationString, decisionIssueMessage('deadline_format')); -// 'errorRoute' is the handle the runner reserves for the error policy. function isNotBlank(text: string): boolean { return text.trim().length > 0; } +// 'errorRoute' is the handle the runner reserves for the error policy. const portSchema = z .string() .refine(isNotBlank, decisionIssueMessage('port_empty')) @@ -71,7 +71,7 @@ const formPropertySchema = z.looseObject({ 'x-pii': z.boolean().optional(), }); -// Shape only; a JSON Schema validator arrives with the first consumer that checks edited values. +// Shape only. const formSchema = z .looseObject({ type: z.literal('object'), @@ -91,6 +91,8 @@ const deadlineSchema = z.looseObject({ policy: z.string().refine((policy) => policy === 'reject', decisionIssueMessage('deadline_policy')), }); +// Parses a request already inside a guarded snapshot. Raw JSON goes through +// `workflowSnapshotSchema`; `mapper/own-proto-key.ts` says why. export const decisionRequestSchema = z .looseObject({ version: z.literal(1), diff --git a/apps/backend/src/domain/decision/validate-submitted-decision.test.ts b/apps/backend/src/domain/decision/validate-submitted-decision.test.ts index bfe82f2fb..81ceb55a4 100644 --- a/apps/backend/src/domain/decision/validate-submitted-decision.test.ts +++ b/apps/backend/src/domain/decision/validate-submitted-decision.test.ts @@ -252,6 +252,8 @@ describe('submittedDecisionSchema', () => { { name: 'edits as an array', body: { action: 'approve', edits: [] }, path: 'edits' }, { name: 'edits as a number', body: { action: 'approve', edits: 42 }, path: 'edits' }, { name: 'edits as null', body: { action: 'approve', edits: null }, path: 'edits' }, + { name: 'edits as a string', body: { action: 'approve', edits: 'x' }, path: 'edits' }, + { name: 'a non-string action', body: { action: 42 }, path: 'action' }, { name: 'a non-string reason', body: { action: 'reject', reason: 42 }, path: 'reason' }, { name: 'a non-string comment', body: { action: 'ask-again', comment: {} }, path: 'comment' }, { name: 'a missing action', body: { edits: {} }, path: 'action' }, diff --git a/apps/backend/src/routes/workflows.test.ts b/apps/backend/src/routes/workflows.test.ts index 6406f4a6c..43b8d1918 100644 --- a/apps/backend/src/routes/workflows.test.ts +++ b/apps/backend/src/routes/workflows.test.ts @@ -398,7 +398,7 @@ describe('createWorkflowsRoutes - own __proto__ key in the draft', () => { expect(databaseMock.update).not.toHaveBeenCalled(); }); - it('draft save still stores it; only publish and execute refuse', async () => { + it('draft save still stores it', async () => { databaseMock.update.mockReturnValue(chainResolving([{ ...fakeWorkflow, draftJson: poisonedDraft }])); const response = await jsonRequest(allowAllApp(), '/api/workflows/w-1/draft', 'PATCH', { From 857a3576c7e03bddb454282713cd99517b9c0c76 Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Tue, 8 Sep 2026 15:18:58 +0200 Subject: [PATCH 16/25] refactor: record the decision's action by name, return the matched action beside it --- apps/backend/decision-request.decision-log.md | 3 ++- .../validate-submitted-decision.test.ts | 14 ++++++++----- .../decision/validate-submitted-decision.ts | 21 +++++++------------ .../workflow-execution/decision-request.ts | 13 ++++++++++++ 4 files changed, 32 insertions(+), 19 deletions(-) diff --git a/apps/backend/decision-request.decision-log.md b/apps/backend/decision-request.decision-log.md index 5c63be31d..62418d567 100644 --- a/apps/backend/decision-request.decision-log.md +++ b/apps/backend/decision-request.decision-log.md @@ -19,7 +19,7 @@ The shape itself is documented on the type (`packages/types/src/workflow-executi 5. **Validated on publish and execute, never on draft save.** A draft is legitimately mid-edit; validating it would lose the author's work on every autosave. Both routes go through one `parseSnapshot` helper and the existing `invalid_snapshot` 400, so they cannot drift. 6. **One definition of the proposal source.** `resolveProposalSource` is the only place that says which node's output a decision judges. The pending-decision resource and the rerun loop must call it rather than re-derive the rule. 7. **Read requests through the parser, never from raw JSON.** Only the parsed form carries the defaults (`port`, `reasonRequired`, `maxIterations`). The stored snapshot stays raw. -8. **Three names for the lifecycle.** `DecisionRequest` is what the node asks. `SubmittedDecision` is what the decider sends, still unchecked. `Decision` is what validation accepts and what will later be stored and audited. The shape of a submission is parsed with `submittedDecisionSchema` at the endpoint; `validateSubmittedDecision` assumes it and checks only the rules. The field is not called `decision` because it holds the question, not the answer, and not `decisionContract` because that reads as configuration rather than as a request to a person. +8. **Three names for the lifecycle.** `DecisionRequest` is what the node asks. `SubmittedDecision` is what the decider sends, still unchecked. `Decision` is what validation accepts and what is recorded on the node's completion and audited; it names the chosen action and carries the effect, edits, reason and comment. The matched `DecisionAction` is returned beside it for routing, never inside it, so the port and label live once, on the request. The shape of a submission is parsed with `submittedDecisionSchema` at the endpoint; `validateSubmittedDecision` assumes it and checks only the rules. The field is not called `decision` because it holds the question, not the answer, and not `decisionContract` because that reads as configuration rather than as a request to a person. 9. **Results carry `error?: undefined`, not an `ok` flag.** `{ value; error?: undefined } | { value?: undefined; error }` reads as plain error handling and the compiler still forbids both-set and neither-set. The flag only repeated what the presence of `error` says. 10. **Vocabulary.** A node carrying a request is a node; no separate noun names it. The rerun effect is named for what it does, `rerun-source`, never for what the source is. Action names in examples (`approve`, `reject`, `ask-again`) are the client's and await a sync with design. @@ -31,6 +31,7 @@ The shape itself is documented on the type (`packages/types/src/workflow-executi - A home-grown field list instead of JSON Schema: a second standard to render and validate. - An `ignore` verb: a disguised "abandon the run". - Defaulting `deadline.policy` to `reject`: a timer that rejects is audit-relevant and must be written down, not implied. +- Recording the whole action object on the decision: the port and label would then live twice, on the request and in every completion, with two sources of truth about where a verdict routes. - Exporting the duration pattern from the Temporal plugin: a published API widened for one regex; duplicated with a pointer instead `(follow-up: shared-duration-format)`. ## Known gaps diff --git a/apps/backend/src/domain/decision/validate-submitted-decision.test.ts b/apps/backend/src/domain/decision/validate-submitted-decision.test.ts index 81ceb55a4..7e7788bc7 100644 --- a/apps/backend/src/domain/decision/validate-submitted-decision.test.ts +++ b/apps/backend/src/domain/decision/validate-submitted-decision.test.ts @@ -74,7 +74,8 @@ describe('validateSubmittedDecision', () => { expect(result.error).toBeUndefined(); expect(result.decision?.effect).toBe(effect); - expect(result.decision?.action.name).toBe(call.action); + expect(result.decision?.action).toBe(call.action); + expect(result.action?.name).toBe(call.action); }); // Open point in the decision log: until decided otherwise, edits on a non-resume action @@ -205,20 +206,23 @@ describe('validateSubmittedDecision', () => { }); }); - it('builds the decision from the matched action and what was submitted', () => { + it('records the action by name and returns the matched action beside the decision', () => { const submitted = { action: 'approve', edits: { refundAmount: 12 }, comment: 'rounded down' }; - expect(validateSubmittedDecision(requestWith(), submitted).decision).toEqual({ - action: approve, + const result = validateSubmittedDecision(requestWith(), submitted); + + expect(result.decision).toEqual({ + action: 'approve', effect: 'resume-with-edits', edits: { refundAmount: 12 }, comment: 'rounded down', }); + expect(result.action).toEqual(approve); }); it('defaults edits to an empty object when none were submitted', () => { expect(validateSubmittedDecision(requestWith(), { action: 'reject', reason: 'late' }).decision).toEqual({ - action: reject, + action: 'reject', effect: 'reject', edits: {}, reason: 'late', diff --git a/apps/backend/src/domain/decision/validate-submitted-decision.ts b/apps/backend/src/domain/decision/validate-submitted-decision.ts index 2236684c6..63e1d3ba5 100644 --- a/apps/backend/src/domain/decision/validate-submitted-decision.ts +++ b/apps/backend/src/domain/decision/validate-submitted-decision.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; import type { + Decision, DecisionAction, DecisionEffect, DecisionRequest, @@ -20,21 +21,12 @@ export const submittedDecisionSchema = z.object({ export type SubmittedDecision = z.infer; -// A submission the request accepts. The matched action carries the port to route on; -// `effect` is `resume-with-edits` when a resume came with edits. -export type Decision = { - action: DecisionAction; - effect: DecisionEffect; - edits: Record; - reason?: string; - comment?: string; -}; - export type SubmittedDecisionError = { code: SubmittedDecisionErrorCode; message: string; path?: string[] }; +// `action` is the matched action, for routing; the decision itself records only its name. export type SubmittedDecisionResult = - | { decision: Decision; error?: undefined } - | { decision?: undefined; error: SubmittedDecisionError }; + | { decision: Decision; action: DecisionAction; error?: undefined } + | { decision?: undefined; action?: undefined; error: SubmittedDecisionError }; function refuse(code: SubmittedDecisionErrorCode, value: string, path: string[]): SubmittedDecisionResult { return { error: { code, message: submittedDecisionErrorMessage(code, value), path } }; @@ -86,5 +78,8 @@ export function validateSubmittedDecision( const withEdits = Object.keys(edits).length > 0; const effect: DecisionEffect = action.effect === 'resume' && withEdits ? 'resume-with-edits' : action.effect; - return { decision: { action, effect, edits, reason: submitted.reason, comment: submitted.comment } }; + return { + decision: { action: action.name, effect, edits, reason: submitted.reason, comment: submitted.comment }, + action, + }; } diff --git a/packages/types/src/workflow-execution/decision-request.ts b/packages/types/src/workflow-execution/decision-request.ts index 443139b61..ae7128cda 100644 --- a/packages/types/src/workflow-execution/decision-request.ts +++ b/packages/types/src/workflow-execution/decision-request.ts @@ -61,6 +61,19 @@ export type DecisionDeadline = { policy: string; }; +/** + * A decision the request accepted: what is recorded on the node's completion and audited. + * `action` is the name of the chosen action; where it routes stays on the request, so the + * port is never written twice. `effect` is `resume-with-edits` when a resume carried edits. + */ +export type Decision = { + action: string; + effect: DecisionEffect; + edits: Record; + reason?: string; + comment?: string; +}; + /** * What a node asks a human to decide before the run continues. Authored under * `data.properties.decisionRequest` in the editor snapshot and lifted to `BaseNode.decisionRequest`. From f0cc5e147028b88e8fd495618be69821a3d4f5b3 Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Fri, 11 Sep 2026 09:46:35 +0200 Subject: [PATCH 17/25] fix(backend): scan a snapshot for __proto__ without recursion Nesting depth is whatever the client sent, and the scan that runs before parsing was recursive. A draft a few thousand levels deep, well inside the 1 MB body limit, made it throw RangeError. zod does not catch that, so publish and execute answered 500 where the contract promises invalid_snapshot 400, and the stored row stayed unpublishable for good. Measured on Node 22, the version CI uses: the old scan died above depth 3125, around 6 KB of body. The walk now uses an explicit stack and copies the path once, on a hit, which also drops the quadratic path copying. The README gains a note that the check does not weigh position, so it also refuses a key buried in an opaque node property, where zod never copies keys one by one and the key is inert. --- apps/backend/README.md | 2 + .../src/domain/mapper/own-proto-key.test.ts | 14 +++++ .../src/domain/mapper/own-proto-key.ts | 52 ++++++++++++---- apps/backend/src/routes/workflows.test.ts | 59 +++++++++++++++++++ 4 files changed, 115 insertions(+), 12 deletions(-) diff --git a/apps/backend/README.md b/apps/backend/README.md index 35b8c0a55..490114317 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -35,6 +35,8 @@ A node asks a human for a decision by carrying `data.properties.decisionRequest` The request is validated on `POST /:id/publish` and `POST /:id/execute`, never on `PATCH /:id/draft`: a draft is legitimately mid-edit. A broken request answers with the existing `invalid_snapshot` 400, whose `details[].path` points at the node index and field, for example `nodes.1.data.properties.decisionRequest.actions.1.effect`. Structural issues come first; the graph rules (proposal source, predecessors) run once the structure parses, so a second round of issues can follow a fix. Every domain message the validation can produce is listed in `src/domain/decision/decision-issues.ts`. +One key is refused outright, wherever it sits. An own `__proto__` anywhere in the snapshot answers `invalid_snapshot` 400 naming its path: `JSON.parse` turns it into an ordinary key, and a loose object copies unknown keys by assignment, which for that one swaps the parsed output's prototype and hands the engine a request no schema ever saw. The check does not weigh position, so it also refuses a `__proto__` buried inside an opaque node property, where zod never copies keys one by one and the key is inert. A node type that keeps a raw JSON document in `data.properties` therefore cannot carry one. + A submitted decision is checked against the request by `validateSubmittedDecision` in `src/domain/decision/`; the decision endpoint that calls it is a separate change. Shape, rules and the reasoning are in [`decision-request.decision-log.md`](./decision-request.decision-log.md). ## Running individual processes diff --git a/apps/backend/src/domain/mapper/own-proto-key.test.ts b/apps/backend/src/domain/mapper/own-proto-key.test.ts index ec6558c88..c34077cf6 100644 --- a/apps/backend/src/domain/mapper/own-proto-key.test.ts +++ b/apps/backend/src/domain/mapper/own-proto-key.test.ts @@ -16,6 +16,20 @@ describe('findOwnProtoKey', () => { expect(findOwnProtoKey(cyclic)).toBeUndefined(); }); + + // Depth is the client's to pick and only the 1 MB body limit caps it: 50k levels of + // `[` is 100 KB, and a recursive walk died long before that. + it('walks a snapshot nested far deeper than a recursive scan could', () => { + const depth = 50_000; + const nested = (leaf: string): unknown => JSON.parse('['.repeat(depth) + leaf + ']'.repeat(depth)); + + expect(findOwnProtoKey(nested(''))).toBeUndefined(); + + const found = findOwnProtoKey(nested('{"__proto__":{}}')); + + expect(found?.at(-1)).toBe('__proto__'); + expect(found).toHaveLength(depth + 1); + }); }); describe('rejectingOwnProtoKey', () => { diff --git a/apps/backend/src/domain/mapper/own-proto-key.ts b/apps/backend/src/domain/mapper/own-proto-key.ts index f173b41d9..7f929dd04 100644 --- a/apps/backend/src/domain/mapper/own-proto-key.ts +++ b/apps/backend/src/domain/mapper/own-proto-key.ts @@ -2,25 +2,53 @@ import { z } from 'zod'; const OWN_PROTO_KEY_MESSAGE = "the key '__proto__' is not allowed"; +function isWalkable(value: unknown): value is object { + return typeof value === 'object' && value !== null; +} + +// Arrays yield numeric indices, objects string keys, which is the shape a JSON path takes. +function ownEntries(value: object): Iterator<[PropertyKey, unknown]> { + return Array.isArray(value) ? value.entries() : Object.entries(value).values(); +} + // `JSON.parse` turns "__proto__" into an ordinary own key. zod's loose objects copy unknown // keys with a plain assignment, which for that key swaps the output's prototype instead, // so anything under it is read back as if validated. Refuse it before parsing. -export function findOwnProtoKey( - value: unknown, - path: PropertyKey[] = [], - seen = new Set(), -): PropertyKey[] | undefined { - if (typeof value !== 'object' || value === null || seen.has(value)) return undefined; - seen.add(value); - if (Object.hasOwn(value, '__proto__')) return [...path, '__proto__']; - const entries = Array.isArray(value) ? value.entries() : Object.entries(value); - for (const [key, child] of entries) { - const found = findOwnProtoKey(child, [...path, key], seen); - if (found !== undefined) return found; +// +// Walked with an explicit stack, never recursion: a snapshot's nesting depth is whatever the +// client sent, and a blown call stack would answer 500 where this promises a 400. +export function findOwnProtoKey(value: unknown): PropertyKey[] | undefined { + if (!isWalkable(value)) return undefined; + if (Object.hasOwn(value, '__proto__')) return ['__proto__']; + + const seen = new Set([value]); + // One segment per stacked iterator below the root, so the path is copied once, on a hit. + const path: PropertyKey[] = []; + const stack: Iterator<[PropertyKey, unknown]>[] = [ownEntries(value)]; + + while (stack.length > 0) { + const step = stack.at(-1)!.next(); + if (step.done === true) { + stack.pop(); + // A no-op for the root, which owns no segment. + path.pop(); + continue; + } + + const [key, child] = step.value; + if (!isWalkable(child) || seen.has(child)) continue; + if (Object.hasOwn(child, '__proto__')) return [...path, key, '__proto__']; + + seen.add(child); + path.push(key); + stack.push(ownEntries(child)); } + return undefined; } +// Refuses the key outright, at its path. Wrap a payload where it enters: every loose +// object below is then safe without each one having to defend itself. export function rejectingOwnProtoKey(schema: T) { return z.preprocess((value, context) => { const path = findOwnProtoKey(value); diff --git a/apps/backend/src/routes/workflows.test.ts b/apps/backend/src/routes/workflows.test.ts index 43b8d1918..794a3652e 100644 --- a/apps/backend/src/routes/workflows.test.ts +++ b/apps/backend/src/routes/workflows.test.ts @@ -409,3 +409,62 @@ describe('createWorkflowsRoutes - own __proto__ key in the draft', () => { expect(databaseMock.update).toHaveBeenCalledTimes(1); }); }); + +// ---- deeply nested drafts ----------------------------------------------------- +// +// Nesting depth is the client's to choose and the draft route does not validate, so the +// scan that runs before parsing meets whatever was stored. It must answer on the contract, +// never as an unhandled error. + +const DEEP = 20_000; + +function deepDraft(leaf: string): unknown { + return JSON.parse( + '{"nodes":[{"id":"n1","data":{"type":"product/any","properties":{"deep":' + + '['.repeat(DEEP) + + leaf + + ']'.repeat(DEEP) + + '}}}],"edges":[]}', + ); +} + +describe('createWorkflowsRoutes - a draft nested deeper than a call stack', () => { + it('publishes a clean one', async () => { + databaseMock.select.mockReturnValue(chainResolving([{ ...fakeWorkflow, draftJson: deepDraft('') }])); + databaseMock.update.mockReturnValue(chainResolving([{ ...fakeWorkflow, draftJson: null }])); + + const response = await publish(allowAllApp()); + + expect(response.status).toBe(200); + expect(databaseMock.update).toHaveBeenCalledTimes(1); + }); + + it('refuses one hiding an own __proto__ key at the bottom, and points at it', async () => { + databaseMock.select.mockReturnValue( + chainResolving([{ ...fakeWorkflow, draftJson: deepDraft('{"__proto__":{}}') }]), + ); + + const response = await publish(allowAllApp()); + const body = (await response.json()) as InvalidSnapshotBody; + + expect(response.status).toBe(400); + expect(body.code).toBe('invalid_snapshot'); + expect(body.details[0]?.path.slice(0, 5)).toEqual(['nodes', 0, 'data', 'properties', 'deep']); + expect(body.details[0]?.path.at(-1)).toBe('__proto__'); + expect(databaseMock.update).not.toHaveBeenCalled(); + }); + + it('answers execute the same way', async () => { + databaseMock.select.mockReturnValue( + chainResolving([{ ...fakeWorkflow, draftJson: deepDraft('{"__proto__":{}}') }]), + ); + + const response = await jsonRequest(allowAllApp(), '/api/workflows/w-1/execute', 'POST', { + sourceVersion: 'draft', + }); + + expect(response.status).toBe(400); + expect(((await response.json()) as InvalidSnapshotBody).code).toBe('invalid_snapshot'); + expect(engineMock.submit).not.toHaveBeenCalled(); + }); +}); From 6c2aa0a39416a40fa93711ff52e6fcf74369e35e Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Fri, 11 Sep 2026 09:59:41 +0200 Subject: [PATCH 18/25] fix(backend): accept a form property that declares no type The form schema is documented as validated for shape only and opaque to the engine, but every property was required to carry a string `type`. JSON Schema makes `type` optional and allows a list of names, and JsonForms 3.5.1 generates a control for a property carrying only `enum`, `$ref`, `anyOf` or `oneOf`. All of those were refused with invalid_snapshot on publish and execute. Nothing reads a form property's `type`; `readOnly` and `x-pii` are the two keys the backend does read, and their checks are unchanged. Decision 4 now spells out what "shape only" covers, since the loose wording is what let this creep in. --- apps/backend/decision-request.decision-log.md | 2 +- .../decision/decision-request-schema.test.ts | 26 +++++++++++++++++-- .../decision/decision-request-schema.ts | 2 +- apps/backend/src/routes/workflows.test.ts | 19 ++++++++++++-- 4 files changed, 43 insertions(+), 6 deletions(-) diff --git a/apps/backend/decision-request.decision-log.md b/apps/backend/decision-request.decision-log.md index 62418d567..d6b166fff 100644 --- a/apps/backend/decision-request.decision-log.md +++ b/apps/backend/decision-request.decision-log.md @@ -15,7 +15,7 @@ The shape itself is documented on the type (`packages/types/src/workflow-executi 1. **Data on the node, not a node kind.** The request lives under the reserved key `data.properties.decisionRequest` and is lifted to `BaseNode.decisionRequest`, as `errorPolicy` is. Its presence is the only marker; nothing detects such a node by `type`. Present or absent, never `null`: a `null` value is refused, so an editor that clears the request must remove the key. A client adding its own node type never has to teach the backend about it. 2. **Name and effect are split.** `name` and `label` are the client's words ("Escalate to finance"); `effect` is the engine's closed set. A new business vocabulary is data, not a code change. 3. **Edit is not an action.** The decider corrects fields and approves. Whether a field may be edited is already said by `readOnly` in the schema; a second switch would be a second source of truth. `resume-with-edits` is therefore derived, never declared. -4. **JSON Schema for the form**, validated for shape only. The SDK already renders and validates JSON Schema, so the decision form comes for free. A real validator arrives with the first consumer that checks edited values `(follow-up: decision-edit-value-validation)`. +4. **JSON Schema for the form**, validated for shape only. The SDK already renders and validates JSON Schema, so the decision form comes for free. A real validator arrives with the first consumer that checks edited values `(follow-up: decision-edit-value-validation)`. Shape only means: an object with a `properties` map, `required` naming declared fields, and `readOnly`, `x-pii` and `type` well-typed where present. `type` is optional, as JSON Schema makes it and as JsonForms renders without it. Every other keyword passes through unread. 5. **Validated on publish and execute, never on draft save.** A draft is legitimately mid-edit; validating it would lose the author's work on every autosave. Both routes go through one `parseSnapshot` helper and the existing `invalid_snapshot` 400, so they cannot drift. 6. **One definition of the proposal source.** `resolveProposalSource` is the only place that says which node's output a decision judges. The pending-decision resource and the rerun loop must call it rather than re-derive the rule. 7. **Read requests through the parser, never from raw JSON.** Only the parsed form carries the defaults (`port`, `reasonRequired`, `maxIterations`). The stored snapshot stays raw. diff --git a/apps/backend/src/domain/decision/decision-request-schema.test.ts b/apps/backend/src/domain/decision/decision-request-schema.test.ts index 8f694d432..8831b4332 100644 --- a/apps/backend/src/domain/decision/decision-request-schema.test.ts +++ b/apps/backend/src/domain/decision/decision-request-schema.test.ts @@ -64,6 +64,23 @@ describe('decisionRequestSchema', () => { expect(decisionRequestSchema.safeParse(minimal).success).toBe(true); }); + // Shapes JsonForms 3.5.1 generates a control for. The parser reads none of their + // keywords, so each has to reach the renderer exactly as authored. + it.each([ + { shape: 'a list of type names', property: { type: ['string', 'null'] } }, + { shape: 'enum alone', property: { enum: ['open', 'closed'] } }, + { shape: 'a local $ref', property: { $ref: '#/$defs/money' } }, + { shape: 'anyOf alone', property: { anyOf: [{ type: 'string' }, { type: 'number' }] } }, + { shape: 'readOnly beside no type', property: { enum: ['open'], readOnly: true, 'x-pii': true } }, + ])('accepts a form property declared with $shape, untouched', ({ property }) => { + const parsed = decisionRequestSchema.safeParse( + request({ schema: { type: 'object', properties: { field: property } } }), + ); + + expect(parsed.success).toBe(true); + expect(parsed.success ? parsed.data.schema['properties'] : undefined).toEqual({ field: property }); + }); + it.each([...DECLARABLE_DECISION_EFFECTS])('accepts a declared %s action', (effect) => { const resume = { name: 'ok', label: 'OK', effect: 'resume' }; const actions = effect === 'resume' ? [resume] : [resume, { name: 'other', label: 'Other', effect }]; @@ -271,8 +288,13 @@ describe('decisionRequestSchema', () => { path: 'schema.properties', }, { - name: 'a form property without a type', - input: request({ schema: { type: 'object', properties: { refundAmount: { title: 'Refund amount' } } } }), + name: 'a form property whose type is neither a name nor a list of names', + input: request({ schema: { type: 'object', properties: { refundAmount: { type: 7 } } } }), + path: 'schema.properties.refundAmount.type', + }, + { + name: 'a form property whose type list holds something other than a name', + input: request({ schema: { type: 'object', properties: { refundAmount: { type: ['string', 7] } } } }), path: 'schema.properties.refundAmount.type', }, { diff --git a/apps/backend/src/domain/decision/decision-request-schema.ts b/apps/backend/src/domain/decision/decision-request-schema.ts index 8ec549eac..9d3e29f8e 100644 --- a/apps/backend/src/domain/decision/decision-request-schema.ts +++ b/apps/backend/src/domain/decision/decision-request-schema.ts @@ -66,7 +66,7 @@ const decisionActionSchema = z.discriminatedUnion( ); const formPropertySchema = z.looseObject({ - type: z.string(), + type: z.union([z.string(), z.array(z.string())]).optional(), readOnly: z.boolean().optional(), 'x-pii': z.boolean().optional(), }); diff --git a/apps/backend/src/routes/workflows.test.ts b/apps/backend/src/routes/workflows.test.ts index 794a3652e..e17d93e74 100644 --- a/apps/backend/src/routes/workflows.test.ts +++ b/apps/backend/src/routes/workflows.test.ts @@ -275,7 +275,7 @@ describe('createWorkflowsRoutes - execute propagates tenant identity', () => { // version before submitting; both answer with the same `invalid_snapshot` body. // Draft save never validates: a draft is legitimately mid-edit. -function snapshotWithDecisionActions(actions: unknown[]) { +function snapshotWithDecisionActions(actions: unknown[], properties: Record = {}) { return { nodes: [ { id: 'src', data: { type: 'product/any' } }, @@ -283,7 +283,7 @@ function snapshotWithDecisionActions(actions: unknown[]) { id: 'review', data: { type: 'product/any', - properties: { decisionRequest: { version: 1, actions, schema: { type: 'object', properties: {} } } }, + properties: { decisionRequest: { version: 1, actions, schema: { type: 'object', properties } } }, }, }, ], @@ -336,6 +336,21 @@ describe('createWorkflowsRoutes - snapshot validation on publish', () => { expect(await response.json()).toMatchObject({ id: 'w-1', publishedJson: validDecisionSnapshot }); }); + it('accepts a form whose properties carry no type, as JSON Schema allows', async () => { + const draftJson = snapshotWithDecisionActions([approve], { + status: { enum: ['open', 'closed'] }, + amount: { $ref: '#/$defs/money' }, + nickname: { type: ['string', 'null'] }, + }); + databaseMock.select.mockReturnValue(chainResolving([{ ...fakeWorkflow, draftJson }])); + databaseMock.update.mockReturnValue(chainResolving([{ ...fakeWorkflow, publishedJson: draftJson }])); + + const response = await publish(allowAllApp()); + + expect(response.status).toBe(200); + expect(databaseMock.update).toHaveBeenCalledTimes(1); + }); + it('still publishes a workflow without a draft, unvalidated', async () => { databaseMock.select.mockReturnValue(chainResolving([{ ...fakeWorkflow, draftJson: null }])); databaseMock.update.mockReturnValue(chainResolving([{ ...fakeWorkflow, draftJson: null }])); From 16cdaf82d484c7303e248d61229eb03ff2343116 Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Fri, 11 Sep 2026 11:49:46 +0200 Subject: [PATCH 19/25] docs: the runner does not read decisionRequest The backend README and the field's public JSDoc both said its presence, never `type`, is what makes the run park. No engine reads the field: run a graph whose node carries a valid request and the executor runs, downstream runs, the run completes, and nobody is asked anything. The JSDoc ships in the Temporal plugin's declarations, so a consumer would have built against it. Not a gap to close but a sentence that conflated two markers. The backend and the decision endpoint find a request by the field; a run stops where a node's executor returns a waiting result, deliberately, so the runner learns no product's vocabulary. Both texts now say that, the node type whose executor only parks is listed as work outside this change, and a runner test holds the docs to it by failing the day the runner starts reading the field. --- apps/backend/README.md | 4 ++- apps/backend/decision-request.decision-log.md | 2 +- .../execution-core/src/graph-runner.test.ts | 28 +++++++++++++++++++ .../src/workflow-execution/execution-model.ts | 5 ++-- 4 files changed, 35 insertions(+), 4 deletions(-) diff --git a/apps/backend/README.md b/apps/backend/README.md index 490114317..38946f33d 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -31,7 +31,9 @@ Frontend (React) ## Decision request on a node -A node asks a human for a decision by carrying `data.properties.decisionRequest`: the actions offered, the JSON Schema of the form, the node whose output is judged, and an optional deadline. Any node type may carry one; its presence, never `type`, is what makes the run park there. The mapper lifts it to `BaseNode.decisionRequest`, out of `config`. +A node asks a human for a decision by carrying `data.properties.decisionRequest`: the actions offered, the JSON Schema of the form, the node whose output is judged, and an optional deadline. Any node type may carry one: the backend and the decision endpoint find the request by this field, never by `type`. The mapper lifts it to `BaseNode.decisionRequest`, out of `config`. + +The runner does not read the field, deliberately: it learns no product's vocabulary, so a run stops where a node's executor returns a waiting result. A request on a node that never parks therefore validates, reaches the worker and asks nobody anything. The node whose executor does nothing but park is its own task, listed under what this change leaves out. The request is validated on `POST /:id/publish` and `POST /:id/execute`, never on `PATCH /:id/draft`: a draft is legitimately mid-edit. A broken request answers with the existing `invalid_snapshot` 400, whose `details[].path` points at the node index and field, for example `nodes.1.data.properties.decisionRequest.actions.1.effect`. Structural issues come first; the graph rules (proposal source, predecessors) run once the structure parses, so a second round of issues can follow a fix. Every domain message the validation can produce is listed in `src/domain/decision/decision-issues.ts`. diff --git a/apps/backend/decision-request.decision-log.md b/apps/backend/decision-request.decision-log.md index d6b166fff..e8a4b18cf 100644 --- a/apps/backend/decision-request.decision-log.md +++ b/apps/backend/decision-request.decision-log.md @@ -52,7 +52,7 @@ Taken conservatively; confirm or change when the decision endpoint lands. ## Not in this change -Further request fields (condition, four-eyes, several decisions), identity and `x-pii` masking, the decision endpoint, the pending-decision resource, the rerun loop, the deadline timer, and authoring the request in the editor `(follow-up: decision-request-properties-ui)`. +Further request fields (condition, four-eyes, several decisions), identity and `x-pii` masking, the decision endpoint, the pending-decision resource, the rerun loop, the deadline timer, authoring the request in the editor `(follow-up: decision-request-properties-ui)`, and the node that actually parks. The runner learns no product's vocabulary by design, so a run stops where a node's executor returns a waiting result, never because a field is present. The node type whose executor does only that, and therefore waits without side effects of its own, is its own task `(follow-up: human-decision-node)`. ## Status diff --git a/packages/execution-core/src/graph-runner.test.ts b/packages/execution-core/src/graph-runner.test.ts index 047be5767..a4cfee2ac 100644 --- a/packages/execution-core/src/graph-runner.test.ts +++ b/packages/execution-core/src/graph-runner.test.ts @@ -1592,6 +1592,34 @@ describe('runGraph — node_started payload', () => { }); describe('runGraph — waiting results', () => { + // The runner reads no `decisionRequest`, deliberately: it learns no product's vocabulary. + // The field's public JSDoc and the backend README say so; this holds them to it. + it('runs a node carrying a decisionRequest like any other: only a waiting result parks', async () => { + const deciding: TestNode = { + id: 'B', + type: 'test/node', + config: {}, + decisionRequest: { + version: 1, + actions: [{ name: 'approve', label: 'Approve', effect: 'resume', port: 'approved' }], + schema: { type: 'object', properties: {} }, + }, + }; + const { port, callOrder } = makeRunner(); + const events = makeEvents(); + + const outcome = await runGraph( + makeInput([start('A'), deciding, trigger('C')], [edge('e1', 'A', 'B'), edge('e2', 'B', 'C')]), + port, + events.port, + ); + + expect(outcome.status).toBe('completed'); + expect(callOrder).toEqual(['A', 'B', 'C']); + expect(events.events.map((event) => event.type)).not.toContain('node_waiting'); + expect(events.statuses.map((status) => status.status)).not.toContain('waiting'); + }); + it('fails the run when the adapter has no awaitResolution, even with errorPolicy continue on the gate', async () => { const runner = makeRunner({ A: { waits: true } }); const events = makeEvents(); diff --git a/packages/types/src/workflow-execution/execution-model.ts b/packages/types/src/workflow-execution/execution-model.ts index 5e7c08fd8..721f2d81e 100644 --- a/packages/types/src/workflow-execution/execution-model.ts +++ b/packages/types/src/workflow-execution/execution-model.ts @@ -35,8 +35,9 @@ export type BaseNode = { label?: string; errorPolicy?: NodeErrorPolicy; /** - * What this node asks a human to decide before the run continues. This field's - * presence, never `type`, marks a node as one that waits for a decision. + * What this node asks a human to decide before the run continues. The backend and the + * decision endpoint find it by this field, never by `type`. The runner does not read it: + * a run stops where a node's executor returns a waiting result. */ decisionRequest?: DecisionRequest; role?: NodeRole; From 483fbf411ad81bd8c77456528545c5a81e960043 Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Fri, 11 Sep 2026 12:06:57 +0200 Subject: [PATCH 20/25] fix(backend): check edits at every level the form declares The submission validator only walked the outermost object, so a readOnly child was rewritten by replacing the object that held it, a child named by a nested required list was cleared, and a readOnly field inside an array item was swapped. All three came back as an accepted resume-with-edits. The walk is now one recursion over the edited value paired with its schema. An array is described as an object whose keys are indices and whose elements are all declared by `items`, so the three rules stay a single flat loop and the error carries the full path. A level the form does not describe inline declares nothing editable, so an edit into it is an unknown field rather than being waved through. --- apps/backend/decision-request.decision-log.md | 1 + .../validate-submitted-decision.test.ts | 122 ++++++++++++++++++ .../decision/validate-submitted-decision.ts | 74 +++++++++-- 3 files changed, 184 insertions(+), 13 deletions(-) diff --git a/apps/backend/decision-request.decision-log.md b/apps/backend/decision-request.decision-log.md index e8a4b18cf..f4a148c21 100644 --- a/apps/backend/decision-request.decision-log.md +++ b/apps/backend/decision-request.decision-log.md @@ -39,6 +39,7 @@ The shape itself is documented on the type (`packages/types/src/workflow-executi - A workflow with a `null` draft still publishes `null`, unvalidated, as it did before. Changing that is its own decision. - A draft may store an own `__proto__` key; it goes nowhere but the database, and publish and execute refuse it. Rejecting it at save time was judged not worth touching the draft route. - The submission validator returns the first refusal, not a list. +- It checks editability and presence at every level the form describes inline, following `properties` and `items`. A level reached only through `$ref` or a composition keyword describes nothing there, so an edit into it is refused as an unknown field rather than checked `(follow-up: decision-edit-schema-composition)`. - The snapshot schema does not check that edge endpoints exist, so an explicit source with a dangling edge passes. This predates the change. - Node ids are not checked for uniqueness either; with a duplicate, the graph rules see the first node of that id. Also pre-existing `(follow-up: snapshot-node-id-uniqueness)`. diff --git a/apps/backend/src/domain/decision/validate-submitted-decision.test.ts b/apps/backend/src/domain/decision/validate-submitted-decision.test.ts index 7e7788bc7..caa3fe817 100644 --- a/apps/backend/src/domain/decision/validate-submitted-decision.test.ts +++ b/apps/backend/src/domain/decision/validate-submitted-decision.test.ts @@ -33,6 +33,33 @@ function requestWith(overrides: Partial = {}): DecisionRequest }; } +// A form the SDK's own field model produces: an object with children and an array of +// objects. Editability lives on the children, not on the wrapper. +const nestedRequest = (): DecisionRequest => + requestWith({ + schema: { + type: 'object', + properties: { + profile: { + type: 'object', + required: ['nickname'], + properties: { id: { type: 'string', readOnly: true }, nickname: { type: 'string' } }, + }, + lines: { + type: 'array', + items: { + type: 'object', + properties: { + sku: { type: 'string', readOnly: true }, + qty: { type: 'number' }, + origin: { type: 'object', properties: { warehouse: { type: 'string', readOnly: true } } }, + }, + }, + }, + }, + }, + }); + describe('validateSubmittedDecision', () => { it.each<{ name: string; request?: DecisionRequest; call: SubmittedDecision; effect: string }>([ { name: 'a resume without edits resumes', call: { action: 'approve' }, effect: 'resume' }, @@ -57,6 +84,12 @@ describe('validateSubmittedDecision', () => { call: { action: 'approve', edits: { note: '' } }, effect: 'resume-with-edits', }, + { + name: 'editable children of an object and of an array item', + request: nestedRequest(), + call: { action: 'approve', edits: { profile: { nickname: 'Ada' }, lines: [{ qty: 3 }] } }, + effect: 'resume-with-edits', + }, { name: 'a reject without a reason when none is required', call: { action: 'reject' }, effect: 'reject' }, { name: 'a reject with a reason when one is required', @@ -200,12 +233,101 @@ describe('validateSubmittedDecision', () => { value: 'refundAmount', path: ['edits', 'refundAmount'], }, + { + name: 'a read-only child rewritten by replacing the object that holds it', + request: nestedRequest(), + call: { action: 'approve', edits: { profile: { id: 'changed' } } }, + code: 'field_not_editable', + value: 'id', + path: ['edits', 'profile', 'id'], + }, + { + name: "a child the object's own required list names, emptied", + request: nestedRequest(), + call: { action: 'approve', edits: { profile: { nickname: null } } }, + code: 'required_field_missing', + value: 'nickname', + path: ['edits', 'profile', 'nickname'], + }, + { + name: 'a child the nested object does not declare', + request: nestedRequest(), + call: { action: 'approve', edits: { profile: { ghost: 1 } } }, + code: 'unknown_field', + value: 'ghost', + path: ['edits', 'profile', 'ghost'], + }, + { + name: 'a read-only child of an array item, named with its index', + request: nestedRequest(), + call: { action: 'approve', edits: { lines: [{ qty: 2 }, { sku: 'swapped' }] } }, + code: 'field_not_editable', + value: 'sku', + path: ['edits', 'lines', '1', 'sku'], + }, + { + name: 'a child of an object the form declares but never describes', + request: requestWith({ schema: { type: 'object', properties: { opaque: { type: 'object' } } } }), + call: { action: 'approve', edits: { opaque: { anything: 1 } } }, + code: 'unknown_field', + value: 'anything', + path: ['edits', 'opaque', 'anything'], + }, + { + name: 'an element of an array the form declares but never describes', + request: requestWith({ schema: { type: 'object', properties: { rows: { type: 'array' } } } }), + call: { action: 'approve', edits: { rows: [{ anything: 1 }] } }, + code: 'unknown_field', + value: '0', + path: ['edits', 'rows', '0'], + }, + { + name: 'any element of an array whose items are read-only, named by its index', + request: requestWith({ + schema: { type: 'object', properties: { rows: { type: 'array', items: { readOnly: true } } } }, + }), + call: { action: 'approve', edits: { rows: ['a', 'b'] } }, + code: 'field_not_editable', + value: '0', + path: ['edits', 'rows', '0'], + }, + { + name: 'a read-only field three levels down, through an array item', + request: nestedRequest(), + call: { action: 'approve', edits: { lines: [{ qty: 1 }, { origin: { warehouse: 'moved' } }] } }, + code: 'field_not_editable', + value: 'warehouse', + path: ['edits', 'lines', '1', 'origin', 'warehouse'], + }, + { + name: 'a nested child that exists only on Object.prototype', + request: nestedRequest(), + call: { action: 'approve', edits: { profile: { constructor: 1 } } }, + code: 'unknown_field', + value: 'constructor', + path: ['edits', 'profile', 'constructor'], + }, ])('refuses $name', ({ request = requestWith(), call, code, value, path }) => { expect(validateSubmittedDecision(request, call)).toEqual({ error: { code, message: submittedDecisionErrorMessage(code, value), path }, }); }); + // The decision log promises the first refusal, not a list. Submission order decides which. + it('reports only the first bad edit, in the order they were submitted', () => { + const readOnlyFirst = validateSubmittedDecision(requestWith(), { + action: 'approve', + edits: { orderDate: '2026-01-01', discount: 10 }, + }); + const unknownFirst = validateSubmittedDecision(requestWith(), { + action: 'approve', + edits: { discount: 10, orderDate: '2026-01-01' }, + }); + + expect(readOnlyFirst.error).toMatchObject({ code: 'field_not_editable', path: ['edits', 'orderDate'] }); + expect(unknownFirst.error).toMatchObject({ code: 'unknown_field', path: ['edits', 'discount'] }); + }); + it('records the action by name and returns the matched action beside the decision', () => { const submitted = { action: 'approve', edits: { refundAmount: 12 }, comment: 'rounded down' }; diff --git a/apps/backend/src/domain/decision/validate-submitted-decision.ts b/apps/backend/src/domain/decision/validate-submitted-decision.ts index 63e1d3ba5..0000651f0 100644 --- a/apps/backend/src/domain/decision/validate-submitted-decision.ts +++ b/apps/backend/src/domain/decision/validate-submitted-decision.ts @@ -41,14 +41,67 @@ function isEmptied(value: unknown): boolean { return value === undefined || value === null || (typeof value === 'string' && value.trim().length === 0); } -// The request arrived through the parser, so `schema` has the shape checked there; -// the reads below only narrow what `Record` hides. -function formProperties(request: DecisionRequest): Record { - return (request.schema['properties'] ?? {}) as Record; +// The request arrived through the parser, so `schema` has the shape checked there; the +// reads below only narrow what `Record` hides. +function asObject(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; } -function requiredFields(request: DecisionRequest): string[] { - return (request.schema['required'] ?? []) as string[]; +type EditedChild = { key: string; value: unknown; declared: Record | undefined }; + +// What an edited value's children are and which schema declares each. Undefined for a leaf, +// which has none. An array's elements are all declared by `items`, so an index carries no +// rules of its own; a level the form does not describe inline declares nothing at all. +function childrenOf( + schema: Record, + edited: unknown, +): { children: EditedChild[]; required: Set } | undefined { + const fields = asObject(edited); + if (fields !== undefined) { + const properties = asObject(schema['properties']) ?? {}; + const names = schema['required']; + return { + children: Object.entries(fields).map(([key, value]) => ({ + key, + value, + declared: Object.hasOwn(properties, key) ? (asObject(properties[key]) ?? {}) : undefined, + })), + required: new Set(Array.isArray(names) ? (names as string[]) : []), + }; + } + + if (!Array.isArray(edited)) return undefined; + const items = asObject(schema['items']); + return { + children: edited.map((value, index) => ({ key: `${index}`, value, declared: items })), + required: new Set(), + }; +} + +// Every level the form declares, not just the outermost one: a `readOnly` child would +// otherwise be rewritten by replacing the object that holds it +// (follow-up: decision-edit-schema-composition). +function validateEdits( + schema: Record, + edited: unknown, + path: string[], +): SubmittedDecisionResult | undefined { + const level = childrenOf(schema, edited); + if (level === undefined) return undefined; + + for (const { key, value, declared } of level.children) { + const here = [...path, key]; + if (declared === undefined) return refuse('unknown_field', key, here); + if (declared['readOnly'] === true) return refuse('field_not_editable', key, here); + if (level.required.has(key) && isEmptied(value)) return refuse('required_field_missing', key, here); + + const refused = validateEdits(declared, value, here); + if (refused !== undefined) return refused; + } + + return undefined; } // Presence and editability only. Whether an edited value fits its declared type is a @@ -67,14 +120,9 @@ export function validateSubmittedDecision( return refuse('comment_required', action.name, ['comment']); } - const properties = formProperties(request); - const required = new Set(requiredFields(request)); const edits = submitted.edits ?? {}; - for (const [field, value] of Object.entries(edits)) { - if (!Object.hasOwn(properties, field)) return refuse('unknown_field', field, ['edits', field]); - if (properties[field].readOnly === true) return refuse('field_not_editable', field, ['edits', field]); - if (required.has(field) && isEmptied(value)) return refuse('required_field_missing', field, ['edits', field]); - } + const refused = validateEdits(request.schema, edits, ['edits']); + if (refused !== undefined) return refused; const withEdits = Object.keys(edits).length > 0; const effect: DecisionEffect = action.effect === 'resume' && withEdits ? 'resume-with-edits' : action.effect; From 55a48c7388bde098bbefe32da1494cb01aeadf4e Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Fri, 11 Sep 2026 12:36:58 +0200 Subject: [PATCH 21/25] fix(backend): guard the decision request wherever it is parsed The guard wrapped only workflowSnapshotSchema, so the exported decisionRequestSchema swapped the parsed output's prototype on its own. Every level of a request is a loose object, and an own __proto__ at the request, an action, the form schema or a form property came back as an inherited field no schema had seen. Today's routes reach a request through the guarded snapshot, so nothing was exposed; the trap was waiting for the decision endpoint and the pending-decision resource. The export now guards itself. own-proto-key moves to domain/schema/, so the decision schema can use it without domain/decision importing from domain/mapper, which would have turned a one-way dependency into a two-way one. mapNode reads own keys only. It is the one read that turns an inherited field into a real one on the node sent to Temporal, and it is exported, so it can be reached down a path the guard never saw. --- apps/backend/decision-request.decision-log.md | 2 +- .../decision/decision-request-schema.test.ts | 33 ++++++++ .../decision/decision-request-schema.ts | 82 ++++++++++--------- .../domain/mapper/from-integration-data.ts | 5 +- .../src/domain/mapper/snapshot-schema.test.ts | 18 ++++ .../src/domain/mapper/snapshot-schema.ts | 2 +- .../{mapper => schema}/own-proto-key.test.ts | 0 .../{mapper => schema}/own-proto-key.ts | 0 8 files changed, 101 insertions(+), 41 deletions(-) rename apps/backend/src/domain/{mapper => schema}/own-proto-key.test.ts (100%) rename apps/backend/src/domain/{mapper => schema}/own-proto-key.ts (100%) diff --git a/apps/backend/decision-request.decision-log.md b/apps/backend/decision-request.decision-log.md index f4a148c21..f0d23f583 100644 --- a/apps/backend/decision-request.decision-log.md +++ b/apps/backend/decision-request.decision-log.md @@ -23,7 +23,7 @@ The shape itself is documented on the type (`packages/types/src/workflow-executi 9. **Results carry `error?: undefined`, not an `ok` flag.** `{ value; error?: undefined } | { value?: undefined; error }` reads as plain error handling and the compiler still forbids both-set and neither-set. The flag only repeated what the presence of `error` says. 10. **Vocabulary.** A node carrying a request is a node; no separate noun names it. The rerun effect is named for what it does, `rerun-source`, never for what the source is. Action names in examples (`approve`, `reject`, `ask-again`) are the client's and await a sync with design. -11. **An own `__proto__` key anywhere in a snapshot is refused before parsing.** `JSON.parse` makes it an ordinary key, and zod's loose objects copy unknown keys with a plain assignment, which for that key swaps the output's prototype: everything under it then reads back as validated, and the mapper would copy an inherited request into a real field on the way to the engine. `workflowSnapshotSchema`, the one parser that preserves unknown keys (loose objects), is wrapped in a preprocess that rejects the key at its path with the usual `invalid_snapshot` 400. +11. **An own `__proto__` key anywhere in a snapshot is refused before parsing.** `JSON.parse` makes it an ordinary key, and zod's loose objects copy unknown keys with a plain assignment, which for that key swaps the output's prototype: everything under it then reads back as validated, and the mapper would copy an inherited request into a real field on the way to the engine. Both parsers that preserve unknown keys are wrapped in a preprocess that rejects the key at its path: `workflowSnapshotSchema`, which answers the usual `invalid_snapshot` 400, and `decisionRequestSchema`, which guards itself so a caller parsing raw JSON with it cannot inherit a request no schema checked. `z.record` is immune by construction but cannot type known keys beside unknown ones, and it protects only its own level, so it is no substitute here. ## Rejected diff --git a/apps/backend/src/domain/decision/decision-request-schema.test.ts b/apps/backend/src/domain/decision/decision-request-schema.test.ts index 8831b4332..349d24351 100644 --- a/apps/backend/src/domain/decision/decision-request-schema.test.ts +++ b/apps/backend/src/domain/decision/decision-request-schema.test.ts @@ -371,3 +371,36 @@ describe('decisionRequestSchema', () => { expectTypeOf>().toMatchTypeOf(); }); }); + +// Every level of a request is a loose object, and a loose object copies an unknown key by +// assignment, which for this one swaps the parsed output's prototype. The schema guards +// itself rather than relying on the snapshot it is usually nested in. +describe('decisionRequestSchema: own __proto__ keys, parsed on its own', () => { + const action = '{"name":"approve","label":"Approve","effect":"resume"}'; + const form = '{"type":"object","properties":{}}'; + + it.each([ + { + where: 'at the request level', + json: `{"version":1,"actions":[${action}],"schema":${form},"__proto__":{"deadline":{"after":"nonsense"}}}`, + path: '__proto__', + }, + { + where: 'inside an action', + json: `{"version":1,"actions":[{"name":"a","label":"A","effect":"resume","__proto__":{"port":"stolen"}}],"schema":${form}}`, + path: 'actions.0.__proto__', + }, + { + where: 'inside the form schema', + json: `{"version":1,"actions":[${action}],"schema":{"type":"object","properties":{},"__proto__":{"required":["x"]}}}`, + path: 'schema.__proto__', + }, + { + where: 'inside a form property', + json: `{"version":1,"actions":[${action}],"schema":{"type":"object","properties":{"amount":{"type":"number","__proto__":{"readOnly":true}}}}}`, + path: 'schema.properties.amount.__proto__', + }, + ])('refuses one $where', ({ json, path }) => { + expect(issuesOf(JSON.parse(json))).toEqual([{ path, message: "the key '__proto__' is not allowed" }]); + }); +}); diff --git a/apps/backend/src/domain/decision/decision-request-schema.ts b/apps/backend/src/domain/decision/decision-request-schema.ts index 9d3e29f8e..ab0b6d63e 100644 --- a/apps/backend/src/domain/decision/decision-request-schema.ts +++ b/apps/backend/src/domain/decision/decision-request-schema.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; import { DECLARABLE_DECISION_EFFECTS } from '@workflow-builder/types/workflow-execution/decision-request'; +import { rejectingOwnProtoKey } from '../schema/own-proto-key'; import { decisionIssue, decisionIssueMessage } from './decision-issues'; // Mirrors DURATION_PATTERN and the protobuf Duration range in @@ -91,45 +92,50 @@ const deadlineSchema = z.looseObject({ policy: z.string().refine((policy) => policy === 'reject', decisionIssueMessage('deadline_policy')), }); -// Parses a request already inside a guarded snapshot. Raw JSON goes through -// `workflowSnapshotSchema`; `mapper/own-proto-key.ts` says why. -export const decisionRequestSchema = z - .looseObject({ - version: z.literal(1), - actions: z.array(decisionActionSchema).min(1, decisionIssueMessage('actions_empty')), - schema: formSchema, - uiSchema: z.record(z.string(), z.unknown()).optional(), - proposalSourceNodeId: z.string().optional(), - deadline: deadlineSchema.optional(), - }) - .superRefine((request, context) => { - const seenNames = new Set(); - const firstIndexByEffect = new Map(); - - for (const [index, action] of request.actions.entries()) { - if (seenNames.has(action.name)) { - context.addIssue(decisionIssue('duplicate_action_name', ['actions', index, 'name'], action.name)); +// Guarded on its own, not only through `workflowSnapshotSchema`: every level below is a +// loose object, so a caller parsing raw JSON with this export would inherit a request no +// schema checked. `../schema/own-proto-key.ts` says why a loose object needs that. +export const decisionRequestSchema = rejectingOwnProtoKey( + z + .looseObject({ + version: z.literal(1), + actions: z.array(decisionActionSchema).min(1, decisionIssueMessage('actions_empty')), + schema: formSchema, + uiSchema: z.record(z.string(), z.unknown()).optional(), + proposalSourceNodeId: z.string().optional(), + deadline: deadlineSchema.optional(), + }) + .superRefine((request, context) => { + const seenNames = new Set(); + const firstIndexByEffect = new Map(); + + for (const [index, action] of request.actions.entries()) { + if (seenNames.has(action.name)) { + context.addIssue(decisionIssue('duplicate_action_name', ['actions', index, 'name'], action.name)); + } + seenNames.add(action.name); + + if (firstIndexByEffect.has(action.effect)) { + context.addIssue(decisionIssue('duplicate_effect', ['actions', index, 'effect'], action.effect)); + } else { + firstIndexByEffect.set(action.effect, index); + } } - seenNames.add(action.name); - if (firstIndexByEffect.has(action.effect)) { - context.addIssue(decisionIssue('duplicate_effect', ['actions', index, 'effect'], action.effect)); - } else { - firstIndexByEffect.set(action.effect, index); + const resumeIndex = firstIndexByEffect.get('resume'); + if (resumeIndex === undefined) { + context.addIssue(decisionIssue('resume_required', ['actions'])); + return; } - } - - const resumeIndex = firstIndexByEffect.get('resume'); - if (resumeIndex === undefined) { - context.addIssue(decisionIssue('resume_required', ['actions'])); - return; - } - const rejectIndex = firstIndexByEffect.get('reject'); - if (rejectIndex === undefined) return; - const resume = request.actions[resumeIndex]; - const reject = request.actions[rejectIndex]; - if (resume.effect === 'resume' && reject.effect === 'reject' && resume.port === reject.port) { - context.addIssue(decisionIssue('reject_port_equals_resume_port', ['actions', rejectIndex, 'port'], reject.port)); - } - }); + const rejectIndex = firstIndexByEffect.get('reject'); + if (rejectIndex === undefined) return; + const resume = request.actions[resumeIndex]; + const reject = request.actions[rejectIndex]; + if (resume.effect === 'resume' && reject.effect === 'reject' && resume.port === reject.port) { + context.addIssue( + decisionIssue('reject_port_equals_resume_port', ['actions', rejectIndex, 'port'], reject.port), + ); + } + }), +); diff --git a/apps/backend/src/domain/mapper/from-integration-data.ts b/apps/backend/src/domain/mapper/from-integration-data.ts index 0a389ec8e..5a20969f8 100644 --- a/apps/backend/src/domain/mapper/from-integration-data.ts +++ b/apps/backend/src/domain/mapper/from-integration-data.ts @@ -32,7 +32,10 @@ export function mapToExecutionModel(workflowId: string, data: WorkflowSnapshot): // `sharedProperties`) and `decisionRequest` (validated and defaulted by the parse, unchecked here). // `role` comes from `data.isStartNode` beside the properties; `description` stays in `config`. function mapNode(node: FrontendNode): BaseNode { - const { errorPolicy: rawErrorPolicy, label: rawLabel, decisionRequest, ...config } = node.data.properties ?? {}; + // Spread first, so only own keys are read. The parse keeps unknown keys, and an own + // `__proto__` among them would leave the properties inheriting fields no schema saw; + // this is the one read that would turn such a field into a real one on the way out. + const { errorPolicy: rawErrorPolicy, label: rawLabel, decisionRequest, ...config } = { ...node.data.properties }; const errorPolicy = isErrorPolicy(rawErrorPolicy) ? rawErrorPolicy : undefined; const label = isNonEmptyString(rawLabel) ? rawLabel.trim() : undefined; const role: NodeRole | undefined = node.data.isStartNode === true ? 'start' : undefined; diff --git a/apps/backend/src/domain/mapper/snapshot-schema.test.ts b/apps/backend/src/domain/mapper/snapshot-schema.test.ts index 28c28cb1a..c9e9a7ee4 100644 --- a/apps/backend/src/domain/mapper/snapshot-schema.test.ts +++ b/apps/backend/src/domain/mapper/snapshot-schema.test.ts @@ -366,6 +366,24 @@ describe('mapToExecutionModel', () => { ]); }); + // `workflowSnapshotSchema` refuses an own `__proto__`, so this reaches the mapper only if + // that guard is bypassed. The mapper is exported, so it can be. + it('ignores properties that are only inherited, however they got there', () => { + const snapshot = workflowSnapshotSchema.parse({ + nodes: [{ id: 'n1', data: { type: 'product/foo', properties: { own: 1 } } }], + edges: [], + }); + Object.setPrototypeOf(snapshot.nodes[0]?.data.properties ?? {}, { + decisionRequest: { version: 99, actions: [] }, + label: 'Inherited', + errorPolicy: 'continue', + }); + + const result = mapToExecutionModel('wf-1', snapshot); + + expect(result.nodes).toEqual([{ id: 'n1', type: 'product/foo', config: { own: 1 } }]); + }); + it('defaults `config` to `{}` when properties are absent', () => { const snapshot = workflowSnapshotSchema.parse({ nodes: [{ id: 'n1', data: { type: 'product/empty' } }], diff --git a/apps/backend/src/domain/mapper/snapshot-schema.ts b/apps/backend/src/domain/mapper/snapshot-schema.ts index 27802e837..18418f1ab 100644 --- a/apps/backend/src/domain/mapper/snapshot-schema.ts +++ b/apps/backend/src/domain/mapper/snapshot-schema.ts @@ -6,7 +6,7 @@ import { z } from 'zod'; import { type DecisionIssueCode, decisionIssue } from '../decision/decision-issues'; import { decisionRequestSchema } from '../decision/decision-request-schema'; import { type UnresolvedSourceReason, resolveProposalSource } from '../decision/proposal-source'; -import { rejectingOwnProtoKey } from './own-proto-key'; +import { rejectingOwnProtoKey } from '../schema/own-proto-key'; const frontendNodeSchema = z.object({ id: z.string(), diff --git a/apps/backend/src/domain/mapper/own-proto-key.test.ts b/apps/backend/src/domain/schema/own-proto-key.test.ts similarity index 100% rename from apps/backend/src/domain/mapper/own-proto-key.test.ts rename to apps/backend/src/domain/schema/own-proto-key.test.ts diff --git a/apps/backend/src/domain/mapper/own-proto-key.ts b/apps/backend/src/domain/schema/own-proto-key.ts similarity index 100% rename from apps/backend/src/domain/mapper/own-proto-key.ts rename to apps/backend/src/domain/schema/own-proto-key.ts From 0665e7a7060f6a75efccfd4bd75754b5e8fe9b0a Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Mon, 14 Sep 2026 09:55:18 +0200 Subject: [PATCH 22/25] fix(backend): a published decision must name a node to judge Resolving the proposal source was skipped unless the request declared an explicit source or a rerun-source action. A node with no predecessor published, and so did one behind a join with no explicit source, while the same resolver answered source_missing and source_ambiguous for those graphs. The first is an orphan the runner fails anyway; the second hands the decider a form and nothing to judge, discovered while a person is already waiting. The resolver now runs for every request. Only the rule that the source must not carry its own request stays tied to rerun-source, the one effect that re-runs it. Two accepting cases became refusals, and two fixtures gained a predecessor so they go on testing what their names say. The field's public JSDoc and decision 6 said the permissive thing; both now state the rule. Decision 6 also records why the pending-decision resource keeps its no-proposal path: a resolved source can still be skipped at run time. --- apps/backend/decision-request.decision-log.md | 2 +- .../src/domain/mapper/snapshot-schema.test.ts | 34 ++++++++++++------- .../src/domain/mapper/snapshot-schema.ts | 1 - .../workflow-execution/decision-request.ts | 6 ++-- 4 files changed, 27 insertions(+), 16 deletions(-) diff --git a/apps/backend/decision-request.decision-log.md b/apps/backend/decision-request.decision-log.md index f0d23f583..2f0ad4bd6 100644 --- a/apps/backend/decision-request.decision-log.md +++ b/apps/backend/decision-request.decision-log.md @@ -17,7 +17,7 @@ The shape itself is documented on the type (`packages/types/src/workflow-executi 3. **Edit is not an action.** The decider corrects fields and approves. Whether a field may be edited is already said by `readOnly` in the schema; a second switch would be a second source of truth. `resume-with-edits` is therefore derived, never declared. 4. **JSON Schema for the form**, validated for shape only. The SDK already renders and validates JSON Schema, so the decision form comes for free. A real validator arrives with the first consumer that checks edited values `(follow-up: decision-edit-value-validation)`. Shape only means: an object with a `properties` map, `required` naming declared fields, and `readOnly`, `x-pii` and `type` well-typed where present. `type` is optional, as JSON Schema makes it and as JsonForms renders without it. Every other keyword passes through unread. 5. **Validated on publish and execute, never on draft save.** A draft is legitimately mid-edit; validating it would lose the author's work on every autosave. Both routes go through one `parseSnapshot` helper and the existing `invalid_snapshot` 400, so they cannot drift. -6. **One definition of the proposal source.** `resolveProposalSource` is the only place that says which node's output a decision judges. The pending-decision resource and the rerun loop must call it rather than re-derive the rule. +6. **One definition of the proposal source.** `resolveProposalSource` is the only place that says which node's output a decision judges. The pending-decision resource and the rerun loop must call it rather than re-derive the rule. Publishing refuses a request whose source does not resolve: a published decision with nothing to judge is not what the author meant, and with no predecessor at all the node is an orphan the runner fails anyway. Only the rule that the source must not carry its own request stays tied to `rerun-source`, the one effect that re-runs it. A resolved source can still yield no proposal at decision time, when that branch was skipped, so the pending-decision resource keeps its no-proposal path. 7. **Read requests through the parser, never from raw JSON.** Only the parsed form carries the defaults (`port`, `reasonRequired`, `maxIterations`). The stored snapshot stays raw. 8. **Three names for the lifecycle.** `DecisionRequest` is what the node asks. `SubmittedDecision` is what the decider sends, still unchecked. `Decision` is what validation accepts and what is recorded on the node's completion and audited; it names the chosen action and carries the effect, edits, reason and comment. The matched `DecisionAction` is returned beside it for routing, never inside it, so the port and label live once, on the request. The shape of a submission is parsed with `submittedDecisionSchema` at the endpoint; `validateSubmittedDecision` assumes it and checks only the rules. The field is not called `decision` because it holds the question, not the answer, and not `decisionContract` because that reads as configuration rather than as a request to a person. 9. **Results carry `error?: undefined`, not an `ok` flag.** `{ value; error?: undefined } | { value?: undefined; error }` reads as plain error handling and the compiler still forbids both-set and neither-set. The flag only repeated what the presence of `error` says. diff --git a/apps/backend/src/domain/mapper/snapshot-schema.test.ts b/apps/backend/src/domain/mapper/snapshot-schema.test.ts index c9e9a7ee4..9599f12d0 100644 --- a/apps/backend/src/domain/mapper/snapshot-schema.test.ts +++ b/apps/backend/src/domain/mapper/snapshot-schema.test.ts @@ -218,21 +218,15 @@ describe('workflowSnapshotSchema: decision requests', () => { edges: [edge('a', 'review', 'left'), edge('a', 'review', 'right')], }, }, - { - name: 'a node without rerun-source and with several predecessors and no explicit source', - snapshot: { - nodes: [node('a'), node('b'), decisionNode('review', { actions: [approve] })], - edges: [edge('a', 'review'), edge('b', 'review')], - }, - }, { name: 'a node without rerun-source whose explicit source carries its own decision request', snapshot: { nodes: [ + node('a'), decisionNode('first', { actions: [approve] }), decisionNode('second', { actions: [approve], proposalSourceNodeId: 'first' }), ], - edges: [edge('first', 'second')], + edges: [edge('a', 'first'), edge('first', 'second')], }, }, { @@ -305,6 +299,21 @@ describe('workflowSnapshotSchema: decision requests', () => { path: 'nodes.2.data.properties.decisionRequest.proposalSourceNodeId', issue: { code: 'source_has_decision_request', value: 'first' }, }, + { + name: 'a node with no predecessor, so its proposal source cannot be resolved', + snapshot: { nodes: [decisionNode('review', { actions: [approve] })], edges: [] }, + path: 'nodes.0.data.properties.decisionRequest.proposalSourceNodeId', + issue: { code: 'source_missing' }, + }, + { + name: 'a node with several predecessors and no explicit source', + snapshot: { + nodes: [node('a'), node('b'), decisionNode('review', { actions: [approve] })], + edges: [edge('a', 'review'), edge('b', 'review')], + }, + path: 'nodes.2.data.properties.decisionRequest.proposalSourceNodeId', + issue: { code: 'source_ambiguous' }, + }, { name: 'a rerun-source node whose explicit source carries its own decision request', snapshot: { @@ -547,22 +556,23 @@ describe('mapToExecutionModel', () => { }; const snapshot = workflowSnapshotSchema.parse({ nodes: [ + { id: 'a', data: { type: 'product/any' } }, { id: 'review', data: { type: 'product/any', properties: { label: 'Review', foo: 1, decisionRequest: request } }, }, ], - edges: [], + edges: [{ id: 'e1', source: 'a', target: 'review' }], }); const result = mapToExecutionModel('wf-1', snapshot); - expect(result.nodes[0]!.decisionRequest).toEqual({ + expect(result.nodes[1]!.decisionRequest).toEqual({ ...request, actions: [{ ...request.actions[0], port: 'approved' }], }); - expect(result.nodes[0]!.config).toEqual({ foo: 1 }); - expect(result.nodes[0]!.label).toBe('Review'); + expect(result.nodes[1]!.config).toEqual({ foo: 1 }); + expect(result.nodes[1]!.label).toBe('Review'); }); it('gives a node without a request no `decisionRequest` key', () => { diff --git a/apps/backend/src/domain/mapper/snapshot-schema.ts b/apps/backend/src/domain/mapper/snapshot-schema.ts index 18418f1ab..aa517df3a 100644 --- a/apps/backend/src/domain/mapper/snapshot-schema.ts +++ b/apps/backend/src/domain/mapper/snapshot-schema.ts @@ -52,7 +52,6 @@ export const workflowSnapshotSchema = rejectingOwnProtoKey( const request = node.data.properties?.decisionRequest; if (request === undefined) continue; const declaresRerun = request.actions.some((action) => action.effect === 'rerun-source'); - if (request.proposalSourceNodeId === undefined && !declaresRerun) continue; const path = ['nodes', index, 'data', 'properties', 'decisionRequest', 'proposalSourceNodeId']; const resolution = resolveProposalSource(nodes, edges, node.id); diff --git a/packages/types/src/workflow-execution/decision-request.ts b/packages/types/src/workflow-execution/decision-request.ts index ae7128cda..509fdd229 100644 --- a/packages/types/src/workflow-execution/decision-request.ts +++ b/packages/types/src/workflow-execution/decision-request.ts @@ -92,8 +92,10 @@ export type DecisionRequest = { /** JsonForms UI schema for the decision form. Passed through; never read by the backend. */ uiSchema?: Record; /** - * The proposal source: the node whose output the decider judges. Must be a direct - * predecessor of the deciding node; absent means its only predecessor. + * The proposal source: the node whose output the decider judges. When set it must be a + * direct predecessor of the deciding node; when absent it is the node's single direct + * predecessor. Publishing refuses a request whose source does not resolve, so a published + * decision always names one node to judge. */ proposalSourceNodeId?: string; /** Absent means the node waits forever. */ From 48835a6fe66fc82d0a7583c2124a855a843ac8a3 Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Mon, 14 Sep 2026 10:29:33 +0200 Subject: [PATCH 23/25] fix(backend): execute and publish agree on when a snapshot is absent Publish validated any draft that was not null; execute short-circuited on any falsy value. For an empty string, zero or false the two routes disagreed, one answering invalid_snapshot and the other published_version_missing, while the shared helper's comment promised they could never drift. Execute now tests for null, and the route test that compares their answers became a table over exactly those values. The section comments this change had added to the route tests restated the test names; each is now the header plus the one fact the code does not show. --- apps/backend/src/routes/workflows.test.ts | 25 +++++++++++------------ apps/backend/src/routes/workflows.ts | 2 +- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/apps/backend/src/routes/workflows.test.ts b/apps/backend/src/routes/workflows.test.ts index e17d93e74..3da30574c 100644 --- a/apps/backend/src/routes/workflows.test.ts +++ b/apps/backend/src/routes/workflows.test.ts @@ -270,10 +270,7 @@ describe('createWorkflowsRoutes - execute propagates tenant identity', () => { }); // ---- snapshot validation on publish and execute ----------------------------- -// -// Publish validates the draft before copying it and execute validates the chosen -// version before submitting; both answer with the same `invalid_snapshot` body. -// Draft save never validates: a draft is legitimately mid-edit. +// Never on draft save: a draft is legitimately mid-edit. function snapshotWithDecisionActions(actions: unknown[], properties: Record = {}) { return { @@ -361,8 +358,15 @@ describe('createWorkflowsRoutes - snapshot validation on publish', () => { expect(databaseMock.update).toHaveBeenCalledTimes(1); }); - it('answers with the same body execute gives for the same broken snapshot', async () => { - databaseMock.select.mockReturnValue(chainResolving([{ ...fakeWorkflow, draftJson: twoResumesSnapshot }])); + // The falsy scalars used to short-circuit execute into published_version_missing while + // publish went on to validate them; only null means "no version". + it.each([ + { name: 'a broken decision request', draftJson: twoResumesSnapshot }, + { name: 'an empty string', draftJson: '' }, + { name: 'zero', draftJson: 0 }, + { name: 'false', draftJson: false }, + ])('answers with the same body execute gives for $name', async ({ draftJson }) => { + databaseMock.select.mockReturnValue(chainResolving([{ ...fakeWorkflow, draftJson }])); const publishResponse = await publish(allowAllApp()); const publishBody = await publishResponse.json(); @@ -391,9 +395,7 @@ describe('createWorkflowsRoutes - draft save never validates the snapshot', () = }); // ---- own __proto__ keys in a stored draft ------------------------------------- -// -// A draft is stored as sent, so it can carry an own `__proto__` key. Publish and -// execute refuse it before the parser could turn it into the snapshot's prototype. +// A draft is stored as sent, so it can carry one. const poisonedDraft = JSON.parse( '{"nodes":[{"id":"n1","data":{"type":"product/any","properties":' + @@ -426,10 +428,7 @@ describe('createWorkflowsRoutes - own __proto__ key in the draft', () => { }); // ---- deeply nested drafts ----------------------------------------------------- -// -// Nesting depth is the client's to choose and the draft route does not validate, so the -// scan that runs before parsing meets whatever was stored. It must answer on the contract, -// never as an unhandled error. +// Depth is the client's to choose, and only the body limit caps it. const DEEP = 20_000; diff --git a/apps/backend/src/routes/workflows.ts b/apps/backend/src/routes/workflows.ts index 3b1826d69..283424c80 100644 --- a/apps/backend/src/routes/workflows.ts +++ b/apps/backend/src/routes/workflows.ts @@ -196,7 +196,7 @@ export function createWorkflowsRoutes( const snapshotJson = body.sourceVersion === 'published' ? workflow.publishedJson : workflow.draftJson; - if (!snapshotJson) { + if (snapshotJson === null) { return c.json({ code: 'published_version_missing', message: `No ${body.sourceVersion} version available` }, 400); } From 464e9744ccfff6b4ac833760d330e56ee757a681 Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Mon, 14 Sep 2026 10:29:39 +0200 Subject: [PATCH 24/25] docs: regenerate the decision-log index The collector had been failing since June: it looks for a Date line and the TenantContextPort log carried Proposed/Landed instead, and the durable-pause log had no metadata block at all. Both headers now follow the convention of the other logs, with every date and the landing commit kept. The regenerated index gains eight entries that had been missing, five of them since spring. --- DECISION-LOGS.md | 8 ++++++++ apps/backend/tenant-context-port.decision-log.md | 2 +- .../temporal/src/workflow/durable-pause.decision-log.md | 6 +++++- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/DECISION-LOGS.md b/DECISION-LOGS.md index c2df150c7..a756d75cf 100644 --- a/DECISION-LOGS.md +++ b/DECISION-LOGS.md @@ -6,14 +6,22 @@ - _08.04.2025_: [Lazy-loaded Icons](./apps/icons/lazy-loaded-icons-08-04-2025.decision-log.md) - _15.04.2025_: [Internationalization implementation with i18next](./packages/sdk/src/features/i18n/i18next.decision-log.md) - _26.05.2025_: [JSON Form Validation Strategy](./packages/sdk/src/features/json-form/form-validation.decision-log.md) +- _05.03.2026_: [Independent docs deployment strategy](./apps/docs/docs-deployment.decision-log.md) +- _13.03.2026_: [Remark plugin for automatic base path link rewriting](./apps/docs/remark-base-path-links.decision-log.md) - _16.04.2026_: [CSP-safe Ajv replacement with @cfworker/json-schema](./packages/sdk/src/utils/validation/workflow-builder-validator-16-04-2026.decision-log.md) - _22.04.2026_: [SDK restructuring — inversion, relocation, plugin API, config naming](./packages/sdk/sdk-restructuring.decision-log.md) - _27.04.2026_: [Default to 127.0.0.1 binding for the reference backend](./apps/backend/local-dev-binding.decision-log.md) - _27.04.2026_: [Workflow cancellation handling in Temporal engine](./packages/temporal/src/workflow/cancellation-handling.decision-log.md) - _28.04.2026_: [Topological scheduling for the graph runner](./packages/execution-core/topological-scheduling.decision-log.md) - _29.04.2026_: [Decision executor fails fast on no matching branch](./packages/execution-core/decision-no-match.decision-log.md) +- _30.04.2026 (revised 04.05.2026 after team review)_: [Audience-based docs IA + schema authoring reference](./apps/docs/docs-restructure.decision-log.md) +- _30.04.2026_: [TypeDoc-driven API Reference for `@workflowbuilder/sdk`](./apps/docs/typedoc-api-reference.decision-log.md) - _05.05.2026_: [Extract AI Studio from `apps/demo` into its own `apps/ai-studio` app](./apps/ai-studio/ai-studio-extraction.decision-log.md) - _05.05.2026_: [Workspace layout — relocate libraries to `packages/`](./packages/sdk/workspace-layout.decision-log.md) - _06.05.2026_: [Make execution-core generic over the consumer's node union](./packages/execution-core/generic-execution-core.decision-log.md) - _15.05.2026_: [AuthPort seam for backend authn/authz](./apps/backend/auth-port.decision-log.md) +- _21.05.2026 (proposed; landed 03.06.2026 in `fa5999dd`)_: [TenantContextPort — multi-tenant identity seam for the reference backend](./apps/backend/tenant-context-port.decision-log.md) +- _07.08.2026_: [Keep the postcss box-sizing plugin over lint-based or selector-based alternatives](./packages/ui/postcss-box-sizing.decision-log.md) - _24.08.2026_: [`incomplete` as a third terminal state, distinct from `failed` and from a stall](./packages/execution-core/terminal-states.decision-log.md) +- _07.09.2026 (shape), 08.09.2026 (names)_: [Decision request as versioned data on a node](./apps/backend/decision-request.decision-log.md) +- _08.09.2026_: [Durable pause, the Temporal side of the human-in-the-loop seam](./packages/temporal/src/workflow/durable-pause.decision-log.md) diff --git a/apps/backend/tenant-context-port.decision-log.md b/apps/backend/tenant-context-port.decision-log.md index ef937c2d8..6e8943976 100644 --- a/apps/backend/tenant-context-port.decision-log.md +++ b/apps/backend/tenant-context-port.decision-log.md @@ -2,7 +2,7 @@ ### Proposed by: Kacper Cierzniewski -### Proposed: 21.05.2026 — Landed: 03.06.2026 (`fa5999dd`) +### Date: 21.05.2026 (proposed; landed 03.06.2026 in `fa5999dd`) > This is the **decision** (why this shape, what was rejected, what it does and does not protect). The **how-to-wire-it** lives in [`multi-tenancy.md`](./multi-tenancy.md) — that document tracks the code and is the source of truth for current signatures and per-seam status. If a snippet here ever disagrees with the code, the code wins. diff --git a/packages/temporal/src/workflow/durable-pause.decision-log.md b/packages/temporal/src/workflow/durable-pause.decision-log.md index fe09d61e4..347e0994d 100644 --- a/packages/temporal/src/workflow/durable-pause.decision-log.md +++ b/packages/temporal/src/workflow/durable-pause.decision-log.md @@ -1,4 +1,8 @@ -# Durable pause (HITL seam) — decision log +### Title: Durable pause, the Temporal side of the human-in-the-loop seam + +### Proposed by: Piotr Błaszczyk + +### Date: 08.09.2026 Context: a node executor can return `{ waiting: true }`; the graph runner parks that wave slot on `ActivityRunnerPort.awaitResolution` and resumes with the completion the From 007e1734b936c2fc1b5f8838a06261431b64070d Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Mon, 14 Sep 2026 15:42:33 +0200 Subject: [PATCH 25/25] feat(backend): name the domain issue in snapshot validation details Every domain message had an identifier, but only the English text reached the client: decisionIssue used the code to pick the wording and then returned zod's generic code 'custom', and the serializer forwarded path, message and that code. A client could only branch or translate by matching the sentence. The identifier and the interpolated value now ride on the zod issue as params and come out beside the existing fields as domainCode and params. zod's own code and the message stay as they were. Sites built on .refine carry the same identifier through decisionRefinement; .min(1) on actions became a refine, since zod drops params from its built-in checks; and unknown_effect moved out of the union's error map, which cannot carry params, into a pre-check that aborts the action the way the union's failure did. A differential run over sixty-one inputs shows the only change in existing output is zod's code on five issues, which nothing reads. --- apps/backend/README.md | 2 +- apps/backend/decision-request.decision-log.md | 2 +- .../domain/decision/decision-issues.test.ts | 48 +++++++++++++++++-- .../src/domain/decision/decision-issues.ts | 28 ++++++++++- .../decision/decision-request-schema.test.ts | 23 +++++++-- .../decision/decision-request-schema.ts | 43 ++++++++++------- .../backend/src/routes/snapshot-validation.ts | 13 +++-- apps/backend/src/routes/workflows.test.ts | 11 +++-- 8 files changed, 134 insertions(+), 36 deletions(-) diff --git a/apps/backend/README.md b/apps/backend/README.md index 38946f33d..7e932a9d3 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -35,7 +35,7 @@ A node asks a human for a decision by carrying `data.properties.decisionRequest` The runner does not read the field, deliberately: it learns no product's vocabulary, so a run stops where a node's executor returns a waiting result. A request on a node that never parks therefore validates, reaches the worker and asks nobody anything. The node whose executor does nothing but park is its own task, listed under what this change leaves out. -The request is validated on `POST /:id/publish` and `POST /:id/execute`, never on `PATCH /:id/draft`: a draft is legitimately mid-edit. A broken request answers with the existing `invalid_snapshot` 400, whose `details[].path` points at the node index and field, for example `nodes.1.data.properties.decisionRequest.actions.1.effect`. Structural issues come first; the graph rules (proposal source, predecessors) run once the structure parses, so a second round of issues can follow a fix. Every domain message the validation can produce is listed in `src/domain/decision/decision-issues.ts`. +The request is validated on `POST /:id/publish` and `POST /:id/execute`, never on `PATCH /:id/draft`: a draft is legitimately mid-edit. A broken request answers with the existing `invalid_snapshot` 400, whose `details[].path` points at the node index and field, for example `nodes.1.data.properties.decisionRequest.actions.1.effect`. Structural issues come first; the graph rules (proposal source, predecessors) run once the structure parses, so a second round of issues can follow a fix. Every domain message the validation can produce is listed in `src/domain/decision/decision-issues.ts`. Each such detail also carries `domainCode`, its key in that dictionary, and `params` with the value the message interpolates, so a client branches and translates on the identifier and never on the wording; `code` stays zod's own. One key is refused outright, wherever it sits. An own `__proto__` anywhere in the snapshot answers `invalid_snapshot` 400 naming its path: `JSON.parse` turns it into an ordinary key, and a loose object copies unknown keys by assignment, which for that one swaps the parsed output's prototype and hands the engine a request no schema ever saw. The check does not weigh position, so it also refuses a `__proto__` buried inside an opaque node property, where zod never copies keys one by one and the key is inert. A node type that keeps a raw JSON document in `data.properties` therefore cannot carry one. diff --git a/apps/backend/decision-request.decision-log.md b/apps/backend/decision-request.decision-log.md index 2f0ad4bd6..63c1b6c80 100644 --- a/apps/backend/decision-request.decision-log.md +++ b/apps/backend/decision-request.decision-log.md @@ -16,7 +16,7 @@ The shape itself is documented on the type (`packages/types/src/workflow-executi 2. **Name and effect are split.** `name` and `label` are the client's words ("Escalate to finance"); `effect` is the engine's closed set. A new business vocabulary is data, not a code change. 3. **Edit is not an action.** The decider corrects fields and approves. Whether a field may be edited is already said by `readOnly` in the schema; a second switch would be a second source of truth. `resume-with-edits` is therefore derived, never declared. 4. **JSON Schema for the form**, validated for shape only. The SDK already renders and validates JSON Schema, so the decision form comes for free. A real validator arrives with the first consumer that checks edited values `(follow-up: decision-edit-value-validation)`. Shape only means: an object with a `properties` map, `required` naming declared fields, and `readOnly`, `x-pii` and `type` well-typed where present. `type` is optional, as JSON Schema makes it and as JsonForms renders without it. Every other keyword passes through unread. -5. **Validated on publish and execute, never on draft save.** A draft is legitimately mid-edit; validating it would lose the author's work on every autosave. Both routes go through one `parseSnapshot` helper and the existing `invalid_snapshot` 400, so they cannot drift. +5. **Validated on publish and execute, never on draft save.** A draft is legitimately mid-edit; validating it would lose the author's work on every autosave. Both routes go through one `parseSnapshot` helper and the existing `invalid_snapshot` 400, so they cannot drift. Each domain issue in `details` carries `domainCode` and `params` beside zod's `code` and the English `message`, so a client keys on the identifier and the wording stays free to change. 6. **One definition of the proposal source.** `resolveProposalSource` is the only place that says which node's output a decision judges. The pending-decision resource and the rerun loop must call it rather than re-derive the rule. Publishing refuses a request whose source does not resolve: a published decision with nothing to judge is not what the author meant, and with no predecessor at all the node is an orphan the runner fails anyway. Only the rule that the source must not carry its own request stays tied to `rerun-source`, the one effect that re-runs it. A resolved source can still yield no proposal at decision time, when that branch was skipped, so the pending-decision resource keeps its no-proposal path. 7. **Read requests through the parser, never from raw JSON.** Only the parsed form carries the defaults (`port`, `reasonRequired`, `maxIterations`). The stored snapshot stays raw. 8. **Three names for the lifecycle.** `DecisionRequest` is what the node asks. `SubmittedDecision` is what the decider sends, still unchecked. `Decision` is what validation accepts and what is recorded on the node's completion and audited; it names the chosen action and carries the effect, edits, reason and comment. The matched `DecisionAction` is returned beside it for routing, never inside it, so the port and label live once, on the request. The shape of a submission is parsed with `submittedDecisionSchema` at the endpoint; `validateSubmittedDecision` assumes it and checks only the rules. The field is not called `decision` because it holds the question, not the answer, and not `decisionContract` because that reads as configuration rather than as a request to a person. diff --git a/apps/backend/src/domain/decision/decision-issues.test.ts b/apps/backend/src/domain/decision/decision-issues.test.ts index 40ac16f2b..7c421b0db 100644 --- a/apps/backend/src/domain/decision/decision-issues.test.ts +++ b/apps/backend/src/domain/decision/decision-issues.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest'; -import { DECISION_ISSUE_MESSAGES, decisionIssue, decisionIssueMessage } from './decision-issues'; +import { + DECISION_ISSUE_MESSAGES, + decisionIssue, + decisionIssueMessage, + decisionIssueOf, + decisionRefinement, +} from './decision-issues'; describe('decisionIssueMessage', () => { it('fills the placeholder', () => { @@ -19,11 +25,43 @@ describe('decisionIssueMessage', () => { expect(decisionIssueMessage('resume_required', 'ignored')).toBe(DECISION_ISSUE_MESSAGES.resume_required); }); - it('builds the issue shape a superRefine adds', () => { - expect(decisionIssue('port_empty', ['actions', 0, 'port'])).toEqual({ + it('builds the issue shape a superRefine adds, carrying its identifier', () => { + expect(decisionIssue('duplicate_action_name', ['actions', 1, 'name'], 'approve')).toEqual({ code: 'custom', - message: 'port must not be blank', - path: ['actions', 0, 'port'], + message: "action name 'approve' is used more than once", + path: ['actions', 1, 'name'], + params: { issue: 'duplicate_action_name', value: 'approve' }, }); }); + + it('leaves `value` out of the identifier when the message has none', () => { + expect(decisionIssue('port_empty', ['actions', 0, 'port']).params).toEqual({ issue: 'port_empty' }); + expect(decisionRefinement('port_empty')).toEqual({ + error: 'port must not be blank', + params: { issue: 'port_empty' }, + }); + }); +}); + +describe('decisionIssueOf', () => { + it('reads the identifier back off an issue, with and without a value', () => { + expect(decisionIssueOf(decisionIssue('duplicate_effect', ['actions'], 'resume'))).toEqual({ + issue: 'duplicate_effect', + value: 'resume', + }); + expect(decisionIssueOf(decisionIssue('resume_required', ['actions']))).toEqual({ issue: 'resume_required' }); + }); + + it.each([ + { name: "zod's own structural issue", issue: { code: 'invalid_type', path: ['nodes'], message: 'x' } }, + { name: 'params that are not ours', issue: { code: 'custom', params: { minimum: 1 } } }, + { name: 'an identifier not in the dictionary', issue: { code: 'custom', params: { issue: 'made_up' } } }, + { + name: 'an identifier that exists only on Object.prototype', + issue: { code: 'custom', params: { issue: 'constructor' } }, + }, + { name: 'no object at all', issue: null }, + ])('answers undefined for $name', ({ issue }) => { + expect(decisionIssueOf(issue)).toBeUndefined(); + }); }); diff --git a/apps/backend/src/domain/decision/decision-issues.ts b/apps/backend/src/domain/decision/decision-issues.ts index 6dcea825c..78412216b 100644 --- a/apps/backend/src/domain/decision/decision-issues.ts +++ b/apps/backend/src/domain/decision/decision-issues.ts @@ -49,6 +49,32 @@ export function submittedDecisionErrorMessage(code: SubmittedDecisionErrorCode, return fill(SUBMITTED_DECISION_ERRORS[code], value); } +// Rides on the zod issue as `params` and is read back by the HTTP serializer, so a client +// branches and translates on the identifier and never on the wording of `message`. +export type DecisionIssueParams = { issue: DecisionIssueCode; value?: string }; + +function decisionIssueParams(code: DecisionIssueCode, value: string | undefined): DecisionIssueParams { + return value === undefined ? { issue: code } : { issue: code, value }; +} + export function decisionIssue(code: DecisionIssueCode, path: PropertyKey[], value?: string) { - return { code: 'custom' as const, message: decisionIssueMessage(code, value), path }; + return { + code: 'custom' as const, + message: decisionIssueMessage(code, value), + path, + params: decisionIssueParams(code, value), + }; +} + +// The same issue, shaped as the options a `.refine` takes. +export function decisionRefinement(code: DecisionIssueCode, value?: string) { + return { error: decisionIssueMessage(code, value), params: decisionIssueParams(code, value) }; +} + +export function decisionIssueOf(issue: unknown): DecisionIssueParams | undefined { + const params = typeof issue === 'object' && issue !== null ? (issue as { params?: unknown }).params : undefined; + if (typeof params !== 'object' || params === null) return undefined; + const { issue: code, value } = params as { issue?: unknown; value?: unknown }; + if (typeof code !== 'string' || !Object.hasOwn(DECISION_ISSUE_MESSAGES, code)) return undefined; + return decisionIssueParams(code as DecisionIssueCode, typeof value === 'string' ? value : undefined); } diff --git a/apps/backend/src/domain/decision/decision-request-schema.test.ts b/apps/backend/src/domain/decision/decision-request-schema.test.ts index 349d24351..8686dc963 100644 --- a/apps/backend/src/domain/decision/decision-request-schema.test.ts +++ b/apps/backend/src/domain/decision/decision-request-schema.test.ts @@ -6,7 +6,7 @@ import { type DecisionRequest, } from '@workflow-builder/types/workflow-execution/decision-request'; -import { type DecisionIssueCode, decisionIssueMessage } from './decision-issues'; +import { type DecisionIssueCode, decisionIssueMessage, decisionIssueOf } from './decision-issues'; import { decisionRequestSchema } from './decision-request-schema'; const approve = { name: 'approve', label: 'Approve', effect: 'resume', port: 'approved' }; @@ -40,11 +40,15 @@ function request(overrides: Record = {}): unknown { return { ...workedExample(), ...overrides }; } -function issuesOf(input: unknown): { path: string; message: string }[] { +function issuesOf(input: unknown): { path: string; message: string; domain?: { issue: string; value?: string } }[] { const result = decisionRequestSchema.safeParse(input); return result.success ? [] - : result.error.issues.map((issue) => ({ path: issue.path.join('.'), message: issue.message })); + : result.error.issues.map((issue) => ({ + path: issue.path.join('.'), + message: issue.message, + domain: decisionIssueOf(issue), + })); } const declarableEffects = DECLARABLE_DECISION_EFFECTS.join(', '); @@ -364,9 +368,22 @@ describe('decisionRequestSchema', () => { expect(atPath.length).toBeGreaterThan(0); if (issue !== undefined) { expect(atPath.map((candidate) => candidate.message)).toContain(decisionIssueMessage(issue.code, issue.value)); + // The identifier, not the wording, is what a client keys on. + expect(atPath.map((candidate) => candidate.domain)).toContainEqual( + issue.value === undefined ? { issue: issue.code } : { issue: issue.code, value: issue.value }, + ); } }); + // The effect check aborts the action the way the union's own failure did; a second issue + // about a missing resume action for an action that never parsed would only mislead. + it('reports an unknown effect once, without a missing-resume issue riding along', () => { + const issues = issuesOf(request({ actions: [{ name: 'a', label: 'A', effect: 'zzz' }] })); + + expect(issues.map((issue) => issue.path)).toEqual(['actions.0.effect']); + expect(issues[0]?.domain).toEqual({ issue: 'unknown_effect', value: declarableEffects }); + }); + it('parses into a value assignable to DecisionRequest', () => { expectTypeOf>().toMatchTypeOf(); }); diff --git a/apps/backend/src/domain/decision/decision-request-schema.ts b/apps/backend/src/domain/decision/decision-request-schema.ts index ab0b6d63e..46310eda4 100644 --- a/apps/backend/src/domain/decision/decision-request-schema.ts +++ b/apps/backend/src/domain/decision/decision-request-schema.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { DECLARABLE_DECISION_EFFECTS } from '@workflow-builder/types/workflow-execution/decision-request'; import { rejectingOwnProtoKey } from '../schema/own-proto-key'; -import { decisionIssue, decisionIssueMessage } from './decision-issues'; +import { decisionIssue, decisionRefinement } from './decision-issues'; // Mirrors DURATION_PATTERN and the protobuf Duration range in // packages/temporal/src/workflow/profile-validation.ts (follow-up: shared-duration-format) @@ -19,7 +19,7 @@ function isDurationString(value: string): boolean { return milliseconds >= MIN_DURATION_MS && milliseconds <= MAX_DURATION_MS; } -const durationSchema = z.string().refine(isDurationString, decisionIssueMessage('deadline_format')); +const durationSchema = z.string().refine(isDurationString, decisionRefinement('deadline_format')); function isNotBlank(text: string): boolean { return text.trim().length > 0; @@ -28,12 +28,12 @@ function isNotBlank(text: string): boolean { // 'errorRoute' is the handle the runner reserves for the error policy. const portSchema = z .string() - .refine(isNotBlank, decisionIssueMessage('port_empty')) - .refine((port) => port !== 'errorRoute', decisionIssueMessage('port_reserved')); + .refine(isNotBlank, decisionRefinement('port_empty')) + .refine((port) => port !== 'errorRoute', decisionRefinement('port_reserved')); const actionBase = { - name: z.string().refine(isNotBlank, decisionIssueMessage('name_empty')), - label: z.string().refine(isNotBlank, decisionIssueMessage('label_empty')), + name: z.string().refine(isNotBlank, decisionRefinement('name_empty')), + label: z.string().refine(isNotBlank, decisionRefinement('label_empty')), }; const resumeActionSchema = z.looseObject({ @@ -55,15 +55,22 @@ const rerunSourceActionSchema = z.looseObject({ maxIterations: z.int().min(1).default(3), }); -const decisionActionSchema = z.discriminatedUnion( - 'effect', - [resumeActionSchema, rejectActionSchema, rerunSourceActionSchema], - { - error: (issue) => - issue.code === 'invalid_union' - ? decisionIssueMessage('unknown_effect', DECLARABLE_DECISION_EFFECTS.join(', ')) - : undefined, - }, +// The effect picks the member that parses the rest, so it is checked on its own first: a +// union that finds no member cannot name what was wrong, and a client needs the name. +const declaredEffect = z.unknown().superRefine((action, context) => { + if (typeof action !== 'object' || action === null || Array.isArray(action)) return; + const effect = (action as { effect?: unknown }).effect; + if (typeof effect === 'string' && (DECLARABLE_DECISION_EFFECTS as readonly string[]).includes(effect)) return; + // Aborting, as the union's own failure was: the request-level rules must not go on to + // report a missing resume action for an action that never parsed. + context.addIssue({ + ...decisionIssue('unknown_effect', ['effect'], DECLARABLE_DECISION_EFFECTS.join(', ')), + continue: false, + }); +}); + +const decisionActionSchema = declaredEffect.pipe( + z.discriminatedUnion('effect', [resumeActionSchema, rejectActionSchema, rerunSourceActionSchema]), ); const formPropertySchema = z.looseObject({ @@ -89,7 +96,7 @@ const formSchema = z const deadlineSchema = z.looseObject({ after: durationSchema, - policy: z.string().refine((policy) => policy === 'reject', decisionIssueMessage('deadline_policy')), + policy: z.string().refine((policy) => policy === 'reject', decisionRefinement('deadline_policy')), }); // Guarded on its own, not only through `workflowSnapshotSchema`: every level below is a @@ -99,7 +106,9 @@ export const decisionRequestSchema = rejectingOwnProtoKey( z .looseObject({ version: z.literal(1), - actions: z.array(decisionActionSchema).min(1, decisionIssueMessage('actions_empty')), + actions: z + .array(decisionActionSchema) + .refine((actions) => actions.length > 0, decisionRefinement('actions_empty')), schema: formSchema, uiSchema: z.record(z.string(), z.unknown()).optional(), proposalSourceNodeId: z.string().optional(), diff --git a/apps/backend/src/routes/snapshot-validation.ts b/apps/backend/src/routes/snapshot-validation.ts index 87161cfa0..242c7ca12 100644 --- a/apps/backend/src/routes/snapshot-validation.ts +++ b/apps/backend/src/routes/snapshot-validation.ts @@ -3,17 +3,20 @@ import { z } from 'zod'; import type { SourceVersion } from '@workflow-builder/types/workflow-execution/api'; +import { decisionIssueOf } from '../domain/decision/decision-issues'; import { type WorkflowSnapshot, workflowSnapshotSchema } from '../domain/mapper/snapshot-schema'; import { logger as backendLogger } from '../logger'; const logger = backendLogger.child({ component: 'snapshot-validation' }); +// `code` is zod's; a domain issue adds `domainCode` and `params` so a client never keys on `message`. export function formatValidationDetails(error: z.ZodError) { - return error.issues.map((issue) => ({ - path: issue.path, - message: issue.message, - code: issue.code, - })); + return error.issues.map((issue) => { + const detail = { path: issue.path, message: issue.message, code: issue.code }; + const domain = decisionIssueOf(issue); + if (domain === undefined) return detail; + return { ...detail, domainCode: domain.issue, params: domain.value === undefined ? {} : { value: domain.value } }; + }); } export type SnapshotParse = diff --git a/apps/backend/src/routes/workflows.test.ts b/apps/backend/src/routes/workflows.test.ts index 3da30574c..1b7048953 100644 --- a/apps/backend/src/routes/workflows.test.ts +++ b/apps/backend/src/routes/workflows.test.ts @@ -292,7 +292,10 @@ const approve = { name: 'approve', label: 'Approve', effect: 'resume' }; const validDecisionSnapshot = snapshotWithDecisionActions([approve]); const twoResumesSnapshot = snapshotWithDecisionActions([approve, { ...approve, name: 'approve-2' }]); -type InvalidSnapshotBody = { code: string; details: { path: (string | number)[] }[] }; +type InvalidSnapshotBody = { + code: string; + details: { path: (string | number)[]; code?: string; domainCode?: string; params?: Record }[]; +}; function allowAllApp() { return buildApp(allowAll(vi.fn(async () => true))); @@ -315,9 +318,11 @@ describe('createWorkflowsRoutes - snapshot validation on publish', () => { expect(response.status).toBe(400); expect(body.code).toBe('invalid_snapshot'); - expect(body.details.map((detail) => detail.path.join('.'))).toContain( - 'nodes.1.data.properties.decisionRequest.actions.1.effect', + const detail = body.details.find( + (candidate) => candidate.path.join('.') === 'nodes.1.data.properties.decisionRequest.actions.1.effect', ); + // Beside zod's `code` and the English message, the identifier a client keys on and its value. + expect(detail).toMatchObject({ code: 'custom', domainCode: 'duplicate_effect', params: { value: 'resume' } }); expect(databaseMock.update).not.toHaveBeenCalled(); });