Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
4807b7f
feat(temporal): resolveNode on the engine port delivers a verdict to …
piotrblaszczyk Sep 10, 2026
fc1f809
feat(backend): domain pieces the decision endpoint builds on
piotrblaszczyk Sep 10, 2026
ef04b05
feat(backend): count a node's waits, the attempt a decision addresses
piotrblaszczyk Sep 10, 2026
527445f
feat(backend): POST /api/executions/:id/decision delivers a human's d…
piotrblaszczyk Sep 10, 2026
81e26c3
test(temporal): pin that the validator throws every code the port dec…
piotrblaszczyk Sep 10, 2026
d489776
feat(backend): a decision the engine could not confirm in time answer…
piotrblaszczyk Sep 10, 2026
982a210
docs: the decision endpoint, its codes and the reasons behind them
piotrblaszczyk Sep 10, 2026
e74e8fd
test(backend): pin two more links of the check order and a thrown eng…
piotrblaszczyk Sep 10, 2026
a025854
refactor(backend): one fill for both decision dictionaries
piotrblaszczyk Sep 10, 2026
7d49fb0
refactor(temporal): name the validator's dictionary apart from the po…
piotrblaszczyk Sep 10, 2026
2bc51be
test(temporal): pin the default resolveNode deadline the README quotes
piotrblaszczyk Sep 10, 2026
0c6e34e
docs(backend): when a deny hides ids, and the snapshot re-parse gap
piotrblaszczyk Sep 10, 2026
a68011c
fix(backend): deliver a decision to the id the row carries, not the u…
piotrblaszczyk Sep 14, 2026
57a0e38
fix(backend): a timed-out decision resend names the wait, not the sender
piotrblaszczyk Sep 14, 2026
abc8a0f
docs: mark the seam where a decision's attempt has to reach the engine
piotrblaszczyk Sep 14, 2026
caf66e0
refactor(backend): refuse takes named options, so no call site reads …
piotrblaszczyk Sep 15, 2026
c752f77
refactor(backend): one name for the env every route mounts on
piotrblaszczyk Sep 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion apps/backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,24 @@ The request is validated on `POST /:id/publish` and `POST /:id/execute`, never o

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).
A submitted decision is checked against the request by `validateSubmittedDecision` in `src/domain/decision/` and delivered by the endpoint below. Shape, rules and the reasoning are in [`decision-request.decision-log.md`](./decision-request.decision-log.md).

### Deciding: `POST /api/executions/:id/decision`

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). 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`.

| Status | Code | When |
| ------ | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| 400 | `validation_error` | Body shape |
| 400 | `invalid_decision` | Submission against the request; `details[0].code` is a `SUBMITTED_DECISION_ERRORS` key |
| 404 | `execution_not_found` | |
| 404 | `node_not_found` | Not in the run's snapshot |
| 409 | `execution_not_waiting` | Terminal or cancelling run, or the engine no longer has it |
| 409 | `node_not_waiting` | No request on the node, never parked, or not waiting now. Final |
| 409 | `decision_already_made` | The first decision won, whoever sent it |
| 409 | `decision_attempt_mismatch` | Body carries the current `attempt` |
| 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 |

## Running individual processes

Expand Down
27 changes: 14 additions & 13 deletions apps/backend/auth-port.decision-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,20 @@ This decision log records the structural piece (scope L from [`local-dev-binding

## Actions covered today

| Action | Resource |
| ------------------- | ------------------------------------ |
| `workflows:create` | `{ kind: 'workflows' }` |
| `workflows:list` | `{ kind: 'workflows' }` |
| `workflows:read` | `{ kind: 'workflow', workflowId }` |
| `workflows:update` | `{ kind: 'workflow', workflowId }` |
| `workflows:publish` | `{ kind: 'workflow', workflowId }` |
| `workflows:execute` | `{ kind: 'workflow', workflowId }` |
| `executions:read` | `{ kind: 'execution', executionId }` |
| `executions:stream` | `{ kind: 'execution', executionId }` |
| `executions:cancel` | `{ kind: 'execution', executionId }` |

Per-row resource kinds (`workflow`, `execution`) also accept an optional `attributes: Record<string, unknown>`. Routes that already loaded the row can pass it through so ABAC ports do not need to refetch. Pure RBAC ports ignore the field. Routes that load before authorize is wired (see follow-ups on data scoping) will start using it without a breaking change.
| Action | Resource |
| ------------------- | ------------------------------------------------- |
| `workflows:create` | `{ kind: 'workflows' }` |
| `workflows:list` | `{ kind: 'workflows' }` |
| `workflows:read` | `{ kind: 'workflow', workflowId }` |
| `workflows:update` | `{ kind: 'workflow', workflowId }` |
| `workflows:publish` | `{ kind: 'workflow', workflowId }` |
| `workflows:execute` | `{ kind: 'workflow', workflowId }` |
| `executions:read` | `{ kind: 'execution', executionId }` |
| `executions:stream` | `{ kind: 'execution', executionId }` |
| `executions:cancel` | `{ kind: 'execution', executionId }` |
| `executions:decide` | `{ kind: 'execution', executionId, attributes? }` |

Per-row resource kinds (`workflow`, `execution`) also accept an optional `attributes: Record<string, unknown>`. Routes that already loaded the row can pass it through so ABAC ports do not need to refetch. Pure RBAC ports ignore the field. The decision route is the first to load before it authorizes: `attributes` is `{ workflowId, tenantId, status }`, absent when the row does not exist, and a deny is answered before the 404. Hiding which ids exist depends on the port: it must deny when `attributes` is absent too, or a caller learns that 404 means unknown and 403 means someone else's.

## Alternative Options Considered

Expand Down
30 changes: 22 additions & 8 deletions apps/backend/decision-request.decision-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

### Proposed by: Piotr Błaszczyk

### Date: 07.09.2026 (shape), 08.09.2026 (names)
### Date: 07.09.2026 (shape), 08.09.2026 (names), 10.09.2026 (endpoint)

## Context

Expand All @@ -25,6 +25,18 @@ The shape itself is documented on the type (`packages/types/src/workflow-executi

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.

## The endpoint (10.09.2026)

What the endpoint does and answers is in the README. Only the reasons are here.

12. **`nodeId` in the body, not the path**, so the pending-decision resource addresses the same node the same way. The route owns the body shape; `validateSubmittedDecision` judges only the rules, the workflow's validator only engine integrity.
13. **The row is read before authorization** so a port can scope by it; a deny therefore wins over 404 and reveals no id.
14. **Only terminal and `cancelling` runs are refused by status.** The status write is best-effort, so a parked run may read `pending`; the engine is the arbiter.
15. **`attempt` is the node's `node_waiting` count.** The engine has no attempt yet, so the check is not atomic with delivery; it holds only while a node parks at most once. The rerun loop has to carry the wait instance into the engine `(follow-up: decision-attempt-in-engine)`.
16. **A second submission is always 409 `decision_already_made`.** The backend stores nothing about a decision, so it cannot tell a repeat from a contradiction; a byte-identical replay needs a caller key `(follow-up: decision-idempotency-key)`. The Temporal update id stays random: a deterministic one would hand a second decider the first one's outcome.
17. **`rerun-source` is 501** until the engine can re-run a source; the LLM budget guard and rate limit move there with the verb `(follow-up: decision-rerun-source)`.
18. **`node_not_waiting` is final** because the runner registers the wait before announcing it. **`delivery_timeout` is 503 with a hedged message** because an update nobody accepted is not durable, yet the server may still hand it to the next worker.

## Rejected

- Detecting the node by its type string: the backend would have to learn every product's vocabulary.
Expand All @@ -33,6 +45,9 @@ The shape itself is documented on the type (`packages/types/src/workflow-executi
- 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)`.
- Persisting the first submission only to turn one 409 into a 200.
- Requiring `executions.status === 'waiting'`: it would lock the route to a best-effort write.
- Retrying `node_not_waiting`: the race was fixed at its root, in the runner's order of registering and announcing.

## Known gaps

Expand All @@ -42,18 +57,17 @@ The shape itself is documented on the type (`packages/types/src/workflow-executi
- 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)`.
- A reject whose port has no edge ends the run `incomplete`. The terminal-outcome work closes this; the seam is `toNodeResolution`.
- A decision records nothing about who decided.
- The route re-parses the stored snapshot with today's `workflowSnapshotSchema`, and a run can wait for days across deploys. A schema tightened in between makes every parked run whose snapshot no longer parses undecidable: the route answers 500 until the snapshot is migrated or the rule relaxed.

## Open points

Taken conservatively; confirm or change when the decision endpoint lands.
## Open points, closed 10.09.2026

- 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.
A whitespace-only `reason` counts as missing, and "emptied" means `undefined`, `null` or whitespace: both kept. Edits on a non-`resume` action are now refused with `edits_not_allowed`, before the field rules; dropping them silently was the worse failure.

## 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, 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)`.
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, is its own task `(follow-up: human-decision-node)`.

## Status

Expand Down
3 changes: 2 additions & 1 deletion apps/backend/src/auth/auth-port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ export type AuthAction =
| 'workflows:execute'
| 'executions:read'
| 'executions:stream'
| 'executions:cancel';
| 'executions:cancel'
| 'executions:decide';

/**
* Resources passed to `authorize`. The per-row kinds carry an optional
Expand Down
3 changes: 2 additions & 1 deletion apps/backend/src/domain/decision/decision-issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,15 @@ export const SUBMITTED_DECISION_ERRORS = {
unknown_action: "the decision request offers no action named '{value}'",
reason_required: "action '{value}' requires a reason",
comment_required: "action '{value}' requires a comment",
edits_not_allowed: "action '{value}' does not take edits",
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 {
export function fill(template: string, value: string | undefined): string {
// A function replacer, so a value containing `$&` or `$1` lands verbatim.
return template.replace('{value}', () => value ?? '');
}
Expand Down
42 changes: 42 additions & 0 deletions apps/backend/src/domain/decision/find-decision-request.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest';

import { workflowSnapshotSchema } from '../mapper/snapshot-schema';
import { findDecisionRequest } from './find-decision-request';

const approve = { name: 'approve', label: 'Approve', effect: 'resume' };
const reject = { name: 'reject', label: 'Reject', effect: 'reject' };

const snapshot = workflowSnapshotSchema.parse({
nodes: [
{ id: 'source-1', data: { type: 'product/any', properties: {} } },
{ id: 'plain', data: { type: 'product/any' } },
{
id: 'review-1',
data: {
type: 'product/any',
properties: {
decisionRequest: { version: 1, actions: [approve, reject], schema: { type: 'object', properties: {} } },
},
},
},
],
edges: [{ id: 'e1', source: 'source-1', target: 'review-1' }],
});

describe('findDecisionRequest', () => {
it('finds the request of the node, with the parser defaults on its actions', () => {
const found = findDecisionRequest(snapshot, 'review-1');

expect(found.error).toBeUndefined();
expect(found.request?.actions).toEqual([
{ ...approve, port: 'approved' },
{ ...reject, port: 'rejected', reasonRequired: false },
]);
});

it('tells a missing node from a node that carries no request', () => {
expect(findDecisionRequest(snapshot, 'ghost')).toEqual({ error: 'node_not_found' });
expect(findDecisionRequest(snapshot, 'source-1')).toEqual({ error: 'node_without_decision_request' });
expect(findDecisionRequest(snapshot, 'plain')).toEqual({ error: 'node_without_decision_request' });
});
});
17 changes: 17 additions & 0 deletions apps/backend/src/domain/decision/find-decision-request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { DecisionRequest } from '@workflow-builder/types/workflow-execution/decision-request';

import type { WorkflowSnapshot } from '../mapper/snapshot-schema';

export type FindDecisionRequestError = 'node_not_found' | 'node_without_decision_request';

export type FindDecisionRequestResult =
| { request: DecisionRequest; error?: undefined }
| { request?: undefined; error: FindDecisionRequestError };

export function findDecisionRequest(snapshot: WorkflowSnapshot, nodeId: string): FindDecisionRequestResult {
const node = snapshot.nodes.find((candidate) => candidate.id === nodeId);
if (node === undefined) return { error: 'node_not_found' };
const request = node.data.properties?.decisionRequest;
if (request === undefined) return { error: 'node_without_decision_request' };
return { request };
}
Loading
Loading