0068: Pipeline Utilities — Failure-Isolation Event Structured Cause Chain¶
- Status: Accepted
- Author: Chris Colinsky
- Created: 2026-06-15
- Accepted: 2026-06-15
- Targets: spec/pipeline-utilities/spec.md (§6.3 — the failure-isolation event's
caught_exceptionrecord gains a structuredchainfield: an ordered list of cause links{category, message, carrier}from the caught exception down to the originating raise, outermost→innermost, carriers included and flagged; the existingcategory/messagefields are retained and redefined as a derivation over the chain — the outermost non-carrier link carrying a category (elsecategoryisnullandmessageis the outermost non-carrier link's). Supersedes 0065's "resolve through the carrier wrapper to the originating cause" prose, which is ambiguous when the post-carrier chain has multiple non-carrier links; plus a conformance fixture covering a nested-carrier chain. §6.1 retry classification is explicitly unchanged.) - Related: 0050 (introduced §6.3
FailureIsolationMiddleware+ the failure-isolation event and itscaught_exceptionrecord), 0065 (the cause-fidelity carrier-unwrap clause this extends/supersedes), 0009 / 0036 (fan-out — the §9.7 instance-middleware site), 0011 (parallel branches — the §11.7 branch-middleware site), graph-engine §4 (node_exceptioncarrier wrapper +__cause__), graph-engine §6 (observer delivery queue the event rides) - Supersedes: 0065 (the §6.3
caught_exceptioncause-representation clause only; 0065's wrapped-instance/branch lineage SHOULD is unaffected and stands)
Summary¶
0065 tightened §6.3 so the failure-isolation event's caught_exception.category
reports the originating failure rather than the masking graph-engine §4
node_exception carrier — "resolve through the carrier wrapper to the
originating cause and report that category," and "resolution walks nested
carrier wrappers to the originating cause."
That wording assumes the structure is [carriers…] → one originating cause. It
is underspecified when the post-carrier cause chain has more than one
non-carrier link — e.g. a provider_unavailable deliberately re-raised as a
domain error, or an uncategorized surface error wrapping a categorized cause.
"The originating cause" then has no single referent: nearest-categorized,
deepest/root, and first-non-carrier all diverge, and 0065's only fixture
covers single-carrier cases — so two conformant implementations can report
different categories for the same nested failure, defeating the very
fidelity guarantee 0065 exists to provide.
This proposal stops making the fidelity contract hinge on a single adjudicated
cause and instead makes the event carry the whole resolved chain (with the
single-value category kept as a labeled derivation over it). caught_exception
gains a structured chain — every link from the caught exception down to the
originating raise,
each {category, message, carrier}, carriers included and flagged. The
existing category / message fields are kept as a precisely-defined
derivation over the chain (a convenience for the common single-value
consumer and the bundled observers), so nothing is hidden and no consumer is
forced to walk the chain. Conformance becomes "produce this exact chain" —
mechanical and deterministic — rather than "pick the right single category per
a heuristic."
Motivation¶
The failure-isolation event exists to tell a consumer why an instance or branch was isolated. 0065 made that actionable by unwrapping the carrier. But collapsing the cause to one category re-introduces a smaller version of the same problem one level down: the spec has to choose which non-carrier cause wins, and any choice is a heuristic that (a) can map distinct failures to the same reported category with no way to tell them apart, and (b) is the kind of implicit, lossy rule OA otherwise avoids.
A structured chain resolves this in OA's idiom — explicit, structured, nothing hidden:
- It subsumes every single-pick policy. A consumer that wants nearest-categorized derives it; one that wants the root cause derives it. The spec no longer ordains one.
- It is the actionable signal and the full diagnostic. Telemetry that
groups by a single category uses the derived
category; a human debugging a nested failure reads the chain (branch carrier → instance carrier → provider_unavailable) — strictly more than a lone category. - It is more conformable, not less. Producing the chain is a mechanical cause-chain walk; comparing chains is structural equality. That is a sharper conformance contract than "did you apply the resolution heuristic correctly," and it pins the nested case 0065 left open.
- It matches OA's typed-event direction (0049 / 0058 / 0065) — structured over collapsed.
This is an underspecified-interaction refinement, not a conformance bug: an implementation reporting any reasonable single category conforms to 0065's prose today. The chain makes the contract exhaustive so implementations converge.
Detailed design¶
Anticipated bump: MINOR (pre-1.0); concrete version assigned at acceptance.
All changes are in pipeline-utilities §6.3's caught_exception definition.
pipeline-utilities §6.3 — caught_exception.chain¶
The §6.3 caught_exception record gains a required structured field:
chain— an ordered list of cause links describing the caught exception and its cause chain (the language's exception-cause linkage — Python__cause__, TypeScriptError.cause), from the caught exception (outermost, index 0) down to the originating raise (innermost). Each cause link is a structured record:
category— the link's failure category (per its carrying spec, e.g. llm-provider §7 / graph-engine §4) as a string, ornullwhen the link carries no category.message— the link's own message.carrier—truewhen the link is a graph-engine §4node_exceptioncarrier wrapper (including subtypes such as the §11.9parallel_branches_branch_failed),falseotherwise.The chain is built by walking the cause chain from the caught exception to its end, recording one link per exception. Implementations MUST terminate the walk on a repeated reference (a cyclic cause chain MUST NOT hang or crash the degrade path) and MUST stop at the first link whose cause is not itself an exception. At node-level placement the caught exception is the raw error, so the chain is a single non-carrier link; at a non-node placement (§9.7 instance, §11.7 branch, §9.6 / §11.6 parent-node middleware) the outermost link(s) are the engine's carrier wrapper(s),
carrier = true.
pipeline-utilities §6.3 — category / message as a derivation¶
The existing category and message fields are retained (so simple
consumers and the bundled OTel / Langfuse observers keep a single value to
group and render) and redefined as a derivation over chain, replacing
0065's "originating cause" prose:
caught_exception.categoryMUST be thecategoryof the outermost non-carrier link inchainwhosecategoryis a non-empty string. When no non-carrier link carries a category,categoryMUST benull.caught_exception.messageMUST be themessageof that same link; when no non-carrier link carries a category, it MUST be themessageof the outermost non-carrier link (the surface error — the first non-carrier beneath any engine carriers). The derivedcategoryandmessagetherefore always describe one link of the chain.
"Outermost non-carrier carrying a category" makes a deliberate re-categorization
win — if an intermediate layer re-raised a provider_unavailable as a domain
error with its own category, that intentional surface category is reported,
while the full provenance remains visible in chain. This derivation reproduces
0065's single-carrier results exactly (the existing fixtures are unchanged), and
gives the nested case a single, stated answer instead of an ambiguous one.
Why §6.1 retry classification is out of scope (not deferred)¶
§6.1's default classifier is a single-level check (the exception's category or its direct cause's). That is the correct boundary for retry's semantics, not a compromise: retry decides "re-run this node," and re-running an entire outer subgraph because of a deeply-nested transient is the wrong grain — it re-executes unrelated work, independent of whether an inner retry is configured. The place to retry a specific failing call is at or near that call, not at a wrapping site several carriers up. The structured chain makes the event fully transparent regardless, so there is no hidden behavior: the event reports what was caught in full, and retry independently answers a different question. Unifying the two resolutions would change retry's behavior and is a separate proposal on its own merits, not part of this one.
Conformance test impact¶
Extended / new fixture¶
The 0065 fixture (pipeline-utilities/conformance/064-…) is extended (or a
sibling added; number assigned at acceptance) to assert caught_exception.chain
in addition to the derived category / message:
- Single-carrier cases (existing 064 Cases 1–2). Add a
chainassertion:[ {carrier: true, category: …(node_exception)…}, {carrier: false, category: "provider_unavailable"} ], and the derivedcategory == "provider_unavailable"(unchanged from 0065). Confirms the chain is exhaustive and the derivation reproduces 0065. - Node-level case (existing Case 3).
chainis a single non-carrier link; derivedcategoryequals it. Guards the single-link shape. - Uncategorized cause (existing Case 4). The lone non-carrier link has
category: null; derivedcategory == null,messagefrom that link. - New — nested-carrier chain. A failure isolated at a placement where the
cause chain is
carrier → carrier → categorized cause(e.g. a fan-out instance with inner retry, wrapped by a branch). Assertchainrecords both carriers (carrier: true) then the categorized originating link, and the derivedcategoryis that originating category. This is the case 0065 left unpinned. - New (optional) — re-categorized surface. A categorized originating cause
re-raised by an intermediate non-carrier with a different category. Assert
chaincarries both non-carrier links and the derivedcategoryis the outermost (surface) one — pinning the "deliberate re-categorization wins" derivation.
Asserting chain is an additive structural comparison on the existing
expected_failure_isolation_event assertion; no conformance-adapter directive
vocabulary change is anticipated.
Unaffected¶
0050's catch/degrade fixtures and 0065's single-carrier category assertions
continue to pass — the derivation reproduces 0065's values; this proposal adds
the chain and pins the nested case.
Versioning¶
MINOR bump (pre-1.0):
- §6.3's
caught_exceptionrecord gains the requiredchainfield, andcategory/messageare redefined as a derivation over it (superseding 0065's "originating cause" prose). - A conformance fixture pins the chain, including the nested-carrier case.
Behavior-change note. The caught_exception shape grows a chain field
(additive). Derived category / message are unchanged for single-carrier
chains (0065's results stand); for multi-non-carrier chains they become
well-defined where 0065 was ambiguous. 0065's caught_exception.message SHOULD
is tightened to a MUST (the chain makes the derivation unambiguous) — a
conformance tightening for any implementation that previously diverged on the
message. Catch/degrade behavior and graph execution outcomes are untouched. Classified MINOR (an additive event-shape
change plus a pinned conformance expectation); a PATCH reading is defensible
since the derived values are unchanged in the cases 0065 fixtured — the
maintainer's call at acceptance.
Out of scope¶
- §6.1 retry classification. Stays single-level — the correct boundary for retry semantics (see Detailed design); not deferred, not part of this change.
- The middleware's catch / degrade behavior. Untouched; this proposal changes only what the event reports, not recovery.
- 0065's wrapped-instance / branch lineage SHOULD. Unaffected and stands.
- Promoting the failure-isolation event to a typed observer-union variant. Independent of cause representation; remains the deferred 0050 carve-out.
- Exposing exception type names in a cause link. Deliberately omitted —
{category, message, carrier}is language-agnostic; a concrete class name would leak per-language detail into the cross-impl event. - How the bundled observers surface the
chain. The OTel / Langfuse observers render the derivedcategoryfor their single-value attribute; whether they additionally expose the fullchain(e.g. as structured event detail) is an observability-spec concern, out of scope here — the event carries the chain for custom observers regardless.
Alternatives considered¶
- Single resolved category, "nearest-categorized" rule (no chain). The
minimal fix: keep
caught_exception.{category, message}and define the resolution as nearest-categorized. Rejected as the primary design: it still bakes a lossy heuristic into the spec, can collapse distinct failures to one reported category, and hides the provenance — the chain subsumes it (the derivedcategoryis this rule, now with the full chain beside it). - Single resolved category, deepest/root rule. Same lossiness; also reports
a deep cause even when an intermediate layer deliberately re-categorized,
which is usually the less actionable signal. Rejected for the same reason;
available as a derivation over
chainfor consumers who want it. - Carrier category propagation at wrap time. Have the engine stamp the
originating category onto the
node_exceptioncarrier so everyone reads it single-level. Rejected: a graph-engine §4 carrier-semantics change that still needs a "which category when re-wrapping" rule — the same hard question moved earlier, for more blast radius. - Pure chain, drop derived
category/message. Maximally explicit, but forces every consumer (and both bundled observers, which need one value) to walk the chain. Rejected in favor of keeping the derivation as a stated, verifiable convenience — transparent because the chain sits beside it. - Do nothing. Rejected: leaves the nested case ambiguous, so conformant implementations diverge on the reported category for the same failure.