From 2765246749a3a7ffd22ec84a9ccc39dab5fbafda Mon Sep 17 00:00:00 2001 From: chris-colinsky Date: Sat, 20 Jun 2026 09:41:34 -0700 Subject: [PATCH 1/5] Pin spec v0.65.0 for proposal 0074 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Advance the spec submodule pin v0.64.0 -> v0.65.0 for accepted proposal 0074 (failure-isolation catch gate + §6.4 cause-chain classification primitive). Updates __spec_version__, the pyproject spec_version, the smoke assertion, the conformance.toml spec_pin, and regenerates the bundled AGENTS.md. conformance.toml records 0074 as implemented. --- conformance.toml | 9 ++++++++- openarmature-spec | 2 +- pyproject.toml | 2 +- src/openarmature/AGENTS.md | 4 ++-- src/openarmature/__init__.py | 2 +- tests/test_smoke.py | 2 +- 6 files changed, 14 insertions(+), 7 deletions(-) diff --git a/conformance.toml b/conformance.toml index b693ab0..76a1802 100644 --- a/conformance.toml +++ b/conformance.toml @@ -32,7 +32,7 @@ [manifest] implementation = "openarmature-python" -spec_pin = "v0.64.0" +spec_pin = "v0.65.0" # Status values: # implemented — shipped behavior matches the proposal's contract @@ -719,3 +719,10 @@ note = "PromptBackend.fetch / PromptManager.fetch / get gain an optional cache_t status = "textual-only" since = "0.15.0" note = "Governance + observability §5.5 rationale change: reconciles the gen_ai.* adoption with upstream reality (the whole GenAI semconv surface is at Development status, and gen_ai.system was removed upstream in favor of gen_ai.provider.name). Adds a GenAI-scoped de-facto-interoperability carve-out (OA adopts the recognized core gen_ai.* names directly even at Development; peripheral attributes are mirrored to openarmature.*) and a post-adoption RETENTION rule (an adopted name is kept through an upstream rename / removal). No emitted-attribute change and no conformance-expectation change: python already emits the recognized core gen_ai.* set (including gen_ai.system, now RETAINED despite the upstream rename), so the existing gen_ai.* observability fixtures (e.g. 019-021) stand as the retention regression coverage. No python code and no new fixtures. The gen_ai.system -> gen_ai.provider.name migration is a deferred follow-on." + +# Spec v0.65.0 (proposal 0074). Failure-isolation `catch` cause-chain category +# gate (§6.3) + public cause-chain classification primitive (§6.4). +[proposals."0074"] +status = "implemented" +since = "0.15.0" +note = "FailureIsolationMiddleware gains an optional `catch` set of error categories (§6.3): an exception is caught only if the DERIVED category of its cause chain (the outermost non-carrier link, resolved THROUGH node_exception carriers -- the same value reported as caught_exception.category) is in the set, composing with `predicate` as a conjunction (both default permissive, both unset = catch-all; a null derived category never matches a non-empty set). This classifies a carrier-wrapped failure correctly at a wrapping placement where a surface check sees only the carrier. The §6.4 cause-chain classification walk is promoted to a public primitive classify_cause_chain(exc) -> CaughtException (the existing failure-isolation record: chain + derived category + message) in openarmature.graph, shared by the catch gate, the emitted event, and any consumer. §6.1: the default retry classifier's single-level depth is documented as deliberate (re-run granularity vs §6.3 full-chain degrade); no behavior change. Fixture 072 (catch matches through an instance-placement carrier and degrades; a non-matching catch propagates with no event). The optional native-exception-type catch sugar (spec MAY) is not shipped." diff --git a/openarmature-spec b/openarmature-spec index cc569ff..7472cd7 160000 --- a/openarmature-spec +++ b/openarmature-spec @@ -1 +1 @@ -Subproject commit cc569ff61b67459b4146b58eb6733f96d8064952 +Subproject commit 7472cd7457a76c2cc1b160f4200eda6a11afb7ba diff --git a/pyproject.toml b/pyproject.toml index 637dbb4..f35fb66 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,7 +63,7 @@ Specification = "https://github.com/LunarCommand/openarmature-spec" openarmature = "openarmature.cli:main" [tool.openarmature] -spec_version = "0.64.0" +spec_version = "0.65.0" [dependency-groups] dev = [ diff --git a/src/openarmature/AGENTS.md b/src/openarmature/AGENTS.md index 4012c3f..302280e 100644 --- a/src/openarmature/AGENTS.md +++ b/src/openarmature/AGENTS.md @@ -1,6 +1,6 @@ # OpenArmature — Agent documentation -*This is the agent guide bundled with the openarmature Python package, version 0.14.0 (spec v0.64.0). For the full docs site see [openarmature.ai](https://openarmature.ai). For the canonical spec text see [openarmature.org/capabilities](https://openarmature.org/capabilities/). For project-specific conventions for the code you're editing, see the host project's `AGENTS.md` or `CLAUDE.md`.* +*This is the agent guide bundled with the openarmature Python package, version 0.14.0 (spec v0.65.0). For the full docs site see [openarmature.ai](https://openarmature.ai). For the canonical spec text see [openarmature.org/capabilities](https://openarmature.org/capabilities/). For project-specific conventions for the code you're editing, see the host project's `AGENTS.md` or `CLAUDE.md`.* ## TL;DR @@ -10,7 +10,7 @@ OpenArmature is a workflow framework for LLM pipelines and tool-calling agents: ## Capability contracts -_Sourced from openarmature-spec v0.64.0. Each entry below reproduces §1 (Purpose) and §2 (Concepts) of the capability's `spec.md` verbatim — including additions from accepted proposals that this Python implementation may not yet ship. For per-proposal implementation status (implemented / partial / textual-only / not-yet), see the `conformance.toml` manifest at the repo root. For the full spec text (execution model, error semantics, determinism, observer hooks, etc.) see the linked docs site._ +_Sourced from openarmature-spec v0.65.0. Each entry below reproduces §1 (Purpose) and §2 (Concepts) of the capability's `spec.md` verbatim — including additions from accepted proposals that this Python implementation may not yet ship. For per-proposal implementation status (implemented / partial / textual-only / not-yet), see the `conformance.toml` manifest at the repo root. For the full spec text (execution model, error semantics, determinism, observer hooks, etc.) see the linked docs site._ ### Capability: `graph-engine` diff --git a/src/openarmature/__init__.py b/src/openarmature/__init__.py index 090bc51..eab4587 100644 --- a/src/openarmature/__init__.py +++ b/src/openarmature/__init__.py @@ -25,7 +25,7 @@ """ __version__ = "0.14.0" -__spec_version__ = "0.64.0" +__spec_version__ = "0.65.0" # Proposal 0052 (spec observability §5.1 / §8.4.1): canonical # package-registry name for this implementation. Surfaces on every # OTel invocation span as ``openarmature.implementation.name`` and on diff --git a/tests/test_smoke.py b/tests/test_smoke.py index e0242f9..f847cc7 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -9,7 +9,7 @@ def test_package_versions() -> None: assert openarmature.__version__ == "0.14.0" - assert openarmature.__spec_version__ == "0.64.0" + assert openarmature.__spec_version__ == "0.65.0" def test_spec_version_matches_pyproject() -> None: From 9ef9d6a8455c03095684d8b72d213b69ec33191a Mon Sep 17 00:00:00 2001 From: chris-colinsky Date: Sat, 20 Jun 2026 09:42:19 -0700 Subject: [PATCH 2/5] =?UTF-8?q?Add=20failure-isolation=20catch=20gate=20an?= =?UTF-8?q?d=20=C2=A76.4=20primitive=20(0074)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FailureIsolationMiddleware gains an optional `catch` set of error categories: an exception is caught only if the derived category of its cause chain (resolved through node_exception carriers) is in the set, conjoined with `predicate` (catch checked first, short-circuiting). The carrier-skipping walk behind `catch` and `caught_exception` becomes a public primitive, classify_cause_chain(exc) -> CaughtException. The cause-chain types (CauseLink, CaughtException) move into the new cause_chain module alongside it, so the concept has one home and events consumes it; the public openarmature.graph paths are unchanged. The default retry classifier's single-level depth is documented as deliberate (no behavior change). Unit tests cover the gate, the short-circuit, and the primitive. --- src/openarmature/graph/__init__.py | 4 +- src/openarmature/graph/cause_chain.py | 141 ++++++++++++++++++ src/openarmature/graph/events.py | 51 +------ .../graph/middleware/failure_isolation.py | 132 +++++++--------- src/openarmature/graph/middleware/retry.py | 8 + .../unit/test_failure_isolation_middleware.py | 117 +++++++++++++++ 6 files changed, 323 insertions(+), 130 deletions(-) create mode 100644 src/openarmature/graph/cause_chain.py diff --git a/src/openarmature/graph/__init__.py b/src/openarmature/graph/__init__.py index 8938300..3f82555 100644 --- a/src/openarmature/graph/__init__.py +++ b/src/openarmature/graph/__init__.py @@ -10,6 +10,7 @@ """ from .builder import GraphBuilder +from .cause_chain import CaughtException, CauseLink, classify_cause_chain from .compiled import CompiledGraph from .edges import END, ConditionalEdge, EndSentinel, StaticEdge from .errors import ( @@ -37,8 +38,6 @@ UnreachableNode, ) from .events import ( - CaughtException, - CauseLink, FailureIsolatedEvent, InvocationCompletedEvent, InvocationStartedEvent, @@ -135,6 +134,7 @@ "TimingRecord", "UnreachableNode", "append", + "classify_cause_chain", "concat_flatten", "default_classifier", "deterministic_backoff", diff --git a/src/openarmature/graph/cause_chain.py b/src/openarmature/graph/cause_chain.py new file mode 100644 index 0000000..bb9bcfd --- /dev/null +++ b/src/openarmature/graph/cause_chain.py @@ -0,0 +1,141 @@ +# Spec: pipeline-utilities §6.3 cause chain (proposal 0068) + §6.4 cause-chain +# classification (proposal 0074). The cause-chain types (CauseLink, the per-link +# record; CaughtException, the derived single category / message over the chain) +# and the public classification primitive that produces them live together here. +# §6.4 promotes the carrier-skipping cause-fidelity walk (§6.3) to a public, +# named primitive shared by §6.1 retry, §6.3 isolation, and consumers, so a +# carrier-wrapped failure classifies identically everywhere instead of each site +# re-deriving the walk subtly differently. + +"""Cause-chain classification (types + public primitive). + +A failure that crosses a subgraph / fan-out / branch boundary is wrapped by +the engine in one or more ``node_exception`` carriers. ``classify_cause_chain`` +walks an exception's ``__cause__`` chain, records one :class:`CauseLink` per +exception (flagging those carriers), and derives the single failure category +the chain represents: the outermost non-carrier link's category, resolved +*through* the carriers. The result is a :class:`CaughtException` carrying the +ordered chain plus that derived ``category`` / ``message``. + +This is the classification ``FailureIsolationMiddleware`` reports as +``caught_exception`` and the category vocabulary ``RetryMiddleware``'s +classifier matches against; exposing it publicly lets a ``catch`` set, a custom +``predicate``, a router, or a metric classify a carrier-wrapped failure the way +the framework does. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from .errors import NodeException + + +@dataclass(frozen=True) +class CauseLink: + """One link in a caught exception's resolved cause chain. + + - ``category``: the link's failure category when it carries one (a + string), else ``None``. + - ``message``: the link's own message (the ``str`` of the exception). + - ``carrier``: ``True`` when the link is an engine-applied + ``node_exception`` carrier wrapper, ``False`` for an ordinary + (non-carrier) exception. + """ + + category: str | None + message: str + carrier: bool + + +@dataclass(frozen=True) +class CaughtException: + """A classified exception cause chain. + + The result of :func:`classify_cause_chain`, and the record + ``FailureIsolatedEvent.caught_exception`` carries. + + - ``category``: the derived single failure category, the outermost + non-carrier link whose category is a non-empty string, or ``None`` + when no non-carrier link carries one. + - ``message``: the message of the link ``category`` is derived from, + or (when no link carries a category) the outermost non-carrier + link's message. + - ``chain``: the ordered cause chain, outermost (the classified + exception, index 0) to innermost (the originating raise), one + :class:`CauseLink` per exception. + """ + + category: str | None + message: str + chain: tuple[CauseLink, ...] + + +def classify_cause_chain(exc: Exception) -> CaughtException: + """Classify ``exc`` by walking its ``__cause__`` chain. + + Records one ``CauseLink`` per exception from ``exc`` (outermost) to the + originating raise (innermost), flagging ``node_exception`` carriers, and + derives the single category / message the chain represents (the outermost + non-carrier categorized link). + """ + chain = _build_cause_chain(exc) + category, message = _derive_cause(chain) + return CaughtException(category=category, message=message, chain=chain) + + +def _build_cause_chain(exc: Exception) -> tuple[CauseLink, ...]: + # Walk the ``__cause__`` chain from the caught exception (outermost) to the + # originating raise (innermost), one CauseLink per exception. A graph-engine + # §4 ``node_exception`` carrier (NodeException and subtypes such as + # ParallelBranchesBranchFailed) the engine applies at a non-node placement + # (§9.7 instance / §11.7 branch / §9.6 / §11.6 parent-node middleware) is + # flagged ``carrier=True``. Traverse only BaseException instances (a + # non-exception ``__cause__`` ends the walk, per §6.3) and guard against a + # cyclic chain so a malformed chain can't hang or crash the degrade path. + links: list[CauseLink] = [] + current: BaseException | None = exc + seen: set[int] = set() + while isinstance(current, BaseException) and id(current) not in seen: + seen.add(id(current)) + category = getattr(current, "category", None) + links.append( + CauseLink( + category=category if isinstance(category, str) and category else None, + message=str(current), + carrier=isinstance(current, NodeException), + ) + ) + current = current.__cause__ + return tuple(links) + + +def _derive_cause(chain: tuple[CauseLink, ...]) -> tuple[str | None, str]: + # Derived single ``category`` / ``message`` (§6.3, proposal 0068): the + # OUTERMOST non-carrier link whose category is a non-empty string -- so a + # deliberately re-categorized surface error wins, while an uncategorized + # surface error resolves to the categorized cause beneath it (the same chain + # §6.1's default classifier consults, so the reported category agrees with + # what retry acted on). When no non-carrier link carries a category, the + # category is null and the message is the outermost non-carrier link's. The + # all-carrier fallback is defensive -- failure isolation always catches a + # non-carrier or wraps one, so a chain with no non-carrier link should not + # arise. + surface: CauseLink | None = None + for link in chain: + if link.carrier: + continue + if surface is None: + surface = link + if isinstance(link.category, str) and link.category: + return link.category, link.message + if surface is not None: + return None, surface.message + return None, chain[0].message if chain else "" + + +__all__ = [ + "CauseLink", + "CaughtException", + "classify_cause_chain", +] diff --git a/src/openarmature/graph/events.py b/src/openarmature/graph/events.py index 2171ca1..580ea6a 100644 --- a/src/openarmature/graph/events.py +++ b/src/openarmature/graph/events.py @@ -20,6 +20,7 @@ from openarmature.observability.metadata import AttributeValue +from .cause_chain import CaughtException from .errors import RuntimeGraphError from .state import State @@ -722,54 +723,6 @@ class LlmRetryAttemptEvent: caller_invocation_metadata: Mapping[str, AttributeValue] | None = None -# Spec: pipeline-utilities §6.3 cause chain (proposal 0068). A ``carrier`` -# link is a graph-engine §4 ``node_exception`` wrapper the engine applies at a -# non-node placement (§9.7 instance / §11.7 branch / §9.6 / §11.6 parent-node -# middleware); consumers grouping by the originating failure skip carriers via -# the flag. -@dataclass(frozen=True) -class CauseLink: - """One link in a caught exception's resolved cause chain. - - - ``category``: the link's failure category when it carries one (a - string), else ``None``. - - ``message``: the link's own message (the ``str`` of the exception). - - ``carrier``: ``True`` when the link is an engine-applied - ``node_exception`` carrier wrapper, ``False`` for an ordinary - (non-carrier) exception. - """ - - category: str | None - message: str - carrier: bool - - -# Spec: pipeline-utilities §6.3 (proposals 0050, 0065, 0068). ``chain`` is the -# full ordered cause chain; ``category`` / ``message`` are a derivation over it -# — the outermost non-carrier link whose category is a non-empty string (else -# ``None`` and the outermost non-carrier link's message). The derivation -# reproduces 0065's single-value results; the chain adds the full provenance. -@dataclass(frozen=True) -class CaughtException: - """Structured record of an exception caught by - ``FailureIsolationMiddleware``. - - - ``category``: the caught failure's category (the derived single - value for simple consumers), or ``None`` when no non-carrier link - in the chain carries a category. - - ``message``: the message of the link ``category`` is derived from, - or (when no link carries a category) of the outermost non-carrier - link. - - ``chain``: the ordered cause chain, outermost (the caught - exception, index 0) to innermost (the originating raise), one - :class:`CauseLink` per exception. - """ - - category: str | None - message: str - chain: tuple[CauseLink, ...] - - # Spec: realizes pipeline-utilities §6.3 failure-isolation middleware # (proposal 0050). Emitted by FailureIsolationMiddleware when it # catches an exception escaping the inner chain and substitutes a @@ -816,8 +769,6 @@ class FailureIsolatedEvent: __all__ = [ - "CaughtException", - "CauseLink", "FailureIsolatedEvent", "FanOutEventConfig", "InvocationCompletedEvent", diff --git a/src/openarmature/graph/middleware/failure_isolation.py b/src/openarmature/graph/middleware/failure_isolation.py index a146b38..4c0c6a2 100644 --- a/src/openarmature/graph/middleware/failure_isolation.py +++ b/src/openarmature/graph/middleware/failure_isolation.py @@ -36,7 +36,7 @@ from __future__ import annotations import warnings -from collections.abc import Awaitable, Callable, Mapping +from collections.abc import Awaitable, Callable, Collection, Mapping from typing import TYPE_CHECKING, Any from openarmature.observability.correlation import ( @@ -53,9 +53,10 @@ from ._core import NextCall if TYPE_CHECKING: - # Annotation-only import; the runtime construction in ``_build_cause_chain`` - # uses a deferred local import to keep ``events`` off the module-load path. - from openarmature.graph.events import CauseLink + # Annotation-only import; classify_cause_chain is imported lazily on the + # catch path to keep cause_chain (and its events / errors imports) off the + # middleware module-load path. + from openarmature.graph.cause_chain import CaughtException # A degraded update is either a static partial-update mapping or a # callable resolving one from the pre-call state. Resolved at catch @@ -63,63 +64,6 @@ DegradedUpdate = Mapping[str, Any] | Callable[[Any], Mapping[str, Any]] -def _build_cause_chain(exc: Exception) -> tuple[CauseLink, ...]: - # Cause chain (proposal 0068 / §6.3, superseding 0065's single - # "originating cause" prose). Walk the ``__cause__`` chain from the caught - # exception (outermost) to the originating raise (innermost), recording one - # ``CauseLink`` per exception. A graph-engine §4 ``node_exception`` carrier - # (``NodeException`` and subtypes such as ``ParallelBranchesBranchFailed``) - # the engine applies at a non-node placement (§9.7 instance, §11.7 branch, - # §9.6 / §11.6 parent-node middleware) is flagged ``carrier=True``. Traverse - # only BaseException instances (a non-exception ``__cause__`` ends the walk, - # per §6.3) and guard against a cyclic ``__cause__`` chain so a malformed - # chain can't hang or crash the degrade path. The local imports keep - # ``errors`` / ``events`` off the middleware module-load path, matching the - # deferred imports in ``_emit_event``. - from openarmature.graph.errors import NodeException - from openarmature.graph.events import CauseLink - - links: list[CauseLink] = [] - current: BaseException | None = exc - seen: set[int] = set() - while isinstance(current, BaseException) and id(current) not in seen: - seen.add(id(current)) - category = getattr(current, "category", None) - links.append( - CauseLink( - category=category if isinstance(category, str) and category else None, - message=str(current), - carrier=isinstance(current, NodeException), - ) - ) - current = current.__cause__ - return tuple(links) - - -def _derive_cause(chain: tuple[CauseLink, ...]) -> tuple[str | None, str]: - # Derived single ``category`` / ``message`` (proposal 0068 / §6.3): the - # OUTERMOST non-carrier link whose ``category`` is a non-empty string — so a - # deliberately re-categorized surface error wins, while an uncategorized - # surface error resolves to the categorized cause beneath it (the same chain - # §6.1's default classifier consults, so the reported category agrees with - # what retry acted on). When no non-carrier link carries a category, the - # category is null and the message is the outermost non-carrier link's (the - # surface error). Reproduces 0065's single-carrier results. The all-carrier - # fallback is defensive — failure isolation always catches a non-carrier or - # wraps one, so a chain with no non-carrier link should not arise. - surface: CauseLink | None = None - for link in chain: - if link.carrier: - continue - if surface is None: - surface = link - if isinstance(link.category, str) and link.category: - return link.category, link.message - if surface is not None: - return None, surface.message - return None, chain[0].message if chain else "" - - class FailureIsolationMiddleware: """Catch exceptions escaping the inner chain; return a degraded partial update. @@ -133,9 +77,21 @@ class FailureIsolationMiddleware: site; surfaces on the ``FailureIsolatedEvent``. No default — useful values are node-specific, and a generic default would make downstream telemetry strictly worse. - - ``predicate`` (optional): ``Exception -> bool``. When supplied, - only exceptions where ``predicate(exc)`` is true are caught; - others propagate. Defaults to catching every ``Exception``. + - ``catch`` (optional): a set of error categories. When supplied, an + exception is caught only if the derived category of its cause chain + (the outermost non-carrier link's category, resolving through + ``node_exception`` carriers, the value reported as + ``caught_exception.category``) is in the set. Composes with + ``predicate`` as a conjunction; both default permissive (both unset + catches every ``Exception``). The recommended gate for + category-scoped degradation. + - ``predicate`` (optional): ``Exception -> bool`` over the SURFACE + (caught) exception. When supplied, only exceptions where + ``predicate(exc)`` is true are caught; others propagate. Defaults to + always-true. A predicate inspecting the exception directly sees the + ``node_exception`` carrier at a wrapping placement, not the + originating failure; use ``catch`` for category gating, or classify + the chain via ``classify_cause_chain``. - ``on_caught`` (optional): an async ``Exception -> Awaitable[None]`` hook fired on a caught exception, for caller-specific telemetry beyond the framework event. It runs inline before the degraded @@ -150,11 +106,13 @@ def __init__( *, degraded_update: DegradedUpdate, event_name: str, + catch: Collection[str] | None = None, predicate: Callable[[Exception], bool] | None = None, on_caught: Callable[[Exception], Awaitable[None]] | None = None, ) -> None: self.degraded_update = degraded_update self.event_name = event_name + self.catch = catch self.predicate = predicate self.on_caught = on_caught @@ -173,6 +131,27 @@ async def __call__(self, state: Any, next_: NextCall) -> Mapping[str, Any]: # BaseException (cancellation) never enters here — it # extends BaseException, not Exception. Same rule as # RetryMiddleware: cancellation MUST propagate. + # + # Classify once (proposal 0074 / §6.4): the ``catch`` gate and + # the emitted event both need the cause-chain derivation, so + # compute it here and thread it through. The local import keeps + # cause_chain (and its events / errors imports) off the + # middleware module-load path. + from openarmature.graph.cause_chain import classify_cause_chain + + classification = classify_cause_chain(exc) + # Catch gate (§6.3): caught iff the derived category is in + # ``catch`` (or ``catch`` is unset) AND ``predicate`` admits the + # surface exception, a conjunction with both gates permissive by + # default (both unset stays catch-all). ``catch`` is checked first + # and short-circuits, matching the spec's ``catch_gate(exc) and + # predicate(exc)``, so ``predicate`` is not invoked once ``catch`` + # has rejected. ``catch`` classifies THROUGH carriers (the derived + # category, == caught_exception.category); a null derived category + # never matches a non-empty set, so a bare uncategorized error + # propagates. ``predicate`` sees the surface exception. + if self.catch is not None and classification.category not in self.catch: + raise if self.predicate is not None and not self.predicate(exc): raise # Resolve the degraded update once, at catch time, and @@ -183,7 +162,7 @@ async def __call__(self, state: Any, next_: NextCall) -> Mapping[str, Any]: # preserved below; resolving here first only populates # post_state. degraded = self._resolve_degraded(state) - self._emit_event(state, exc, degraded) + self._emit_event(state, classification, degraded) if self.on_caught is not None: try: await self.on_caught(exc) @@ -208,7 +187,7 @@ def _resolve_degraded(self, state: Any) -> Mapping[str, Any]: return self.degraded_update(state) return self.degraded_update - def _emit_event(self, state: Any, exc: Exception, degraded: Mapping[str, Any]) -> None: + def _emit_event(self, state: Any, classification: CaughtException, degraded: Mapping[str, Any]) -> None: dispatch = current_dispatch() # current_dispatch() is None outside an invocation (no observers # in scope, e.g. unit-testing the middleware directly) — the @@ -219,17 +198,14 @@ def _emit_event(self, state: Any, exc: Exception, degraded: Mapping[str, Any]) - # Local import mirrors set_invocation_metadata's 0040 emit: it # keeps the event-type import off the middleware module-load # path and defers it until the first catch. - from openarmature.graph.events import CaughtException, FailureIsolatedEvent - - # Cause chain + derivation (proposal 0068 / §6.3). ``_build_cause_chain`` - # records every link from the caught exception to the originating raise - # (carriers flagged); ``_derive_cause`` resolves the single reported - # ``category`` / ``message`` from it — the outermost non-carrier link - # carrying a category, NOT the masking ``node_exception``. Both ride on - # ``caught_exception`` so a simple consumer reads one value while the - # full provenance stays visible in the chain. - chain = _build_cause_chain(exc) - category, message = _derive_cause(chain) + from openarmature.graph.events import FailureIsolatedEvent + + # The §6.4 classification (computed once at catch, in __call__) is a + # CaughtException: the full cause chain (carriers flagged) plus the + # derived single category / message -- the outermost non-carrier link's, + # NOT the masking node_exception. It is the event's caught_exception + # record directly, so the reported category equals the value the + # ``catch`` gate matched on. # ``attempt_index`` is the wrapped node's final / exhausting # attempt (proposal 0050 §6.3: "the same lineage tuple NodeEvent # carries, for correlation with the wrapped node's other events"). @@ -254,7 +230,7 @@ def _emit_event(self, state: Any, exc: Exception, degraded: Mapping[str, Any]) - branch_name=current_branch_name(), pre_state=state, post_state=degraded, - caught_exception=CaughtException(category=category, message=message, chain=chain), + caught_exception=classification, ) ) diff --git a/src/openarmature/graph/middleware/retry.py b/src/openarmature/graph/middleware/retry.py index b1883fc..6bc6709 100644 --- a/src/openarmature/graph/middleware/retry.py +++ b/src/openarmature/graph/middleware/retry.py @@ -57,6 +57,14 @@ def default_classifier(exc: Exception, _state: Any) -> bool: unused" while keeping the signature stable for user-supplied state-aware classifiers. """ + # Single-level by design (pipeline-utilities §6.1, proposal 0074): retry + # RE-RUNS the wrapped target, so it classifies at re-attempt granularity -- + # the surface category and its immediate cause, NOT the full carrier-skipped + # chain §6.3 isolation derives (isolation degrades, which is depth- + # independent). A transient buried two or more carriers deep is the inner + # scope's to retry; a caller needing outer full-chain retry classification + # supplies a custom classifier built on classify_cause_chain (the §6.4 + # primitive). direct = getattr(exc, "category", None) if isinstance(direct, str) and direct in TRANSIENT_CATEGORIES: return True diff --git a/tests/unit/test_failure_isolation_middleware.py b/tests/unit/test_failure_isolation_middleware.py index 1acbcd0..a76e9cb 100644 --- a/tests/unit/test_failure_isolation_middleware.py +++ b/tests/unit/test_failure_isolation_middleware.py @@ -27,6 +27,7 @@ RetryMiddleware, State, append, + classify_cause_chain, deterministic_backoff, ) from openarmature.graph.errors import NodeException, ParallelBranchesBranchFailed @@ -446,6 +447,122 @@ async def test_no_event_outside_invocation() -> None: assert out == {"result": []} +# --------------------------------------------------------------------------- +# catch gate (proposal 0074) +# --------------------------------------------------------------------------- + + +async def test_catch_matches_carrier_wrapped_category_and_degrades() -> None: + # The catch set matches the DERIVED category through the node_exception + # carrier, where a surface category check would see only the carrier and + # miss it (the degrade->crash footgun the catch gate closes). + carrier = NodeException(node_name="work", cause=_TransientError("rate limited"), recoverable_state={}) + mw = FailureIsolationMiddleware( + degraded_update={"result": []}, + event_name="iso", + catch=["provider_rate_limit"], + ) + assert await mw("s", _raises(carrier)) == {"result": []} + + +async def test_catch_non_matching_category_propagates() -> None: + # The derived category (provider_rate_limit) is not in the catch set, so + # the gate rejects and the carrier propagates. + carrier = NodeException(node_name="work", cause=_TransientError("rate limited"), recoverable_state={}) + mw = FailureIsolationMiddleware( + degraded_update={"result": []}, + event_name="iso", + catch=["provider_unavailable"], + ) + with pytest.raises(NodeException): + await mw("s", _raises(carrier)) + + +async def test_catch_null_derived_category_propagates() -> None: + # A bare uncategorized error has a null derived category, which never + # matches a non-empty catch set. + mw = FailureIsolationMiddleware( + degraded_update={"result": []}, + event_name="iso", + catch=["provider_rate_limit"], + ) + with pytest.raises(ValueError, match="plain"): + await mw("s", _raises(ValueError("plain"))) + + +async def test_catch_and_predicate_compose_as_conjunction() -> None: + # Both gates must admit: catch matches the derived category AND predicate + # admits the surface exception. A matching catch with a false predicate + # still propagates. + caught = FailureIsolationMiddleware( + degraded_update={"result": []}, + event_name="iso", + catch=["provider_rate_limit"], + predicate=lambda e: isinstance(e, _TransientError), + ) + assert await caught("s", _raises(_TransientError("rate limited"))) == {"result": []} + + rejected = FailureIsolationMiddleware( + degraded_update={"result": []}, + event_name="iso", + catch=["provider_rate_limit"], + predicate=lambda _e: False, + ) + with pytest.raises(_TransientError): + await rejected("s", _raises(_TransientError("rate limited"))) + + +async def test_catch_unset_remains_catch_all() -> None: + # catch unset preserves the catch-everything default. + mw = FailureIsolationMiddleware(degraded_update={"result": []}, event_name="iso") + assert await mw("s", _raises(ValueError("anything"))) == {"result": []} + + +async def test_catch_rejection_short_circuits_predicate() -> None: + # The gate matches the spec's ``catch and predicate`` short-circuit: when + # catch rejects, predicate is not consulted. + consulted: list[Exception] = [] + carrier = NodeException(node_name="work", cause=_TransientError("rate limited"), recoverable_state={}) + + def predicate(exc: Exception) -> bool: + consulted.append(exc) + return True + + mw = FailureIsolationMiddleware( + degraded_update={"result": []}, + event_name="iso", + catch=["provider_unavailable"], # does not match the derived provider_rate_limit + predicate=predicate, + ) + with pytest.raises(NodeException): + await mw("s", _raises(carrier)) + assert consulted == [] + + +# --------------------------------------------------------------------------- +# classify_cause_chain public primitive (proposal 0074 §6.4) +# --------------------------------------------------------------------------- + + +def test_classify_cause_chain_derives_category_through_carrier() -> None: + carrier = NodeException(node_name="work", cause=_TransientError("rate limited"), recoverable_state={}) + result = classify_cause_chain(carrier) + assert result.category == "provider_rate_limit" + assert result.message == "rate limited" + assert [(link.category, link.carrier) for link in result.chain] == [ + ("node_exception", True), + ("provider_rate_limit", False), + ] + + +def test_classify_cause_chain_bare_exception_is_null() -> None: + result = classify_cause_chain(ValueError("plain")) + assert result.category is None + assert result.message == "plain" + assert len(result.chain) == 1 + assert result.chain[0].carrier is False + + # --------------------------------------------------------------------------- # Engine integration # --------------------------------------------------------------------------- From 3c83b8b48a2f320d8cf05e4000b7342a397c07aa Mon Sep 17 00:00:00 2001 From: chris-colinsky Date: Sat, 20 Jun 2026 09:42:37 -0700 Subject: [PATCH 3/5] Wire failure-isolation catch conformance fixture 072 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parse the `catch` directive on the failure_isolation fixture middleware config and add fixture 072 to the failure-isolation fixture set. 072 (two cases) drives the catch gate matching through a §9.7 instance node_exception carrier (degrade) and a non-matching catch (propagate). --- tests/conformance/test_pipeline_utilities.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/conformance/test_pipeline_utilities.py b/tests/conformance/test_pipeline_utilities.py index 47ef155..e13fe65 100644 --- a/tests/conformance/test_pipeline_utilities.py +++ b/tests/conformance/test_pipeline_utilities.py @@ -95,7 +95,7 @@ def _load(path: Path) -> dict[str, Any]: # by test_checkpoint.py, which also owns fixture 067, hence the gap at 67). # 071 (fan-out degrade strict-reducer-raise, proposal 0069 coverage, # spec v0.63.1) is an FI-degrade fixture this runner drives. -_FAILURE_ISOLATION_FIXTURES = frozenset(range(58, 67)) | {68, 69, 71} +_FAILURE_ISOLATION_FIXTURES = frozenset(range(58, 67)) | {68, 69, 71, 72} def _fixture_paths() -> list[Path]: @@ -447,7 +447,8 @@ def predicate(exc: Exception) -> bool: def _build_failure_isolation(config: Mapping[str, Any], sinks: CaptureSinks) -> Middleware: """Build the canonical FailureIsolationMiddleware from a fixture - ``failure_isolation`` config (fixtures 058-063).""" + ``failure_isolation`` config (fixtures 058-063; the ``catch`` category + gate is fixture 072).""" degraded_raw = config["degraded_update"] degraded: DegradedUpdate if isinstance(degraded_raw, dict): @@ -493,6 +494,7 @@ async def on_caught_cb(_exc: Exception) -> None: return FailureIsolationMiddleware( degraded_update=degraded, event_name=cast("str", config["event_name"]), + catch=cast("list[str] | None", config.get("catch")), predicate=_build_isolation_predicate(cast("Mapping[str, Any] | None", config.get("predicate"))), on_caught=on_caught, ) From b81675b86849d5e19a1604d4a471ffead7ed9964 Mon Sep 17 00:00:00 2001 From: chris-colinsky Date: Sat, 20 Jun 2026 09:42:53 -0700 Subject: [PATCH 4/5] Document failure-isolation catch + classification (0074) Document the `catch` category gate and the public classify_cause_chain primitive in the middleware concepts page, and add the 0.15.0 changelog entry (advancing the spec-pin bullet to v0.65.0). --- CHANGELOG.md | 3 ++- docs/concepts/middleware.md | 47 ++++++++++++++++++++++++++++++++++--- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fac03b4..37476ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,10 +12,11 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The - **Per-attempt LLM spans under call-level retry** (proposal 0050, observability §5.5 / llm-provider §7.1). Completes proposal 0050, which shipped `partial` in v0.14.0 (failure-isolation middleware and the `complete(retry=...)` loop landed then; the per-attempt span surface was deferred). Under call-level retry the OTel observer now emits one `openarmature.llm.complete` span per attempt, each carrying `openarmature.llm.attempt_index` (0-based, 0..N-1, and 0 for a no-retry call). An intermediate failed attempt's span carries ERROR status plus its error category and the request-side attributes; the final attempt's span carries the terminal outcome and, on success, the full response surface. A python-internal `LlmRetryAttemptEvent`, dispatched once per attempt, is the sole source of the OTel span; the terminal `LlmCompletionEvent` / `LlmFailedEvent` stay one per call (payload, latency, Langfuse Generation) and no longer drive the OTel span. Langfuse renders one terminal Generation per call, with the per-attempt detail on the OTel span surface only (a spec-side §8 clarification to pin this is tracked, non-blocking). `conformance.toml` flips proposal 0050 to `implemented`; the call-level fixtures 056-058 are driven through the provider plus OTel observer and the single-attempt observability fixture 057 is wired. - **Langfuse `trace.userId` / `trace.sessionId` population** (proposal 0064, observability §8.4.1, spec v0.62.0). The Langfuse observer now promotes a recognized `userId` key in the caller-supplied invocation metadata to Langfuse's first-class `trace.userId` field (the Users dashboard), additively: the key also remains at `trace.metadata.userId`. Promotion is automatic and unconditional; an absent key leaves `trace.userId` unset. The `LangfuseClient.trace()` surface (the Protocol, the in-memory client, and the SDK adapter) gains `session_id` / `user_id`. `trace.sessionId` is sourced from `openarmature.session_id`, which the sessions capability (proposal 0020) establishes; that capability is not yet implemented in python, so the `sessionId` plumbing is in place but dormant (no source) and unset in the interim. `conformance.toml` records proposal 0064 `partial` on that basis: fixture 084 cases 2/3/4 (not session-bound, `userId` present additively, `userId` absent) run, and the session-bound cases 1/5 defer until 0020. Langfuse-only: the OTel side already carries `openarmature.session_id` and `openarmature.user.*` as span attributes, and OTel has no trace-level session/user field. - **Per-fetch prompt cache control: `cache_ttl_seconds`** (proposal 0072, prompt-management §5 / §6, spec v0.63.0). `PromptBackend.fetch`, `PromptManager.fetch`, and `PromptManager.get` gain an optional `cache_ttl_seconds` read-side control: `None` preserves current behavior, `0` forces a fresh read past any client-side cache, and `N > 0` bounds a served entry's staleness to N seconds; a negative value is rejected at the manager. It governs only which cached entry may be served, not whether or how results are cached. The bundled filesystem backend is cacheless and ignores it; the bundled Langfuse backend forwards it to the Langfuse SDK's `get_prompt` cache. Conformance fixtures 033/034 run through a caching harness backend (conformance-adapter §6.8: `source_read_count` plus a controllable `advance_clock`). +- **Failure-isolation `catch` gate + cause-chain classification primitive** (proposal 0074, pipeline-utilities §6.3 / §6.4, spec v0.65.0). `FailureIsolationMiddleware` gains an optional `catch`: a set of error categories. An exception is caught only if the *derived category* of its cause chain (the outermost non-carrier link's category, resolved through the engine's `node_exception` carriers, the same value reported as `caught_exception.category`) is in the set. This closes a degrade-into-crash footgun: at a wrapping placement (subgraph, fan-out instance, branch) the engine wraps the originating failure in a carrier, so a `predicate` inspecting the surface exception sees only the carrier and misses it, whereas `catch` classifies through the carrier. `catch` composes with `predicate` as a conjunction; both default permissive (both unset stays catch-all), and a null derived category never matches a non-empty set. The carrier-skipping walk behind `catch` and `caught_exception` is promoted to a public primitive, `classify_cause_chain(exc) -> CaughtException` (the ordered `chain`, the derived `category`, and its `message` — the same record the event carries), exported from `openarmature.graph` for use in a custom `predicate`, a router, a metric, or a full-chain retry classifier. The default retry classifier stays deliberately single-level (it classifies at re-attempt granularity); this is now documented, with no behavior change. Conformance fixture 072 (catch matches through an instance-placement carrier and degrades; a non-matching catch propagates with no event). The optional native-exception-type `catch` form (spec MAY) is not shipped. ### Changed -- **Pinned spec advances v0.60.0 → v0.64.0** across the v0.15.0 cycle: v0.61.0 (proposal 0061, the detached-trace invocation span above), v0.62.0 (proposal 0064, the Langfuse session/user population above), v0.63.0 (proposal 0072, the prompt cache control above), the v0.63.1 patch (pipeline-utilities coverage fixtures 070/071 for the already-implemented 0069 / 0070 behavior, no new proposal), and v0.64.0 (proposal 0073, GenAI semconv adoption reconciliation: OA retains `gen_ai.system` despite the upstream rename to `gen_ai.provider.name`; textual-only, with no emitted-attribute or fixture change, so the existing `gen_ai.*` fixtures stand as the retention regression). `conformance.toml` records 0061 / 0072 `implemented`, 0064 `partial` (its `sessionId` half is dormant pending the sessions capability), and 0073 `textual-only`. Proposal 0050 needed no pin bump of its own (it was already within the pin from its v0.42.0 acceptance); its v0.14.0 `partial` entry flips to `implemented` with the per-attempt span surface above. +- **Pinned spec advances v0.60.0 → v0.65.0** across the v0.15.0 cycle: v0.61.0 (proposal 0061, the detached-trace invocation span above), v0.62.0 (proposal 0064, the Langfuse session/user population above), v0.63.0 (proposal 0072, the prompt cache control above), the v0.63.1 patch (pipeline-utilities coverage fixtures 070/071 for the already-implemented 0069 / 0070 behavior, no new proposal), and v0.64.0 (proposal 0073, GenAI semconv adoption reconciliation: OA retains `gen_ai.system` despite the upstream rename to `gen_ai.provider.name`; textual-only, with no emitted-attribute or fixture change, so the existing `gen_ai.*` fixtures stand as the retention regression), and v0.65.0 (proposal 0074, the failure-isolation `catch` gate above). `conformance.toml` records 0061 / 0072 / 0074 `implemented`, 0064 `partial` (its `sessionId` half is dormant pending the sessions capability), and 0073 `textual-only`. Proposal 0050 needed no pin bump of its own (it was already within the pin from its v0.42.0 acceptance); its v0.14.0 `partial` entry flips to `implemented` with the per-attempt span surface above. ## [0.14.0] — 2026-06-17 diff --git a/docs/concepts/middleware.md b/docs/concepts/middleware.md index 857b8a6..bab7886 100644 --- a/docs/concepts/middleware.md +++ b/docs/concepts/middleware.md @@ -238,9 +238,26 @@ Configuration: like `"failure_isolated"` collapses every degraded path into one indistinguishable bucket in a dashboard, so the name is forced at the construction site, where the context to name it well is available. -- **`predicate`** is an optional `Exception -> bool`. When supplied, - only exceptions where it returns true are caught; everything else - propagates. The default catches every `Exception`. +- **`catch`** is an optional set of error categories. When supplied, an + exception is caught only if the *derived category* of its cause chain + is in the set: the outermost non-carrier link's category, resolved + *through* the engine's `node_exception` carriers (the same value the + event reports as `caught_exception.category`). This is the recommended + gate for category-scoped degradation. At a wrapping placement (a + subgraph, a fan-out instance, a branch) the engine wraps the real + failure in a carrier, so a check on the surface exception sees only the + carrier and misses it; `catch` classifies through the carrier and + matches the originating category. A bare uncategorized error has no + derived category and is not matched, so it propagates. +- **`predicate`** is an optional `Exception -> bool` over the *surface* + (caught) exception. When supplied, only exceptions where it returns true + are caught; everything else propagates. The default is always-true. It + composes with `catch` as a conjunction (both must admit), and both + default permissive, so the both-unset default catches every + `Exception`. Because `predicate` sees the surface exception, it + misclassifies a carrier-wrapped failure at a wrapping placement; reach + for `catch` for category gating, or classify the chain yourself with + `classify_cause_chain` (below). - **`on_caught`** is an optional async hook `Exception -> None`, fired when the middleware catches. Use it to pump the caught exception to caller-specific telemetry beyond the framework event. It fires inline @@ -267,6 +284,30 @@ catch shows up alongside the node's own span. The default emission path is the observer stream only, with no logging-library dependency; `on_caught` is the escape hatch for anything else. +### Cause-chain classification + +The walk behind `catch` and `caught_exception` is exposed as a public +primitive, `classify_cause_chain`, so any consumer classifies a +carrier-wrapped failure the same way the framework does: + +```python +from openarmature.graph import classify_cause_chain + +result = classify_cause_chain(exc) +result.category # derived category (outermost non-carrier link), or None +result.message # the message that category came from +result.chain # the ordered CauseLink chain, carriers flagged +``` + +It returns a `CaughtException` (the same record the failure-isolated +event's `caught_exception` field holds) carrying the ordered `chain` (one +`CauseLink` per exception, carriers flagged), the derived `category`, and +its `message`. Use it in a custom `predicate` that needs to see through +carriers, in a router or metric keyed on the originating category, or in a +retry classifier that wants full-chain depth (the default retry classifier +is deliberately single-level, classifying at re-attempt granularity rather +than walking the full chain). + ### Composing with RetryMiddleware The two compose into the canonical "retry transients, then give up From 6c991a1c4023160a1b35ff961891f5847ceeae9d Mon Sep 17 00:00:00 2001 From: chris-colinsky Date: Sat, 20 Jun 2026 10:09:05 -0700 Subject: [PATCH 5/5] Harden catch typing and tighten derived-category wording PR #174 review: reject a bare str for FailureIsolationMiddleware.catch (a str is a Collection[str], so it would substring-match and silently mis-gate) and normalize to a frozenset. Tighten the derived-category wording in the docstring, the concepts page, and the classify example to the outermost non-carrier link with a category (an uncategorized surface link resolves to the deeper categorized cause). Fix the stale events/errors import comment now that cause_chain imports only errors. --- docs/concepts/middleware.md | 6 +++--- .../graph/middleware/failure_isolation.py | 13 +++++++++---- tests/unit/test_failure_isolation_middleware.py | 11 +++++++++++ 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/docs/concepts/middleware.md b/docs/concepts/middleware.md index bab7886..0fd411e 100644 --- a/docs/concepts/middleware.md +++ b/docs/concepts/middleware.md @@ -240,8 +240,8 @@ Configuration: construction site, where the context to name it well is available. - **`catch`** is an optional set of error categories. When supplied, an exception is caught only if the *derived category* of its cause chain - is in the set: the outermost non-carrier link's category, resolved - *through* the engine's `node_exception` carriers (the same value the + is in the set: the category of the outermost non-carrier link that + carries one, resolved *through* the engine's `node_exception` carriers (the same value the event reports as `caught_exception.category`). This is the recommended gate for category-scoped degradation. At a wrapping placement (a subgraph, a fan-out instance, a branch) the engine wraps the real @@ -294,7 +294,7 @@ carrier-wrapped failure the same way the framework does: from openarmature.graph import classify_cause_chain result = classify_cause_chain(exc) -result.category # derived category (outermost non-carrier link), or None +result.category # derived category (outermost non-carrier link with a category), or None result.message # the message that category came from result.chain # the ordered CauseLink chain, carriers flagged ``` diff --git a/src/openarmature/graph/middleware/failure_isolation.py b/src/openarmature/graph/middleware/failure_isolation.py index 4c0c6a2..221dcfe 100644 --- a/src/openarmature/graph/middleware/failure_isolation.py +++ b/src/openarmature/graph/middleware/failure_isolation.py @@ -54,7 +54,7 @@ if TYPE_CHECKING: # Annotation-only import; classify_cause_chain is imported lazily on the - # catch path to keep cause_chain (and its events / errors imports) off the + # catch path to keep cause_chain (and its errors import) off the # middleware module-load path. from openarmature.graph.cause_chain import CaughtException @@ -79,8 +79,8 @@ class FailureIsolationMiddleware: downstream telemetry strictly worse. - ``catch`` (optional): a set of error categories. When supplied, an exception is caught only if the derived category of its cause chain - (the outermost non-carrier link's category, resolving through - ``node_exception`` carriers, the value reported as + (the category of the outermost non-carrier link that carries one, + resolving through ``node_exception`` carriers, the value reported as ``caught_exception.category``) is in the set. Composes with ``predicate`` as a conjunction; both default permissive (both unset catches every ``Exception``). The recommended gate for @@ -112,7 +112,12 @@ def __init__( ) -> None: self.degraded_update = degraded_update self.event_name = event_name - self.catch = catch + if isinstance(catch, str): + raise TypeError( + "catch must be a collection of category strings, not a single " + f"str (got {catch!r}); pass e.g. [{catch!r}]" + ) + self.catch: frozenset[str] | None = frozenset(catch) if catch is not None else None self.predicate = predicate self.on_caught = on_caught diff --git a/tests/unit/test_failure_isolation_middleware.py b/tests/unit/test_failure_isolation_middleware.py index a76e9cb..a120d4a 100644 --- a/tests/unit/test_failure_isolation_middleware.py +++ b/tests/unit/test_failure_isolation_middleware.py @@ -539,6 +539,17 @@ def predicate(exc: Exception) -> bool: assert consulted == [] +def test_catch_rejects_bare_str() -> None: + # A bare str is a Collection[str]; accepting it would substring-match the + # characters. Construction rejects it so the footgun fails loudly. + with pytest.raises(TypeError, match="collection of category strings"): + FailureIsolationMiddleware( + degraded_update={"result": []}, + event_name="iso", + catch="provider_rate_limit", + ) + + # --------------------------------------------------------------------------- # classify_cause_chain public primitive (proposal 0074 §6.4) # ---------------------------------------------------------------------------