Skip to content

feat(backend): decision request. A node asks a human to decide before the run continues - #127

Open
piotrblaszczyk wants to merge 16 commits into
feat/human-in-the-loopfrom
feat/WB-500-decision-contract
Open

feat(backend): decision request. A node asks a human to decide before the run continues#127
piotrblaszczyk wants to merge 16 commits into
feat/human-in-the-loopfrom
feat/WB-500-decision-contract

Conversation

@piotrblaszczyk

Copy link
Copy Markdown
Contributor

What

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. This PR adds the data shape, its validation, and the pure function that checks a submitted decision against it. No endpoint, no timer, no rerun loop yet; those are the follow-up tasks.

Changes

  • packages/types: DecisionRequest and friends, BaseNode.decisionRequest. Public JSDoc on every field (it ships in the Temporal plugin's .d.ts).
  • apps/backend/src/domain/decision/
    • decision-request-schema.ts: zod schema, loose objects at every level, defaults for port / reasonRequired / maxIterations materialised at parse time.
    • decision-issues.ts: one dictionary of every domain message the request and submission validation can produce.
    • proposal-source.ts: resolveProposalSource, the single definition of "whose output does this node judge".
    • validate-submitted-decision.ts: DecisionRequest + SubmittedDecision -> Decision or a typed refusal.
  • apps/backend/src/domain/mapper: the snapshot schema validates the request in place and checks the graph rules in a superRefine; the mapper lifts decisionRequest out of config.
  • apps/backend/src/routes: publish now validates the draft through the same parseSnapshot helper as execute; draft save never validates (pinned by a test).
  • apps/backend/decision-request.decision-log.md and a README section.

Decisions worth a look

Recorded in the decision log; the ones that changed during implementation:

  • The field is decisionRequest: it holds the question, not the answer. Lifecycle names: DecisionRequest -> SubmittedDecision -> Decision.
  • A node carrying a request is a node; no "gate" anywhere.
  • Result objects use { value; error?: undefined } | { value?: undefined; error }.
  • Working action names in examples: approve, reject, ask-again.

Known gaps, unchanged on purpose

  • Publish of a workflow with draftJson === null still publishes null, unvalidated, as before this PR.
  • The submission validator returns the first refusal, not a list.
  • Edge endpoints are not checked to exist (predates this PR).

Notes for the reviewer

  • No changeset: @workflow-builder/types reaches consumers only through @workflowbuilder/temporal, which is unpublished.

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
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
…apshot

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
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
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
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
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
…mission

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
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
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
…re 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
…s 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
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
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
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
@piotrblaszczyk
piotrblaszczyk force-pushed the feat/WB-500-decision-contract branch from dbc95c4 to 857a357 Compare September 10, 2026 05:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants