From 07f7799f050caab0aca117c080a6c66ea7556054 Mon Sep 17 00:00:00 2001 From: Dawid Aksamski Date: Fri, 2 Oct 2026 09:48:30 +0200 Subject: [PATCH 1/5] feat(backend): a decision request accepts several resume actions --- .../src/domain/decision/decision-issues.ts | 2 +- .../decision/decision-request-schema.test.ts | 49 +++++++++++++++---- .../decision/decision-request-schema.ts | 39 ++++++--------- .../src/domain/mapper/snapshot-schema.test.ts | 2 +- apps/backend/src/routes/workflows.test.ts | 26 +++++++--- 5 files changed, 76 insertions(+), 42 deletions(-) diff --git a/apps/backend/src/domain/decision/decision-issues.ts b/apps/backend/src/domain/decision/decision-issues.ts index cacfb6c5f..a402cc001 100644 --- a/apps/backend/src/domain/decision/decision-issues.ts +++ b/apps/backend/src/domain/decision/decision-issues.ts @@ -11,7 +11,7 @@ export const DECISION_ISSUE_MESSAGES = { port_empty: 'port must not be blank', port_reserved: "port must not be the reserved 'errorRoute'", port_not_allowed: 'a rerun-source action does not route and takes no port', - reject_port_equals_resume_port: "reject port '{value}' must differ from the resume port", + duplicate_port: "port '{value}' is used by more than one action", required_field_undeclared: "required field '{value}' is not declared in properties", 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'", 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 93faeca1d..051f1f11a 100644 --- a/apps/backend/src/domain/decision/decision-request-schema.test.ts +++ b/apps/backend/src/domain/decision/decision-request-schema.test.ts @@ -12,6 +12,7 @@ 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 askAgain = { name: 'ask-again', label: 'Ask again', effect: 'rerun-source', maxIterations: 3 }; +const hold = { name: 'hold', label: 'Put on hold', effect: 'resume', port: 'held' }; const refundForm = { type: 'object', @@ -68,6 +69,12 @@ describe('decisionRequestSchema', () => { expect(decisionRequestSchema.safeParse(minimal).success).toBe(true); }); + it('accepts several resume actions, each on its own port, beside one reject and one rerun-source', () => { + const parsed = decisionRequestSchema.parse(request({ actions: [approve, hold, reject, askAgain] })); + + expect(parsed.actions.map((action) => action.name)).toEqual(['approve', 'hold', 'reject', 'ask-again']); + }); + // 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([ @@ -192,10 +199,10 @@ describe('decisionRequestSchema', () => { issue: { code: 'resume_required' }, }, { - name: 'two resume actions', - input: request({ actions: [approve, { ...approve, name: 'approve-2' }] }), - path: 'actions.1.effect', - issue: { code: 'duplicate_effect', value: 'resume' }, + name: 'two resume actions sharing a port', + input: request({ actions: [approve, { ...hold, port: 'approved' }] }), + path: 'actions.1.port', + issue: { code: 'duplicate_port', value: 'approved' }, }, { name: 'two reject actions', @@ -279,10 +286,16 @@ describe('decisionRequestSchema', () => { issue: { code: 'port_reserved' }, }, { - name: 'a reject port equal to the resume port', + name: 'a reject port equal to a resume port', input: request({ actions: [approve, { ...reject, port: 'approved' }] }), path: 'actions.1.port', - issue: { code: 'reject_port_equals_resume_port', value: 'approved' }, + issue: { code: 'duplicate_port', value: 'approved' }, + }, + { + name: 'a resume port equal to the reject port, the reject coming first', + input: request({ actions: [reject, { ...approve, port: 'rejected' }] }), + path: 'actions.1.port', + issue: { code: 'duplicate_port', value: 'rejected' }, }, { name: 'a non-boolean reasonRequired', @@ -396,8 +409,19 @@ describe('decisionRequestSchema', () => { } }); + it('reports every later action that reuses a port, each at its own index', () => { + const issues = issuesOf( + request({ actions: [approve, { ...hold, port: 'approved' }, { ...reject, port: 'approved' }] }), + ); + + expect(issues.map((issue) => [issue.path, issue.domain])).toEqual([ + ['actions.1.port', { issue: 'duplicate_port', value: 'approved' }], + ['actions.2.port', { issue: 'duplicate_port', value: 'approved' }], + ]); + }); + // Two absent ports would compare equal, so the request-level rule must not run on them. - it('reports each missing port on its own, without a port-equality issue riding along', () => { + it('reports each missing port on its own, without a duplicate-port issue riding along', () => { const issues = issuesOf( request({ actions: [ @@ -411,18 +435,23 @@ describe('decisionRequestSchema', () => { expect(issues.map((issue) => issue.domain)).toEqual([undefined, undefined]); }); - it('reports two blank ports as two empty-port issues, without a port-equality issue riding along', () => { + it('reports three blank ports as three empty-port issues, without a duplicate-port issue riding along', () => { const issues = issuesOf( request({ actions: [ { ...approve, port: ' ' }, + { ...hold, port: ' ' }, { ...reject, port: ' ' }, ], }), ); - expect(issues.map((issue) => issue.path)).toEqual(['actions.0.port', 'actions.1.port']); - expect(issues.map((issue) => issue.domain)).toEqual([{ issue: 'port_empty' }, { issue: 'port_empty' }]); + expect(issues.map((issue) => issue.path)).toEqual(['actions.0.port', 'actions.1.port', 'actions.2.port']); + expect(issues.map((issue) => issue.domain)).toEqual([ + { issue: 'port_empty' }, + { issue: 'port_empty' }, + { issue: 'port_empty' }, + ]); }); it('reports a stray rerun-source port beside a structural failure of the same action', () => { diff --git a/apps/backend/src/domain/decision/decision-request-schema.ts b/apps/backend/src/domain/decision/decision-request-schema.ts index 902c572ea..228985066 100644 --- a/apps/backend/src/domain/decision/decision-request-schema.ts +++ b/apps/backend/src/domain/decision/decision-request-schema.ts @@ -119,7 +119,8 @@ export const decisionRequestSchema = rejectingOwnProtoKey( }) .superRefine((request, context) => { const seenNames = new Set(); - const firstIndexByEffect = new Map(); + const seenSingleEffects = new Set(); + const seenPorts = new Set(); for (const [index, action] of request.actions.entries()) { if (seenNames.has(action.name)) { @@ -127,32 +128,24 @@ export const decisionRequestSchema = rejectingOwnProtoKey( } 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); + if (action.effect !== 'resume') { + if (seenSingleEffects.has(action.effect)) { + context.addIssue(decisionIssue('duplicate_effect', ['actions', index, 'effect'], action.effect)); + } + seenSingleEffects.add(action.effect); } - } - const resumeIndex = firstIndexByEffect.get('resume'); - if (resumeIndex === undefined) { - context.addIssue(decisionIssue('resume_required', ['actions'])); - return; + // A blank port already answered `port_empty`; two of them must not also read as shared. + if (action.effect !== 'rerun-source' && isNotBlank(action.port)) { + if (seenPorts.has(action.port)) { + context.addIssue(decisionIssue('duplicate_port', ['actions', index, 'port'], action.port)); + } + seenPorts.add(action.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 && - isNotBlank(reject.port) - ) { - context.addIssue( - decisionIssue('reject_port_equals_resume_port', ['actions', rejectIndex, 'port'], reject.port), - ); + if (!request.actions.some((action) => action.effect === 'resume')) { + context.addIssue(decisionIssue('resume_required', ['actions'])); } }), ); diff --git a/apps/backend/src/domain/mapper/snapshot-schema.test.ts b/apps/backend/src/domain/mapper/snapshot-schema.test.ts index c69ea0598..0d566ab71 100644 --- a/apps/backend/src/domain/mapper/snapshot-schema.test.ts +++ b/apps/backend/src/domain/mapper/snapshot-schema.test.ts @@ -194,7 +194,7 @@ describe('workflowSnapshotSchema: decision requests', () => { edges: [edge('src', 'review')], }; - expect(issuePaths(snapshot)).toContain('nodes.1.data.properties.decisionRequest.actions.1.effect'); + expect(issuePaths(snapshot)).toContain('nodes.1.data.properties.decisionRequest.actions.1.port'); }); it('rejects `decisionRequest: null`; absent is the only way to carry no request', () => { diff --git a/apps/backend/src/routes/workflows.test.ts b/apps/backend/src/routes/workflows.test.ts index b63680f38..c877c4643 100644 --- a/apps/backend/src/routes/workflows.test.ts +++ b/apps/backend/src/routes/workflows.test.ts @@ -302,8 +302,10 @@ function snapshotWithDecisionActions(actions: unknown[], properties: Record, path: string, method: str describe('createWorkflowsRoutes - snapshot validation on publish', () => { it('rejects a draft with a broken decision request and writes nothing', async () => { - databaseMock.select.mockReturnValue(chainResolving([{ ...fakeWorkflow, draftJson: twoResumesSnapshot }])); + databaseMock.select.mockReturnValue(chainResolving([{ ...fakeWorkflow, draftJson: sharedPortSnapshot }])); const response = await publish(allowAllApp()); const body = (await response.json()) as InvalidSnapshotBody; @@ -333,10 +335,10 @@ describe('createWorkflowsRoutes - snapshot validation on publish', () => { expect(response.status).toBe(400); expect(body.code).toBe('invalid_snapshot'); const detail = body.details.find( - (candidate) => candidate.path.join('.') === 'nodes.1.data.properties.decisionRequest.actions.1.effect', + (candidate) => candidate.path.join('.') === 'nodes.1.data.properties.decisionRequest.actions.1.port', ); // 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(detail).toMatchObject({ code: 'custom', domainCode: 'duplicate_port', params: { value: 'approved' } }); expect(databaseMock.update).not.toHaveBeenCalled(); }); @@ -386,6 +388,16 @@ describe('createWorkflowsRoutes - snapshot validation on publish', () => { expect(await response.json()).toMatchObject({ id: 'w-1', publishedJson: validDecisionSnapshot }); }); + it('accepts a draft whose decision request offers two resume actions on their own ports', async () => { + databaseMock.select.mockReturnValue(chainResolving([{ ...fakeWorkflow, draftJson: twoResumesSnapshot }])); + databaseMock.update.mockReturnValue(chainResolving([{ ...fakeWorkflow, publishedJson: twoResumesSnapshot }])); + + const response = await publish(allowAllApp()); + + expect(response.status).toBe(200); + expect(databaseMock.update).toHaveBeenCalledTimes(1); + }); + it('accepts a form whose properties carry no type, as JSON Schema allows', async () => { const draftJson = snapshotWithDecisionActions([approve], { status: { enum: ['open', 'closed'] }, @@ -414,7 +426,7 @@ describe('createWorkflowsRoutes - snapshot validation on publish', () => { // 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: 'a broken decision request', draftJson: sharedPortSnapshot }, { name: 'a decision request without a port', draftJson: portlessResumeSnapshot }, { name: 'an empty string', draftJson: '' }, { name: 'zero', draftJson: 0 }, @@ -437,10 +449,10 @@ describe('createWorkflowsRoutes - snapshot validation on publish', () => { describe('createWorkflowsRoutes - draft save never validates the snapshot', () => { it('stores a draft with a broken decision request', async () => { - databaseMock.update.mockReturnValue(chainResolving([{ ...fakeWorkflow, draftJson: twoResumesSnapshot }])); + databaseMock.update.mockReturnValue(chainResolving([{ ...fakeWorkflow, draftJson: sharedPortSnapshot }])); const response = await jsonRequest(allowAllApp(), '/api/workflows/w-1/draft', 'PATCH', { - draftJson: twoResumesSnapshot, + draftJson: sharedPortSnapshot, }); expect(response.status).toBe(200); From 75ca31c59adf3c3d9ad346fa3ec6b7b8ba4cc17c Mon Sep 17 00:00:00 2001 From: Dawid Aksamski Date: Fri, 2 Oct 2026 09:52:34 +0200 Subject: [PATCH 2/5] test(backend): a decision on any resume action routes on its port without an outcome --- .../validate-submitted-decision.test.ts | 20 +++++++ apps/backend/src/routes/decision.test.ts | 56 ++++++++++++++++++- 2 files changed, 73 insertions(+), 3 deletions(-) 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 2a8995ee3..f201c1b06 100644 --- a/apps/backend/src/domain/decision/validate-submitted-decision.test.ts +++ b/apps/backend/src/domain/decision/validate-submitted-decision.test.ts @@ -13,6 +13,7 @@ import { 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 askAgain = { name: 'ask-again', label: 'Ask again', effect: 'rerun-source', maxIterations: 3 } as const; +const hold = { name: 'hold', label: 'Put on hold', effect: 'resume', port: 'held' } as const; // As the parser leaves it: defaults present, every action explicit. function requestWith(overrides: Partial = {}): DecisionRequest { @@ -85,6 +86,18 @@ describe('validateSubmittedDecision', () => { call: { action: 'approve', edits: { note: '' } }, effect: 'resume-with-edits', }, + { + name: 'a second resume action, on its own', + request: requestWith({ actions: [approve, hold, reject, askAgain] }), + call: { action: 'hold' }, + effect: 'resume', + }, + { + name: 'a second resume action with an edit', + request: requestWith({ actions: [approve, hold, reject, askAgain] }), + call: { action: 'hold', edits: { note: 'Missing invoice' } }, + effect: 'resume-with-edits', + }, { name: 'editable children of an object and of an array item', request: nestedRequest(), @@ -447,6 +460,13 @@ describe('validateSubmittedDecision', () => { expect(result.action).toEqual(approve); }); + it('returns the second resume action beside the decision, so the route reads its port', () => { + const result = validateSubmittedDecision(requestWith({ actions: [approve, hold, reject] }), { action: 'hold' }); + + expect(result.error).toBeUndefined(); + expect(result.action).toEqual(hold); + }); + it('leaves the initiator to the route: a judged decision carries no resolvedBy', () => { const result = validateSubmittedDecision(requestWith(), { action: 'approve' }); diff --git a/apps/backend/src/routes/decision.test.ts b/apps/backend/src/routes/decision.test.ts index 1261d1b3b..6e6000523 100644 --- a/apps/backend/src/routes/decision.test.ts +++ b/apps/backend/src/routes/decision.test.ts @@ -113,11 +113,13 @@ const refundForm = { }; // Not the strings the former defaults produced: the nextPort assertions must prove the port came from the request. const approve = { name: 'approve', label: 'Approve', effect: 'resume', port: 'source:inner:approved' }; +const hold = { name: 'hold', label: 'Put on hold', effect: 'resume', port: 'source:inner:held' }; const reject = { name: 'reject', label: 'Reject', effect: 'reject', port: 'source:inner:rejected' }; const askAgain = { name: 'ask-again', label: 'Ask again', effect: 'rerun-source' }; -// source-1 feeds two deciding nodes. review-1 offers all three effects and takes a reject -// without a reason; review-2 requires one. after-1 hangs off review-1's approved port. +// source-1 feeds two deciding nodes. review-1 offers two resume actions and all three effects, and takes a +// reject without a reason; review-2 keeps one resume action and requires a reason. after-1 hangs off +// review-1's approved port. const snapshot = { nodes: [ { id: 'source-1', data: { type: 'product/any', properties: {} } }, @@ -128,7 +130,7 @@ const snapshot = { properties: { decisionRequest: { version: 1, - actions: [approve, reject, askAgain], + actions: [approve, hold, reject, askAgain], schema: refundForm, proposalSourceNodeId: 'source-1', }, @@ -556,6 +558,54 @@ describe('POST /api/executions/:id/decision - delivery', () => { expect(completion).not.toHaveProperty('outcome'); }); + it('a decision on the second resume action routes on that action port and declares no outcome', async () => { + program(waitingExecution); + + const response = await decide(buildApp(allowAll()), { nodeId: 'review-1', attempt: 1, action: 'hold' }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + executionId: 'e-1', + nodeId: 'review-1', + attempt: 1, + action: 'hold', + effect: 'resume', + }); + const completion: unknown = engineMock.resolveNode.mock.calls[0]?.[0]?.resolution; + expect(completion).toEqual({ + output: { action: 'hold', effect: 'resume', edits: {}, resolvedBy: 'human' }, + nextPort: 'source:inner:held', + }); + expect(completion).not.toHaveProperty('outcome'); + }); + + it('edits ride on any resume action, not only the first', async () => { + program(waitingExecution); + + const response = await decide(buildApp(allowAll()), { + nodeId: 'review-1', + attempt: 1, + action: 'hold', + edits: { note: 'missing invoice' }, + }); + + expect(response.status).toBe(200); + expect(await bodyOf(response)).toMatchObject({ effect: 'resume-with-edits' }); + expect(engineMock.resolveNode).toHaveBeenCalledWith({ + executionId: 'e-1', + nodeId: 'review-1', + resolution: { + output: { + action: 'hold', + effect: 'resume-with-edits', + edits: { note: 'missing invoice' }, + resolvedBy: 'human', + }, + nextPort: 'source:inner:held', + }, + }); + }); + it.each<{ code: ResolveNodeRejection; status: number; answer: string }>([ { code: 'node_not_waiting', status: 409, answer: 'node_not_waiting' }, { code: 'verdict_already_delivered', status: 409, answer: 'decision_already_made' }, From 9595577e0b2916debc723a2de3f21a307be80f49 Mon Sep 17 00:00:00 2001 From: Dawid Aksamski Date: Fri, 2 Oct 2026 09:55:53 +0200 Subject: [PATCH 3/5] docs(types): a decision request offers one or more resume actions --- .../types/src/workflow-execution/decision-request.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/types/src/workflow-execution/decision-request.ts b/packages/types/src/workflow-execution/decision-request.ts index 6b0a84ba7..0acfe8364 100644 --- a/packages/types/src/workflow-execution/decision-request.ts +++ b/packages/types/src/workflow-execution/decision-request.ts @@ -20,17 +20,17 @@ type DecisionActionBase = { label: string; }; -/** Accepts the proposal, edited or not: the run continues on `port`. Exactly one per request. */ +/** Accepts the proposal, edited or not: the run continues on `port`. One or more per request, each on its own port. */ export type ResumeDecisionAction = DecisionActionBase & { effect: 'resume'; /** Required: the id of the output handle the run continues on. Never `errorRoute`. */ port: string; }; -/** Rejects the proposal: the run continues on `port`. At most one per request. */ +/** Rejects the proposal: the run continues on `port`. At most one per request, and the only action that records an outcome. */ export type RejectDecisionAction = DecisionActionBase & { effect: 'reject'; - /** Required: the id of the output handle the run continues on. Must differ from the resume port. */ + /** Required: the id of the output handle the run continues on. No other routed action may share it. */ port: string; /** Whether the decider must give a reason. Defaults to `false`. */ reasonRequired: boolean; @@ -92,7 +92,10 @@ export type Decision = { 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 offered to the decider: one or more `resume`, at most one `reject`, at most one + * `rerun-source`. Every routed action has its own port. + */ actions: DecisionAction[]; /** * JSON Schema of the decision form. `readOnly: true` marks a field the decider cannot From ebe787d342478aa017ca2d60a1736b3314ed7283 Mon Sep 17 00:00:00 2001 From: Dawid Aksamski Date: Fri, 2 Oct 2026 11:49:21 +0200 Subject: [PATCH 4/5] docs(backend): several resume actions, with an API walkthrough --- apps/backend/README.md | 128 +++++++++++++++++- apps/backend/decision-request.decision-log.md | 11 +- 2 files changed, 135 insertions(+), 4 deletions(-) diff --git a/apps/backend/README.md b/apps/backend/README.md index 0963c2eed..98a089e42 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 `ai-studio/human-decision`: `apps/execution-worker/src/executors/human-decision.ts` returns `{ waiting: true }`, and `apps/ai-studio/src/nodes/human-decision/` renders one output handle per action that carries a port. -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`. A `resume` or `reject` action always names its `port`: a port is the id of an output handle on the canvas, and the backend supplies no default for it. A `rerun-source` action takes no port, because it does not route. A node that carries a request may not set `errorPolicy: 'continue'`, since a failure would then light every port at once; `fail` and `errorRoute` are accepted. 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. +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`. A `resume` or `reject` action always names its `port`: a port is the id of an output handle on the canvas, and the backend supplies no default for it. A `rerun-source` action takes no port, because it does not route. A request offers one or more `resume` actions, each its own branch, at most one `reject` and at most one `rerun-source`. Every routed action has its own port: a port two actions share is refused as `duplicate_port` on the later one. A node that carries a request may not set `errorPolicy: 'continue'`, since a failure would then light every port at once; `fail` and `errorRoute` are accepted. 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. @@ -45,7 +45,7 @@ A submitted decision is checked against the request by `validateSubmittedDecisio Body: `{ nodeId, attempt, action, edits?, reason?, comment? }`. `action` is the `name` of one of the node's actions. `attempt` is how many times the node has parked in this run (its `node_waiting` count; today always 1). `edits` are a patch of the proposal for whoever reads the decision to apply; the node's output carries them unapplied. Nothing applies them yet, so a step after the decision that reads the source's output sees the proposal without the corrections (follow-up: decision-settled-values). An object merges field by field and a list element by element, so a field whose schema declares `properties` or `items` must keep that shape; only `null` also passes, where the field's `type` allows it. Checks run in this order, each answering before the next: row, authorization (`executions:decide` with the row's `{ workflowId, tenantId, status }`; a deny wins over 404), status, body, node, decision, `attempt`, effect, engine. The engine is asked once; nothing is retried. Success: `200 { executionId, nodeId, attempt, action, effect }`. Codes and messages live in `src/routes/decision-refusals.ts`. -The route stamps `resolvedBy: 'human'` on the decision; a body naming an initiator is ignored. A `reject` also declares the run's outcome, edge or no edge: the run closes `completed` unless another branch ends it `incomplete` or `failed`, `GET /api/executions/:id` answers `outcome: 'rejected'` and `resolvedBy: 'human'` (`null` otherwise), and `execution_completed` carries `{ outcome: { value, resolvedBy, nodeId } }`. Publish requires no edge on a reject port. +The route stamps `resolvedBy: 'human'` on the decision; a body naming an initiator is ignored. A decision on any `resume` action continues the run on that action's port and declares no outcome. A `reason` is accepted with any action and required only where a `reject` sets `reasonRequired`. A `reject` also declares the run's outcome, edge or no edge: the run closes `completed` unless another branch ends it `incomplete` or `failed`, `GET /api/executions/:id` answers `outcome: 'rejected'` and `resolvedBy: 'human'` (`null` otherwise), and `execution_completed` carries `{ outcome: { value, resolvedBy, nodeId } }`. Publish requires no edge on a reject port. | Status | Code | When | | ------ | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | @@ -60,6 +60,130 @@ The route stamps `resolvedBy: 'human'` on the decision; a body naming an initiat | 501 | `effect_not_supported` | `rerun-source`, until the engine can re-run a source | | 503 | `decision_delivery_timeout` | No worker accepted it in time. It may still land: resend (`Retry-After`); `decision_already_made` then names the wait, not the sender | +### Trying several resume actions on the local stack + +A review with two normal outcomes and a rejection: `complete` and `incomplete` are both `resume` actions, each on its own port with its own next node; `reject` has no edge, which is a deliberate end. The run below never reaches an AI node. Save this as `review.json`: + +```json +{ + "name": "Review with two outcomes", + "draftJson": { + "nodes": [ + { + "id": "trigger-1", + "type": "start-node", + "position": { "x": 0, "y": 200 }, + "data": { + "isStartNode": true, + "type": "ai-studio/trigger", + "icon": "Lightning", + "segments": [], + "properties": { "label": "Case received", "description": "", "inputPrompt": "" } + } + }, + { + "id": "review-1", + "type": "node", + "position": { "x": 350, "y": 200 }, + "data": { + "type": "ai-studio/human-decision", + "icon": "UserCheck", + "segments": [], + "properties": { + "label": "Review", + "description": "", + "decisionRequest": { + "version": 1, + "actions": [ + { "name": "complete", "label": "Complete", "effect": "resume", "port": "source:inner:complete" }, + { "name": "incomplete", "label": "Incomplete", "effect": "resume", "port": "source:inner:incomplete" }, + { + "name": "reject", + "label": "Reject", + "effect": "reject", + "port": "source:inner:rejected", + "reasonRequired": true + } + ], + "schema": { "type": "object", "properties": {} } + } + } + } + }, + { + "id": "done-1", + "type": "node", + "position": { "x": 700, "y": 100 }, + "data": { + "type": "ai-studio/visualize", + "icon": "Eye", + "segments": [], + "properties": { "label": "Complete", "description": "", "mode": "json" } + } + }, + { + "id": "hold-1", + "type": "node", + "position": { "x": 700, "y": 300 }, + "data": { + "type": "ai-studio/visualize", + "icon": "Eye", + "segments": [], + "properties": { "label": "Incomplete", "description": "", "mode": "json" } + } + } + ], + "edges": [ + { + "id": "e-trigger-review", + "source": "trigger-1", + "sourceHandle": "source", + "target": "review-1", + "targetHandle": "target", + "type": "labelEdge", + "data": {} + }, + { + "id": "e-review-done", + "source": "review-1", + "sourceHandle": "source:inner:complete", + "target": "done-1", + "targetHandle": "target", + "type": "labelEdge", + "data": {} + }, + { + "id": "e-review-hold", + "source": "review-1", + "sourceHandle": "source:inner:incomplete", + "target": "hold-1", + "targetHandle": "target", + "type": "labelEdge", + "data": {} + } + ] + } +} +``` + +With the backend on 3001 and a worker running (`pnpm infra:up`, then `pnpm dev:backend` and `pnpm dev:worker`, or `pnpm dev:ai-studio` for all three): + +```bash +WF=$(curl -s -X POST localhost:3001/api/workflows -H 'content-type: application/json' -d @review.json | jq -r .id) +curl -s -X POST localhost:3001/api/workflows/$WF/publish > /dev/null +RUN=$(curl -s -X POST localhost:3001/api/workflows/$WF/execute -H 'content-type: application/json' \ + -d '{"sourceVersion":"published","triggerPayload":{"input":"Case 48213"}}' | jq -r .executionId) +curl -s localhost:3001/api/executions/$RUN | jq .status +# "waiting", once the node has parked; repeat until it is +curl -s -X POST localhost:3001/api/executions/$RUN/decision -H 'content-type: application/json' \ + -d '{"nodeId":"review-1","attempt":1,"action":"incomplete"}' | jq .effect +# "resume" +curl -s localhost:3001/api/executions/$RUN | jq '{status, outcome}' +# { "status": "completed", "outcome": null }: the run took the incomplete branch and no outcome was declared +``` + +A second run decided with `"action":"complete"` takes the other branch; one decided with `"action":"reject"` needs a `reason` and ends with `"outcome": "rejected"`. AI Studio opens the workflow at `?workflowId=$WF` and the run at `?executionId=$RUN`: the canvas draws one handle per routed action, while the decision form offers the first `resume` action only, for now. + ## Listing executions: `GET /api/executions` Newest first, filtered and paged. Query: `status` (one `ExecutionStatus`), `workflowId` (a UUID), `limit` (default 50, capped at 200; a larger value is clamped, not refused), `cursor` (opaque, taken from the previous page's `nextCursor`). Success: `200 { items, nextCursor }`, `nextCursor` is `null` on the last page. Items carry summary fields only (`id`, `workflowId`, `sourceVersion`, `status`, `startedAt`, `finishedAt`, `createdAt`): no snapshot, no trigger payload, no outputs. Authorization is `executions:list` on `{ kind: 'executions' }`, checked before the query string is read. With a tenant context the list holds the caller's rows plus untenanted rows, the stream route's rule applied as a filter; without one, every row. An empty value counts as absent. Paging is keyset on `(date_trunc('milliseconds', created_at), id)`, not on the raw column: the cursor carries the millisecond `createdAt` the client saw, so both sides of the comparison are truncated the same way. A run submitted between two requests lands on top and never repeats or shifts the pages that follow. A run whose insert committed after a page was read but whose `created_at` predates the cursor is returned mid-walk, on a later page — the predicate only asks for rows older than the cursor. The reverse is the case a walk cannot show: a row whose `created_at` is newer than the cursor stays invisible until the client restarts from a fresh first page. No index serves this order: every page sorts the whole matching set, and `status` or `workflowId` narrow what is scanned but not what is sorted. The two-argument `date_trunc` used here depends on the session `TimeZone` and is therefore STABLE, so it cannot appear in an expression index; the three-argument `date_trunc('milliseconds', created_at, 'UTC')` is IMMUTABLE and returns the same values, so switching to it allows an index on `(date_trunc('milliseconds', created_at, 'UTC'), id)`. Storing `created_at` as `timestamptz(3)` is the other route; it rounds where the current key truncates, so a cursor minted before that migration repeats one row once (follow-up: executions-created-at-millis). A cursor minted under one filter stays valid under another. diff --git a/apps/backend/decision-request.decision-log.md b/apps/backend/decision-request.decision-log.md index aead2739b..7e7e31209 100644 --- a/apps/backend/decision-request.decision-log.md +++ b/apps/backend/decision-request.decision-log.md @@ -2,7 +2,7 @@ ### Proposed by: Piotr Błaszczyk -### Date: 07.09.2026 (shape), 08.09.2026 (names), 10.09.2026 (endpoint), 17.09.2026 (ports), 21.09.2026 (outcome), 29.09.2026 (failure policy, edit shape) +### Date: 07.09.2026 (shape), 08.09.2026 (names), 10.09.2026 (endpoint), 17.09.2026 (ports), 21.09.2026 (outcome), 29.09.2026 (failure policy, edit shape), 01.10.2026 (several resume actions) ## Context @@ -54,6 +54,13 @@ What the endpoint does and answers is in the README. Only the reasons are here. 25. **A node that carries a request may not use `errorPolicy: 'continue'`.** The runner absorbs such a failure with no port, which lights every non-error edge: a worker without the node's executor, or a `node_completed` write that fails after an accepted verdict, would run approve and reject together. Publish and execute refuse it with `error_policy_continue`; `fail` and `errorRoute` keep failures visible. The rule lives in the backend, not the runner, because the runner deliberately reads no request. Like decision 21 it tightens a schema the decision route re-parses, so a run parked on such a node would answer 500; accepted, because the feature lives on its branch with no run in flight. 26. **Edits are a patch of the proposal.** The node's output carries them unapplied; whoever reads the decision merges an object field by field and a list element by element. That is why the walk checks only the keys an edit names. A level with `properties` or `items` must therefore keep its shape: `null`, a primitive or the other container could drop the read-only and required children it may hold, so it answers `field_shape_changed`, except `null` where the level's `type` allows it. `null` is the patch's own way to empty a field, and listing it in `type` is the author's consent; any other value would replace the level rather than patch it, even one its `type` lists. Replacement was rejected: it would make every object with a read-only child uneditable as a whole. +## Several resume actions (01.10.2026) + +27. **One or more `resume` actions, each its own branch.** A step with two normal outcomes, a review that is complete or incomplete, could not be modelled: the second had to be `reject`, which records `rejected` on the run, and a run keeps the first outcome it declares, so a case put on hold once and processed later read as rejected. `reject` stays at most one, as the only action that records an outcome; a per-action outcome is not in this change. `rerun-source` stays at most one. The route needed nothing: it finds the action by name and routes on that action's port. +28. **Ports are unique across routed actions.** `duplicate_port` on the later action's port replaces the reject-versus-resume rule: with several resume actions there is no one pair to compare, and the port is what routes, so two actions on one handle would be one branch under two labels. A blank port answers `port_empty` alone, as before. +29. **A reason stays optional on `resume`.** `reasonRequired` is honoured on `reject` only. An "incomplete" that must carry a note is a real case, but no client can ask for it yet: the AI Studio form takes a reason only in its reject dialog, and the request is not authored in the panel. A flag that defaults to `false` can be added without changing any stored request, so it waits for the first form that asks `(follow-up: decision-resume-reason-required)`. +30. **A parked run still decides.** Unlike decisions 21 and 25 this change only loosens the schema, so a run parked under the old rule still decides. + ## Rejected - Detecting the node by its type string: the backend would have to learn every product's vocabulary. @@ -86,7 +93,7 @@ A whitespace-only `reason` counts as missing, and "emptied" means `undefined`, ` ## Not in this change -Further request fields (condition, four-eyes, several decisions), identity and `x-pii` masking, 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, was its own task; it shipped as `apps/execution-worker/src/executors/human-decision.ts`. +Further request fields (condition, four-eyes, several decisions), identity and `x-pii` masking, 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, was its own task; it shipped as `apps/execution-worker/src/executors/human-decision.ts`. Offering every resume action to the decider in AI Studio, and a showcase template with two normal outcomes, are the next two slices of the same work. ## Status From 96974f60c74e270eaa2d4b58a41d798875e25443 Mon Sep 17 00:00:00 2001 From: Dawid Aksamski Date: Fri, 9 Oct 2026 15:51:49 +0200 Subject: [PATCH 5/5] test(backend): pin the rerun-source and structural-failure edges of the port rule --- .../decision/decision-request-schema.test.ts | 17 +++++++++++++++++ 1 file changed, 17 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 051f1f11a..e4f5b4c82 100644 --- a/apps/backend/src/domain/decision/decision-request-schema.test.ts +++ b/apps/backend/src/domain/decision/decision-request-schema.test.ts @@ -420,6 +420,23 @@ describe('decisionRequestSchema', () => { ]); }); + it('does not count a stray rerun-source port as a shared one', () => { + const issues = issuesOf(request({ actions: [approve, { ...askAgain, port: 'approved' }] })); + + expect(issues.map((issue) => [issue.path, issue.domain])).toEqual([ + ['actions.1.port', { issue: 'port_not_allowed' }], + ]); + }); + + // The request-level rule reads `port` without a type guard: zod skips it once any action failed structurally. + it('reports only the structural failure when a port is missing beside two that share one', () => { + const issues = issuesOf( + request({ actions: [approve, { ...reject, port: undefined }, { ...hold, port: 'approved' }] }), + ); + + expect(issues.map((issue) => [issue.path, issue.domain])).toEqual([['actions.1.port', undefined]]); + }); + // Two absent ports would compare equal, so the request-level rule must not run on them. it('reports each missing port on its own, without a duplicate-port issue riding along', () => { const issues = issuesOf(