feat(backend): decision request. A node asks a human to decide before the run continues - #127
Open
piotrblaszczyk wants to merge 16 commits into
Open
feat(backend): decision request. A node asks a human to decide before the run continues#127piotrblaszczyk wants to merge 16 commits into
piotrblaszczyk wants to merge 16 commits into
Conversation
piotrblaszczyk
requested review from
librowski,
lukasz-jazwa and
szymon-t-sc
as code owners
September 8, 2026 08:44
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
force-pushed
the
feat/WB-500-decision-contract
branch
from
September 10, 2026 05:47
dbc95c4 to
857a357
Compare
dawidaksamski
approved these changes
Sep 10, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:DecisionRequestand 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 forport/reasonRequired/maxIterationsmaterialised 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->Decisionor a typed refusal.apps/backend/src/domain/mapper: the snapshot schema validates the request in place and checks the graph rules in asuperRefine; the mapper liftsdecisionRequestout ofconfig.apps/backend/src/routes: publish now validates the draft through the sameparseSnapshothelper as execute; draft save never validates (pinned by a test).apps/backend/decision-request.decision-log.mdand a README section.Decisions worth a look
Recorded in the decision log; the ones that changed during implementation:
decisionRequest: it holds the question, not the answer. Lifecycle names:DecisionRequest->SubmittedDecision->Decision.{ value; error?: undefined } | { value?: undefined; error }.approve,reject,ask-again.Known gaps, unchanged on purpose
draftJson === nullstill publishesnull, unvalidated, as before this PR.Notes for the reviewer
@workflow-builder/typesreaches consumers only through@workflowbuilder/temporal, which is unpublished.