Skip to content

Feat/wb 501 decision endpoint - #133

Open
piotrblaszczyk wants to merge 12 commits into
feat/WB-500-decision-contractfrom
feat/WB-501-decision-endpoint
Open

Feat/wb 501 decision endpoint#133
piotrblaszczyk wants to merge 12 commits into
feat/WB-500-decision-contractfrom
feat/WB-501-decision-endpoint

Conversation

@piotrblaszczyk

Copy link
Copy Markdown
Contributor

No description provided.

…a parked run

WB-501 step 1. WorkflowEnginePort gains a required resolveNode(executionId,
nodeId, resolution) that answers every expected refusal as a result, never a
throw: the validator's four codes plus run_not_found and delivery_timeout.
The codes live once, as `as const` arrays in the execution-core port module;
the validator types its throws with them and the Temporal adapter derives its
runtime check from them.

The adapter addresses the update by name (RESOLVE_NODE_UPDATE_NAME, a
deliberate root export pinned like RUN_WORKFLOW_NAME) and bounds the RPC with
client.withDeadline (resolveTimeoutMs, default 10 s). Unknown failure types
are rethrown.

The harness showed that an update abandoned at the client deadline is not
dropped: the server still hands it to the next worker, so a retry may hear
verdict_already_delivered. The test pins "exactly one lands".

The validator's messages moved into one dictionary keyed by rejection, in the
backend's style.
WB-501 step 2. toNodeResolution turns an accepted Decision and its matched
action into the completion the engine delivers: output is the decision itself,
nextPort the action's port, rerun-source excluded at the type level and the
reserved errorRoute port refused. findDecisionRequest reads a node's request
out of the parsed snapshot. A non-resume submission carrying edits is now
refused with edits_not_allowed, checked before the field rules so the refusal
names the edits.
WB-501 step 3. countNodeWaits(executionId, nodeId) counts the node_waiting
events of one node in one run. The number is the wait instance a decision
must name, and zero says the node never parked. Shared with the coming
pending-decision resource, so it lives beside the event query, not in a route.
…ecision to a parked run

WB-501 step 4. One door for every future channel. The route loads the row,
authorizes executions:decide with the row's attributes (a deny wins over 404),
refuses terminal and cancelling runs, parses the body, reads the node's request
out of the parsed snapshot, judges the submission, checks the wait instance
(attempt = the node's node_waiting count), refuses rerun-source with 501 until
the engine can re-run a source, and delivers the completion through
engine.resolveNode. Every engine refusal is answered on the first try; no retry.

Codes, statuses and messages live once in decision-refusals.ts: the status map
spells each code, situations are typed against it, and total maps over the
engine's and the lookup's codes make a new code a compile error here.
…lares

A code added to the port's validator group without a throw site compiled fine
and stayed dead. The dictionary is exported and a type-level pin equates its
codes with VerdictRejection in both directions.
…s 503 with Retry-After

WB-501 step 5. The adapter's delivery_timeout becomes decision_delivery_timeout.
The update is not durable until a worker accepts it, yet the server may still
hand it to the next worker, so the message says the decision may or may not
have landed and that a retry answering decision_already_made means it did.
No retry anywhere on the server side.
WB-501 step 6. The backend README is the one place for the endpoint and its
answers; the decision log keeps only the reasons and closes its open points;
the Temporal README and decision log say what resolveNode answers with.
…ine error

The row is checked before the body, the attempt before the effect. An error the engine
throws instead of returning surfaces as 500.
The refusal table reuses the domain's {value} filler instead of carrying its own copy.
The rerun-source marker in the route states the limitation in words.
…rt's code array

VERDICT_REJECTIONS meant a private array of codes in execution-core and an exported map of
messages in the validator. The validator's is now VERDICT_REJECTION_MESSAGES.
A fake client with a frozen clock checks that withDeadline receives now + 10 s by default and
now + resolveTimeoutMs when set. The entry-points table names RUN_WORKFLOW_NAME and
RESOLVE_NODE_UPDATE_NAME.
Deny-before-404 hides which ids exist only if the port denies on absent attributes too.
Re-parsing a stored snapshot with today's schema can leave a parked run undecidable after a deploy.
@piotrblaszczyk
piotrblaszczyk marked this pull request as ready for review September 10, 2026 13:00

const waits = await countNodeWaits(executionId, nodeId);
if (waits === 0) return refuse(c, 'node_never_parked', nodeId);
if (waits !== attempt) return refuse(c, 'attempt_mismatch', undefined, { attempt: waits });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The attempt check lives here in the backend, but the engine never sees the attempt: resolveNode gets only nodeId + resolution, and the waits map in run-workflow.ts is keyed by node id alone. So the check isn't atomic with delivery.

Today that's fine (a node parks once, rerun-source is 501), but once the rerun loop lands, a count read just before a re-park can deliver into the wrong wait, and the engine has no way to notice. The ticket keys the wait instance on execution + node + attempt for exactly this reason. Decision-log 15 acknowledges the gap, but nothing in code marks the seam.

Two options:

Preferred (small, additive, replay-safe since validators write nothing to history):

  • add an optional attempt to resolveNode on the port and to the update input
  • give NodeWaitState an attempt counter, incremented at the park site
  • have the validator reject verdict.attempt !== state.attempt with a new verdict_attempt_mismatch code, mapped to decision_attempt_mismatch in ENGINE_REFUSALS

The completeness tests will catch any missed dictionary. Old callers keep working since the field is optional.

Minimum: a (follow-up: decision-attempt-in-engine) marker here and on the waits map, plus the matching Code marker line on the rerun ticket, so the rerun work can't miss it.

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