Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
47addb8
feat(types): add DecisionContract and lift it onto BaseNode
piotrblaszczyk Sep 7, 2026
7afe929
feat(backend): zod schema for the gate decision contract
piotrblaszczyk Sep 7, 2026
2bff5b5
feat(backend): validate gate contracts and proposal sources in the sn…
piotrblaszczyk Sep 7, 2026
99e1c2c
refactor(backend): one dictionary for decision-contract issue messages
piotrblaszczyk Sep 7, 2026
9ba5350
feat(backend): lift the decision contract out of config onto BaseNode
piotrblaszczyk Sep 7, 2026
1e83978
feat(backend): validate the draft snapshot on publish
piotrblaszczyk Sep 7, 2026
63c7c8d
refactor: drop the "gate" vocabulary for decision-carrying nodes
piotrblaszczyk Sep 8, 2026
ad9e63d
feat(backend): validateSubmittedDecision builds a Decision from a sub…
piotrblaszczyk Sep 8, 2026
d07bcaa
refactor: name the node's decision contract a DecisionRequest
piotrblaszczyk Sep 8, 2026
f98c566
docs(backend): decision log and README section for the decision request
piotrblaszczyk Sep 8, 2026
b292c21
fix(backend): reject an own __proto__ key anywhere in a snapshot befo…
piotrblaszczyk Sep 8, 2026
db84b13
fix(backend): parse the submitted decision's shape before checking it…
piotrblaszczyk Sep 8, 2026
4632441
test(backend): close matrix gaps found in review
piotrblaszczyk Sep 8, 2026
81d1214
fix(backend): review minors for the decision request
piotrblaszczyk Sep 8, 2026
b6d3c05
fix(backend): second review pass on the decision request
piotrblaszczyk Sep 8, 2026
857a357
refactor: record the decision's action by name, return the matched ac…
piotrblaszczyk Sep 8, 2026
f0cc5e1
fix(backend): scan a snapshot for __proto__ without recursion
piotrblaszczyk Sep 11, 2026
6c2aa0a
fix(backend): accept a form property that declares no type
piotrblaszczyk Sep 11, 2026
16cdaf8
docs: the runner does not read decisionRequest
piotrblaszczyk Sep 11, 2026
483fbf4
fix(backend): check edits at every level the form declares
piotrblaszczyk Sep 11, 2026
55a48c7
fix(backend): guard the decision request wherever it is parsed
piotrblaszczyk Sep 11, 2026
0665e7a
fix(backend): a published decision must name a node to judge
piotrblaszczyk Sep 14, 2026
48835a6
fix(backend): execute and publish agree on when a snapshot is absent
piotrblaszczyk Sep 14, 2026
464e974
docs: regenerate the decision-log index
piotrblaszczyk Sep 14, 2026
007e173
feat(backend): name the domain issue in snapshot validation details
piotrblaszczyk Sep 14, 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
8 changes: 8 additions & 0 deletions DECISION-LOGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
12 changes: 12 additions & 0 deletions apps/backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,18 @@ 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: 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`. 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.

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:
Expand Down
60 changes: 60 additions & 0 deletions apps/backend/decision-request.decision-log.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
### 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. 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`. 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)`. 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. 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.
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. 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

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

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

## 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, 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

Accepted
67 changes: 67 additions & 0 deletions apps/backend/src/domain/decision/decision-issues.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';

import {
DECISION_ISSUE_MESSAGES,
decisionIssue,
decisionIssueMessage,
decisionIssueOf,
decisionRefinement,
} 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_has_decision_request', '$&-$1')).toBe(
"proposal source '$&-$1' carries its own decision request 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, carrying its identifier', () => {
expect(decisionIssue('duplicate_action_name', ['actions', 1, 'name'], 'approve')).toEqual({
code: 'custom',
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();
});
});
Loading
Loading