Summary
The v2 core says a run's log ends in exactly one of run.completed, run.failed or run.cancelled, but never says which one a run gets when it runs out of work. This RFC settles one arm of that question: when no node failed and every terminal node (a node with no outgoing edge) was skipped, the run ends completed, and run.completed carries a new optional skippedTerminals list naming those terminals. A core.subWorkflow parent receives the same list. The decision is a pure function of the pinned definition and the recorded node outcomes, so replay and fork reproduce it. The two steward hosts disagree on this state today (one fails it, one completes it with nothing to say so), and the RFC picks completion because a failed run with no failed node reports a failure that nothing produced.
Motivation
The state
A run reaches it when nothing is ready, running or suspended, no node's latest outcome is node.failed, and no terminal node completed. Two mechanisms produce it, and neither needs a defect:
- An edge condition routes nothing (RFC 0134 operators and the original five). A guard declines (
guard.ok equals trueis false), an "only if" step'struthyedge is false, or anequalsrouter's value matches no branch. Every path to every terminal is skipped. - A trigger rule is never satisfied, with no condition anywhere.
A → Bwhere the only edge carriestriggerRule: "any_failed"(an error handler). WhenAsucceeds,Bis skipped, andBis the only terminal. The workflow did exactly what its author wanted, and nothing was conditioned.
The second case matters for the decision: "every terminal was skipped" does not imply "the author's intent was violated".
What the corpus says today
Nothing normative. Measured at openwop cd40f264:
spec/v2/core/events.md§"The terminal event" requires exactly one terminal run event and says nothing about which.spec/v2/core/runs.mdlistscompletedandfailedas statuses, with no rule that selects between them except for specific causes (run_timeout,approval_rejectedininterrupt.md§Rejection,recursion_limit_exceeded).- RFC 0134 and
spec/v1/workflow-chain-packs.md(line 169) define how a skipped source evaluatestruthy/falsy. They do not define a run status. node.skippedis a registered event (spec/v2/event-codemap.json, payloadnodeSkipped), but no v2 rule requires a host to emit it.- "Terminal node" is defined only in RFC 0065 (
outputRole), informally: "those with no outgoing edge". - RFC 0125's motivating case says an
all_completeterminal lets a run "complete cleanly", which presumes a completion rule the core never states.
What the hosts do (code reads, not executions, except where noted)
| Host | Every terminal skipped, no node failed | Evidence |
|---|---|---|
| openwop-app (tier 1, production) | run.failed, error.code all_terminals_skipped (v2 wire: vendor-namespaced openwop-app.all_terminals_skipped), details.skippedTerminals, no failedNodeId. The failure is dead-lettered and raises a user notification. Before openwop-app #5443 (merged 2026-11-01) it was internal_error "unknown node failure". | backend/typescript/src/executor/scheduler.ts inspectDisposition; executor.ts failed branch and emitTerminalFailure (dead letter, notification); run-confirmed on the career.apply chain with a declining guard (test/cs70wf-38-all-terminals-skipped.test.ts) |
v2 reference host (openwop-examples f28b78d) | run.completed with outputs: {} and no marker. It emits node.skipped for the terminal. It does not evaluate edge condition at run time at all, so it reaches this state only through trigger rules. | examples/hosts/v2-reference/src/executor.ts fires, the run loop, runOutputs |
openwop-app emits no node.skipped event (its skips are debug log lines only), so on that host the skipped terminals are visible on the wire only in the failure's details.
The two conforming hosts therefore disagree, and a client cannot tell from either wire whether a completed run did its work or a failed run had a failure.
Who hits it
openwop-app's steward write-up (docs/steward/workflows-assessment/2026-10-31-engine-skipped-terminal.md, finding CS70WF-38) lists 9 shipped chain packs whose every terminal is reached only through conditional edges with no complementary pair that guarantees one completes. One (career.apply) is run-confirmed; the other eight come from a static scan and are candidates, not confirmed failures. A further 32 chains are condition-gated but carry a truthy/falsy pair, so one terminal always completes.
On that host each such run currently:
- reports
failed, so it counts in failure rates and alerts; - is dead-lettered (
runs.md§"Dead letters"), and with a run-level retry policy would be retried first. The retry re-runs every non-terminal node, including side effects that already succeeded, and declines again deterministically; - as the child of a
core.subWorkflowwith the defaultonChildFailure: fail-parent, fails its parent.
Why the spec is the right place
The terminal event type is wire: childStatus (execution.md §subWorkflow), dead-lettering, cancelRun's run_terminal, retry policy and every client's success test key on it. Two hosts already disagree, and the disagreement is visible to every consumer.
Proposal
Choosing the outcome
Three outcomes were weighed (full trade-offs in §"Alternatives considered"):
| Concern | A. completed + marker (proposed) | B. failed + registered code | C. author declares it |
|---|---|---|---|
| Replay and fork | Deterministic: a function of the definition and recorded outcomes | Same | Same, if the declaration is read from the pinned definition, never host config |
outputs and outputRole | outputs is an object as today; the marker tells tooling there is no deliverable | No outputs; the code tells tooling why | Depends on the declared branch |
core.subWorkflow parent | Continues; it can route on childSkippedTerminals | Fails by default (fail-parent) on a by-design decline | Author-dependent, per child |
| Retries, dead letters, alerts keyed on status | Not retried or dead-lettered; alerts must read the marker to see it | Retried, dead-lettered, alerted, including the trigger-rule case where nothing was conditioned | Author-dependent |
| Routing defects (a router matches nothing by mistake) | Silent unless the marker is read | Loud | Loud only where the author opted in |
| Backward compatibility | Flips openwop-app; adds a marker to the reference host | Flips the reference host; openwop-app swaps a vendor code for a registered one | Both hosts change; new wire surface in two schemas |
| What it claims | No node failed, and the run says it completed with no terminal | A failure that no node produced | Whatever the author declared |
The decisive reason: a run status is a claim about the run, and failed here would claim a failure that no node produced. openwop-app had to invent a code and leave failedNodeId empty because there was nothing to name. Each consumer of failed then acts on that missing failure: the dead-letter sink, the default fail-parent, retry policies and alerts. The trigger-rule case (A → B over any_failed) shows that the state is reached by ordinary graphs with no conditions at all, so "every terminal skipped" is not evidence of a broken intent.
The strongest counter-argument: silent success hides routing defects. If a model node renames its output key, every equals edge on the old key goes false, and every run "completes" having done nothing while a dashboard keyed on status stays green. Prior art splits the same way. Airflow, Argo and GitHub Actions treat a run whose remaining steps were skipped by a guard as successful. AWS Step Functions (States.NoChoiceMatched) and BPMN 2.0 (an exclusive gateway with no true condition and no default flow) raise an error. (These descriptions are from the author's knowledge of those systems and were not re-checked against their current documentation for this draft.) In both of the failing systems, though, the error is raised at the routing node, not as a run status with no cause. This RFC keeps that division:
- a run with no failed node is not a failed run;
- an author who needs "nothing routed" to be a failure says so at the routing point, with primitives that exist today: a
core.failterminal on the complementary (falsy) edge or on a router's default branch; - the marker makes the no-terminal outcome visible to every consumer that wants to alert on it.
Whether to add an author declaration (option C) as well is Unresolved question 1.
Definitions
- Terminal node. A node of the run's definition, after chain expansion, with no outgoing edge. A node whose only outgoing edges close a cycle is not terminal. A
disabled: truenode counts like any other node. (RFC 0065's informal definition, made normative.) A definition with no terminal node never reaches this RFC's state. - Quiescent. No node of the run is ready, running or suspended, and the host will schedule none without new input.
- A node's latest outcome is the last of
node.completed,node.failedornode.skippedthe host recorded for it, or the equivalent internal state on a host that does not emitnode.skipped.
§A — Disposition (home: run-event-payloads.schema.json $defs/runCompleted.skippedTerminals)
When a run is quiescent, its definition has at least one terminal node, no terminal node's latest outcome is completed, no node's latest outcome is failed, and no rule that ends the run on its own cause applies (cancellation, run_timeout, a breached cap, recursion_limit_exceeded):
- The host MUST end the run with
run.completed, and the run's status MUST becompleted. It MUST NOT emitrun.failedfor this state. run.completedMUST carryskippedTerminals: the id of every terminal node of the definition, each once, in the order the definition'snodes[]lists them.- A
run.completedfor a run in which at least one terminal node completed MUST NOT carryskippedTerminals. - The disposition MUST be a function only of the run's pinned definition and its recorded node outcomes. It MUST NOT depend on host configuration, the clock, or which host instance evaluates it. A
replayfork that reproduces the source's node outcomes MUST end with the same terminal event type and the sameskippedTerminals. outputsremains governed by events.md §Payloads (an object,{}valid). This RFC adds no rule on its contents.
The state where a node failed and every terminal was skipped is not governed by this RFC (see §"Open spec gaps", row 2).
§B — core.subWorkflow parent (spec/v2/core/execution.md §subWorkflow, Output)
When the child's terminal event is run.completed carrying skippedTerminals:
- The parent node's
node.completedMUST carryoutputs.childStatus: "completed"and MUST carryoutputs.childSkippedTerminals, equal to the child'sskippedTerminals. onChildFailuredoes not apply; the child did not fail.outputMappingapplies as for any completed child: a mapped child variable that is unset arrives undefined (the existing rule).
A parent that must stop when its child reached no terminal routes on it, for example { "type": "truthy", "left": "childSkippedTerminals" } to a core.fail node.
§C — Authoring guidance (non-normative)
- To make a decline an ordinary completion with a deliverable, give the declining branch its own terminal (a
falsycomplement edge to acore.noopor a node that records the reason). openwop-app's Campaign Studio channel chains did this (its ADR 1020 P1). - To make "nothing routed" a failure, add a
core.failterminal on the complement edge or the router's default label. - Tooling that uses
outputRole(RFC 0065) to pick a primary deliverable should present "no deliverable" whenskippedTerminalsis present, rather than fall back to another node's output.
Wire shape changes
schemas/v2/run-event-payloads.schema.json $defs/runCompleted, one optional property:
"properties": {
"outputs": { "$ref": "#/$defs/_outputsObject" },
"durationMs": { "type": "integer", "minimum": 0 },
+ "skippedTerminals": {
+ "type": "array",
+ "description": "RFC 0257 §A — present only when the run completed with no terminal node completed and no node failed: every terminal node's id (a node with no outgoing edge), once each, in nodes[] order. Absent when any terminal completed.",
+ "items": { "$ref": "ids.schema.json#/$defs/nodeId" },
+ "minItems": 1,
+ "uniqueItems": true
+ }
},
outputs.childSkippedTerminals on the core.subWorkflow node needs no schema change: _outputsObject is open and execution.md already lets the node "add fields". The field is named in prose.
No change to RunSnapshot (Unresolved question 3), the error registry, the event registry, api/v2/openapi.yaml or api/v2/asyncapi.yaml (the AsyncAPI message references the payload schema). The CloudEvents and webhook renderings are generated from the same definition and change with it.
Version axes: no eventLogSchemaVersion change. Whether run.completed's per-event schemaVersion changes for an added optional property is Unresolved question 6.
Examples
Positive — a guard declined; the only terminal session was skipped:
{ "type": "run.completed", "payload": { "outputs": {}, "skippedTerminals": ["session"], "durationMs": 412 } }
Positive — a terminal completed, so no marker:
{ "type": "run.completed", "payload": { "outputs": { "payload": { "ok": true } }, "durationMs": 380 } }
Negative — fails validation (minItems: 1):
{ "type": "run.completed", "payload": { "outputs": {}, "skippedTerminals": [] } }
Negative — validates, but violates §A.1 (a failure with no failed node, for this state):
{ "type": "run.failed", "payload": { "error": { "code": "openwop-app.all_terminals_skipped", "message": "…", "details": { "skippedTerminals": ["session"] } } } }
Security considerations
- Threat models.
SECURITY/threat-model-prompt-injection.md: a model or injected content that steers a guard to decline now yields acompletedrun. A consumer that readscompletedas "the action happened" would be wrong. That was already true of a host that completes the run, as the reference host does; the marker makes it detectable.SECURITY/threat-model-replay.md: §A.4 forbids configuration-dependent dispositions, which would otherwise let a replay under a different configuration report a different outcome for the same history. - Payload.
skippedTerminalscarries node ids from the run's definition, whichnode.*events already carry. No credential, input or output material. No redaction recipe needed. - Invariants. None added. The disposition is not a security property, and
SECURITY/invariants.yamlis reserved for those. - Audit and OTel. No new audit event. v2 core defines no run-status OTel attribute (the v1
openwop.run_statusattribute is in the frozenspec/v1/observability.md); whether to add a no-terminal attribute is Unresolved question 5. - External audit. No surface the external audit scoped changes (
SECURITY/external-audit-engagement.md). - Embargo. Not a safety fix; no embargo path.
Open spec gaps (what this RFC does not cover)
- The general run-completion predicate. The v2 core never says when a run is
completedversusfailedbeyond named causes. This RFC settles the "no node failed, every terminal skipped" arm only. - A failed node with every terminal skipped. Out of scope. By code read, openwop-app fails with the failed node's error; the reference host completes when the failure was routed over an
all_complete/any_failededge. - Trigger-rule semantics over a skipped source. Not defined at v2. By code read the hosts differ on
all_successwith one completed and one skipped source (openwop-app fires the target; the reference host skips it). This changes which graphs reach this RFC's state, not the rule. - Edge-condition evaluation is optional at v2. RFC 0134 binds only "a host that evaluates
EdgeCondition", and the reference host does not. node.skippedemission is not required, and openwop-app emits none.- ~~
leftpath resolution~~ — pinned by this RFC (G2): a host that evaluatesEdgeConditionresolvesleftas a dot path into the source node'snode.completedoutputs; a missing segment is undefined. openwop-app already does this; the reference host does not evaluate conditions. - ~~A definition with no terminal node and
disabled: truenodes~~ — decided (UQ 8): the first never reaches this state; a disabled node counts like any other.
Compatibility
Classification: additive.
COMPATIBILITY.md§4: "New normative requirement on a previously undefined behavior — Yes, additive (the spec was previously silent)." No v2 text selects a terminal event for this state.- §2.4: adding an optional property to the closed
runCompletedobject is additive. A consumer validating against an older v2 schema version is validating against that version and SHOULD pin it (versioning.md§4). A consumer that validates new events against an old pinned closed schema will reject arun.completedcarrying the marker; that is the known cost of v2 closure, accepted by §2.4 and its precedents (RFCs 0183, 0186, 0188). - §2.2: no required field changes, no event type changes shape, no endpoint contract changes, no
MUSTis relaxed. No registered error code changes meaning.openwop-app.all_terminals_skippedis a vendor code the host owns, and it stops being emitted for this state.
Forward-compatibility clauses. skippedTerminals is optional with no default and is absent from every run.completed where a terminal completed. Clients that do not know it ignore it, as events.md requires for unknown members. Hosts that never reach the state never emit it.
Honest caveat. "Additive" is a statement about the corpus, not about users. On openwop-app, runs that end failed today will end completed:
- failure counts drop;
- dead letters and failure notifications stop for these runs;
- a
core.subWorkflowparent stops failing on a child that reached no terminal, and continues (Risk R6).
Persisted runs keep their recorded run.failed. A replay fork whose fromSeq precedes the terminal event re-executes under current code, reaches run.completed instead, and emits replay.diverged as replay.md §Divergence requires. That is the documented meaning of replay under current code, not a regression.
On the reference host the status is unchanged; it gains the marker and, under §B, the parent field.
No migration tooling is required (additive). Under GOVERNANCE.md §"Sole-steward operation" there is no outside host to coordinate with; the two steward hosts are listed in §"Implementation notes".
Conformance
Existing coverage
v2-run-completed-outputs.test.tsassertsrun.completedcarriesoutputs(onconformance-noop).v2-terminal-event-once.test.tsasserts exactly one terminal run event.conformance-approval-reject-routed(withv2-approval-reject-disposition) is the nearest fixture: a terminal is skipped while another completes, and the run completes.edge-condition-truthy-falsy(v1) checks the operator shape. No v2 scenario drives an edge condition at run time.subworkflow.test.ts,v2-subworkflow-linkage.test.tsandv2-sub-workflow-advertisement.test.tscovercore.subWorkflow; none covers a child that reached no terminal.
No existing scenario reaches the state this RFC governs.
New scenario (planned): v2-all-terminals-skipped.test.ts
Targets major 2 (row in conformance/scenario-majors.json). Top-of-file docstring cites spec/v2/core/events.md §"The terminal event" and execution.md §subWorkflow. Each fixture is advertised through discovery fixtures[]; a fixture the host does not list records inapplicable for its leg (the existing fixture convention: a blocked row on a conforming host is a release blocker), never a pass.
| Leg | Fixture and input | Asserts |
|---|---|---|
0257.trigger-rule-skip-completes | conformance-error-route-only: start (core.noop) → on-fail (core.noop) over triggerRule: "any_failed". No input. | run.completed; skippedTerminals deep-equals ["on-fail"]; no run.failed in the log. Needs no condition evaluation, so it runs on both steward hosts. |
0257.condition-skip-completes | conformance-all-terminals-skipped: check (core.identity) → act (core.noop) over { "type": "truthy", "left": "payload.ok" }. Input payload: { ok: false }. | run.completed; skippedTerminals deep-equals ["act"]; no run.failed. |
0257.terminal-completed-no-marker | Same fixture, input payload: { ok: true }. | run.completed without skippedTerminals. This is the positive control: a host that ignores the condition runs act on both inputs and fails the previous leg rather than passing both. |
0257.replay-same-disposition | Gated on replay advertising modes ⊇ replay. Fork the conformance-error-route-only run with mode: "replay" from its first node.started (no condition evaluation needed, so both steward hosts can run it). | The fork ends run.completed with the same skippedTerminals. |
0257.subworkflow-child-skipped | Gated on subWorkflow. conformance-subworkflow-skip-parent → child conformance-error-route-only. | Parent run.completed; the parent node's node.completed.outputs.childStatus === "completed" and childSkippedTerminals deep-equals ["on-fail"]. |
Server-free (0257.shape): runCompleted validates with and without skippedTerminals; an empty list and a repeated id fail.
Shipped 2026-11-02 in suite 2.46.41, with judges in lib/skipped-terminals-witness.ts (five self-tests). Before merge every host leg ran against a fake host: it passes a conforming one, and each sabotage (a run.failed, a missing marker, an ignored condition, a marker beside a completed terminal, a replay that changes the disposition) fails the leg it targets.
Fixtures
New conformance/fixtures/conformance-error-route-only.json, conformance-all-terminals-skipped.json and conformance-subworkflow-skip-parent.json, each with a row in the conformance/fixtures.md catalog table and a per-fixture contract, using only typeIds existing fixtures already use (core.noop, core.identity, core.subWorkflow). The truthy fixture's left: "payload.ok" resolves into core.identity's outputs.payload under the pin above (gap G2, closed).
Capability gating
No new capability flag. The disposition is core run behavior, like the terminal-event rule beside it. Legs gate only on existing facets (replay, subWorkflow) and on fixture advertisement.
Reference-host coverage and INTEROP-MATRIX
- openwop-app (tier 1) is expected to witness every leg, including
0257.condition-skip-completes, which needs run-time condition evaluation. - The v2 reference host can witness
0257.trigger-rule-skip-completes,0257.replay-same-dispositionand0257.subworkflow-child-skippedonce it emits the marker. It cannot witness the condition legs until it evaluates conditions (Open spec gap 4). - No new profile or column in
INTEROP-MATRIX.md.
Suite vs spec
The legs assert only what §A and §B state. Nothing is stricter than the spec text.
Falsifiability — one row per normative requirement
| Requirement | Observable — what an outside party sees | Who can cause the condition | Verdict |
|---|---|---|---|
§A.1 ends completed, never run.failed | the terminal event type in the log | the suite, by running an advertised fixture (v2-all-terminals-skipped.test.ts) | witnessable — unaided (fixture-advertised; an unlisted fixture is inapplicable) |
§A.2 skippedTerminals lists every terminal, once, in nodes[] order | the run.completed payload | the suite, same fixtures | witnessable — unaided (fixture-advertised) |
| §A.3 absent when a terminal completed | the run.completed payload on the ok: true input | the suite | witnessable — unaided (fixture-advertised) |
| §A.4 replay reproduces the disposition | the fork's terminal event and marker | the suite, via forkRun | witnessable — gated (replay) |
| §A.4 does not depend on host configuration, clock or instance | — | only the operator can change a host's configuration | unwitnessable — the suite cannot vary host configuration; the replay leg witnesses only that a re-derivation agrees with the recorded one |
§B.1 parent carries childStatus: "completed" and childSkippedTerminals | the parent node's node.completed outputs | the suite, via the parent fixture | witnessable — gated (subWorkflow) |
G2 left resolves into the source's outputs | the condition legs (ok: false declines, ok: true fires) | the suite | witnessable — unaided (fixture-advertised), on a host that evaluates conditions |
Alternatives considered
B. Keep failed, register a standard code. Register all_terminals_skipped (422, not retriable, details: { skippedTerminals: nodeId[] }), and require run.failed with no failedNodeId for this state. It is loud: routing defects cannot hide behind a green status. openwop-app already behaves this way under a vendor code, so it would only rename the code. Rejected because:
- it reports a failure no node produced;
- it fails the trigger-rule case (
A → Boverany_failed), where nothing was conditioned and every node did what it was told; - it dead-letters, retries and alerts on deterministic outcomes, and a retry re-runs side effects that succeeded;
- it fails
core.subWorkflowparents by default on a child's by-design decline; - it flips the reference host the other way.
The loudness it buys is available more precisely at the routing node (core.fail on the complement edge or default branch).
C. An author declaration. For example, WorkflowSettings.onNoTerminal: "complete" | "fail", or a per-terminal "skippable" flag. It states intent exactly where the executor cannot infer it, and is the only option where a multi-branch router with no default can be made fail-closed in one line. Not proposed now because:
- it adds surface to two closed schemas (the definition and the chain manifest);
- it gives two ways to say what a
core.failorcore.noopterminal already says (overview.mdAxiom 2, "one name per thing"); - it still needs a default, and the default is exactly the A-versus-B question.
It can be added later as an additive optional setting whose default is this RFC's rule (Unresolved question 1).
D. A new run status (skipped, or completed-empty). runs.md lets the status vocabulary grow by overview.md §0. Rejected because:
- a consumer must accept an unknown status and "MUST NOT act on it", so existing clients would see a terminal run they cannot classify;
childStatus(completed | failed | cancelled) and the three terminal event types are closed sets that would all have to grow;cancelRun'srun_terminalandisTerminalon poll would need the new member too.
A marker on run.completed carries the same information without that cost.
E. Refuse such graphs at registration. Require every definition to have a terminal that is reachable on every input. Undecidable in general (conditions test run-time values), and a conservative check would refuse the 9 legitimate chains and the any_failed handler pattern. Rejected.
F. Do nothing. Leaves the two steward hosts disagreeing on the terminal event type for the same graph, with no marker on either wire that would let a client tell the difference. Every consumer of childStatus, dead letters and retry policy keeps acting on whichever host it happens to talk to. Rejected; the disagreement is the reason this RFC exists.
Decisions
Decided 2026-11-02 at Active, approved by the maintainer in the openwop-be session.
- No author declaration (option C) now. A
core.failterminal at the routing point says it; a setting can be added later, defaulting to this rule. skippedTerminals, a node id list. Not a token.- No
RunSnapshotprojection. The marker lives onrun.completedonly. outputsstays open (§A.5). openwop-app measured{}for this state (2026-11-02).- No metrics or OTel signal. Operators alert on the marker.
- No per-event
schemaVersionbump for an added optional property. - Homes. §A lives in the
runCompleted.skippedTerminalsdescription; §B is one sentence inexecution.md§subWorkflow(core 36,795 / 36,800). - No terminal node: not this state.
disablednodes count like any other.
Risks. R1 (silent success hides routing defects) is accepted: the marker and the core.fail guidance are the mitigations. R6 (parents stop failing on a child that reached no terminal) is accepted: no outside host exists (GOVERNANCE.md §"Sole-steward operation"), and openwop-app measured zero production runs in this state (G10), so no deployed parent changes behavior.
Unresolved questions (as filed)
- Add option C now? Should the spec also add an author declaration that makes "no terminal completed" a failure, defaulting to this RFC's rule? The proposal says not yet; reviewers who expect many routers without a default branch may disagree.
- Marker name and shape.
skippedTerminals(a node id list) or a closed outcome token such asterminalOutcome: "none-completed"? The list is more useful to a person reading a run. The token extends better if partial-skip outcomes are ever reported. - Project it onto
RunSnapshot? A client that only readsgetRuncannot see the marker. Adding an optional snapshot property is additive, but the snapshot is a fold of the log, and a second copy is a second thing to keep consistent. outputscontents. Shouldoutputsbe{}when no terminal completed? The reference host already emits{}. What openwop-app would put there has not been measured, and the core never defines howoutputsis derived.- An OTel or metrics signal. Should a host expose the count of runs that completed with no terminal, so operators can alert on routing defects without reading payloads? v2 core has no run-status attribute to extend.
- Per-event
schemaVersion. Does adding an optional payload property bumprun.completed'sschemaVersion? No precedent was found in this draft. - Core word budget.
spec/v2/coreis at 36,784 of 36,800 words (scripts/check-core-budget.mjs). §A and §B need roughly 120 words. Which words are freed, or does the rule live outsidecore/? - A definition with no terminal node, and
disablednodes. Should a definition with no terminal node be refused at registration? Is adisabled: truenode terminal-eligible?
Implementation notes (non-normative)
- openwop-app.
scheduler.tsinspectDisposition: theskippedTerminalsbranch returnscompletedinstead offailed, andexecutor.ts's completion path stampsskippedTerminalsonrun.completed. Theall_terminals_skippedfailure branch and its vendor-code mapping are deleted. Note thatscheduler.tstoday also returnsfailedfor a quiescent run where nothing completed and some node failed (Open spec gap 2), which this RFC leaves alone.subWorkflowDispatcher.tsaddschildSkippedTerminals. Itscs70wf-38-all-terminals-skipped.test.tsflips its expectation. Before the flip, measure how many production runs per day endall_terminals_skipped, and which chains produce them, so the change to failure counts is expected rather than discovered. - v2 reference host.
executor.tscomputes the terminal set (nodes not inlinks(def).map(from), the same setrunOutputsuses) and addsskippedTerminalswhen none has anode.completed. Evaluating edge conditions at run time is a separate change (Open spec gap 4). - Sequencing. Spec text and schema → scenario and fixtures (suite minor) → both hosts → certified cuts. The core budget question (UQ 7) must be answered before the spec text can merge.
- Effort. Small on both hosts. The conformance scenario is the largest piece.
- SDKs. openwop-sdks regenerates the
RunCompletedpayload type with the optional field.
Acceptance criteria
- [x] Spec text merged (the
runCompleted.skippedTerminalsdescription,execution.md§subWorkflow) within the core word budget. (2026-11-02.) - [x]
schemas/v2/run-event-payloads.schema.json$defs/runCompleted.skippedTerminalsadded;spec/v2/surface-baseline.jsonregenerated;check-v2-surface-monotone.mjspasses. - [x]
v2-all-terminals-skipped.test.tswith its three fixtures and catalog rows, in a suite release (2.46.41). - [x] CHANGELOG entries (corpus and
conformance/CHANGELOG.md). - [ ] openwop-app passes every leg on a certified production cut, including
0257.condition-skip-completesand its positive control. - [ ] The v2 reference host passes
0257.trigger-rule-skip-completes, or the RFC records why it does not. - [ ] Register sweep: every gap and risk row closed, transferred or carried.
References
- openwop-app
docs/steward/workflows-assessment/2026-10-31-engine-skipped-terminal.md(findingCS70WF-38), openwop-app #5443 (all_terminals_skipped),backend/typescript/test/cs70wf-38-all-terminals-skipped.test.ts. - openwop-app
backend/typescript/src/executor/scheduler.ts(inspectDisposition,evaluateTrigger),executor.ts(failed branch,emitTerminalFailure),middleware/protocolVersion.ts(v2EventErrorCode), atfc536023f. - openwop-examples
examples/hosts/v2-reference/src/executor.ts(fires, the run loop,runOutputs), atf28b78d. - RFC 0013 (workflow-chain packs), RFC 0053 (dead letters), RFC 0065 (
outputRole, terminal node), RFC 0125 (triggerRuleon fragment edges), RFC 0133 (sub-chains), RFC 0134 (truthy/falsy), RFC 0194 (terminal event ends forward execution), RFC 0213 (stating outcomes the core left unstated), RFC 0223 (approval rejection disposition). - Spec:
spec/v2/core/events.md,runs.md,execution.md,replay.md,interrupt.md§Rejection,workflow-chain-packs.md,errors.md;spec/v1/workflow-chain-packs.md(frozen). - Prior art (author's knowledge, not re-verified for this draft): Apache Airflow DAG-run state with skipped leaves (ShortCircuitOperator); Argo Workflows
when; GitHub Actions jobif; AWS Step FunctionsChoice/States.NoChoiceMatched; BPMN 2.0 exclusive gateway default flow. - Registers:
registers/0257-run-status-when-every-terminal-is-skipped.gaps.md,registers/0257-run-status-when-every-terminal-is-skipped.risks.md.