diff --git a/crates/ironclaw_agent_loop/src/executor/capability_helpers.rs b/crates/ironclaw_agent_loop/src/executor/capability_helpers.rs index cd2f0849412..feefc2d109f 100644 --- a/crates/ironclaw_agent_loop/src/executor/capability_helpers.rs +++ b/crates/ironclaw_agent_loop/src/executor/capability_helpers.rs @@ -453,7 +453,6 @@ fn generic_failure_recovery(error_kind: &CapabilityFailureKind) -> ToolRecoveryO | CapabilityFailureKind::Resource | CapabilityFailureKind::Internal | CapabilityFailureKind::Unknown(_) => SameCallRetryConstraint::Allowed, - _ => SameCallRetryConstraint::Allowed, }; ToolRecoveryObservation { same_call_retry, diff --git a/crates/ironclaw_agent_loop/src/executor/mapping.rs b/crates/ironclaw_agent_loop/src/executor/mapping.rs index e9f5a9c8573..50488a5ec98 100644 --- a/crates/ironclaw_agent_loop/src/executor/mapping.rs +++ b/crates/ironclaw_agent_loop/src/executor/mapping.rs @@ -168,12 +168,6 @@ pub(super) fn capability_error_class(kind: &CapabilityFailureKind) -> Capability CapabilityFailureKind::Cancelled | CapabilityFailureKind::Permanent => { CapabilityErrorClass::Permanent } - // CapabilityFailureKind is #[non_exhaustive]. Treat unrecognised future - // variants as recoverable (OperationFailed) to match the host_runtime - // disposition layer's recoverable default: by design no capability - // failure should abort the run, so an unknown kind becomes a - // model-visible tool error rather than killing the run. - &_ => CapabilityErrorClass::OperationFailed, } } @@ -183,7 +177,25 @@ pub(super) fn capability_failure_kind(kind: &CapabilityFailureKind) -> LoopFailu CapabilityFailureKind::Authorization | CapabilityFailureKind::GateDeclined | CapabilityFailureKind::PolicyDenied => LoopFailureKind::PolicyDenied, - _ => LoopFailureKind::CapabilityProtocolError, + // Every remaining kind maps to the protocol-error failure. Enumerated + // explicitly (no wildcard) so a new `CapabilityFailureKind` variant must + // be classified here deliberately rather than silently inheriting this + // terminal fate — `CapabilityFailureKind` is no longer `#[non_exhaustive]`. + CapabilityFailureKind::Backend + | CapabilityFailureKind::Cancelled + | CapabilityFailureKind::Dispatcher + | CapabilityFailureKind::InvalidOutput + | CapabilityFailureKind::MissingRuntime + | CapabilityFailureKind::Network + | CapabilityFailureKind::OperationFailed + | CapabilityFailureKind::OutputTooLarge + | CapabilityFailureKind::Process + | CapabilityFailureKind::Resource + | CapabilityFailureKind::Transient + | CapabilityFailureKind::Unavailable + | CapabilityFailureKind::Internal + | CapabilityFailureKind::Permanent + | CapabilityFailureKind::Unknown(_) => LoopFailureKind::CapabilityProtocolError, } } @@ -269,28 +281,66 @@ mod tests { ); } + /// Classification lock for `capability_error_class`: every + /// `CapabilityFailureKind` variant maps to a deliberate recovery class. + /// + /// This complements the compile-time guarantee (the match is exhaustive with + /// no `_ =>` wildcard, since `CapabilityFailureKind` is no longer + /// `#[non_exhaustive]`, so a *new* variant fails to compile until classified) + /// by also catching a silent *re-bucketing* of an *existing* variant — e.g. + /// moving a recoverable kind into the run-aborting `Permanent` class, or vice + /// versa. Only the genuinely-terminal kinds (`Cancelled` and `Permanent`) + /// may map to `Permanent`; runtime-dispositioned tool failures such as + /// `Dispatcher`, `InvalidOutput`, and the open-set `Unknown` stay + /// model-visible. See + /// `docs/plans/2026-06-28-reborn-error-recoverability-audit.md` §6.1. #[test] - fn model_recoverable_capability_failures_are_operation_failed_not_permanent() { - // The host_runtime disposition layer treats Dispatcher, InvalidOutput, - // and Unknown as model-visible (run-continuing) errors. The recovery - // class mapping must agree: these become OperationFailed (a - // model-visible tool error) rather than Permanent (which aborts the - // run). E.g. the model calling a nonexistent tool becomes a recoverable - // tool error, not a run-ending protocol failure. - assert_eq!( - capability_error_class(&CapabilityFailureKind::Dispatcher), - CapabilityErrorClass::OperationFailed - ); - assert_eq!( - capability_error_class(&CapabilityFailureKind::InvalidOutput), - CapabilityErrorClass::OperationFailed - ); - let unknown = CapabilityFailureKind::unknown("some_future_kind".to_string()) - .expect("valid unknown kind"); - assert_eq!( - capability_error_class(&unknown), - CapabilityErrorClass::OperationFailed - ); + fn every_capability_failure_kind_has_a_deliberate_recovery_class() { + use CapabilityErrorClass as C; + use CapabilityFailureKind as K; + + let unknown = K::unknown("some_future_kind").expect("valid unknown kind"); + let cases: &[(K, C)] = &[ + (K::Network, C::Transient), + (K::Transient, C::Transient), + (K::Backend, C::Unavailable), + (K::Unavailable, C::Unavailable), + (K::InvalidInput, C::InputInvalid), + (K::MissingRuntime, C::OperationFailed), + (K::OperationFailed, C::OperationFailed), + (K::OutputTooLarge, C::OperationFailed), + (K::Process, C::OperationFailed), + (K::Resource, C::OperationFailed), + (K::Authorization, C::PolicyDenied), + (K::GateDeclined, C::PolicyDenied), + (K::PolicyDenied, C::PolicyDenied), + (K::Internal, C::Internal), + (K::Dispatcher, C::OperationFailed), + (K::Cancelled, C::Permanent), + (K::InvalidOutput, C::OperationFailed), + (K::Permanent, C::Permanent), + (unknown.clone(), C::OperationFailed), + ]; + + for (kind, expected) in cases { + assert_eq!( + capability_error_class(kind), + *expected, + "recovery class for {kind:?} changed — re-confirm it is deliberate \ + and does not silently abort a recoverable failure" + ); + } + + // Only these kinds may abort the run. + for (kind, class) in cases { + if *class == C::Permanent { + assert!( + matches!(kind, K::Cancelled | K::Permanent), + "{kind:?} maps to the run-aborting Permanent class but is not a \ + recognized terminal kind — a recoverable failure must not abort" + ); + } + } } #[test] diff --git a/crates/ironclaw_host_runtime/src/lib.rs b/crates/ironclaw_host_runtime/src/lib.rs index af13a24b849..96cd932c0bd 100644 --- a/crates/ironclaw_host_runtime/src/lib.rs +++ b/crates/ironclaw_host_runtime/src/lib.rs @@ -695,8 +695,14 @@ mod raw_http_diagnostic_policy_tests { } /// Stable, sanitized failure categories. +/// +// Deliberately NOT `#[non_exhaustive]`: the `Unknown` variant is the open-set +// escape hatch for unrecognized runtime failures, so the attribute would only +// force classifiers to keep a wildcard arm that silently buckets a new named +// variant. Without it, disposition/classification matches are exhaustive and a +// new named variant fails to compile until classified. See +// `docs/plans/2026-06-28-reborn-error-recoverability-audit.md` §6.1. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -#[non_exhaustive] pub enum RuntimeFailureKind { Authorization, Backend, @@ -714,7 +720,6 @@ pub enum RuntimeFailureKind { Resource, Transient, Unavailable, - Unknown, } impl RuntimeFailureKind { @@ -737,7 +742,6 @@ impl RuntimeFailureKind { Self::Resource => "resource", Self::Transient => "transient", Self::Unavailable => "unavailable", - Self::Unknown => "unknown", } } } diff --git a/crates/ironclaw_host_runtime/src/production.rs b/crates/ironclaw_host_runtime/src/production.rs index 623ff3d9818..4171ae905c4 100644 --- a/crates/ironclaw_host_runtime/src/production.rs +++ b/crates/ironclaw_host_runtime/src/production.rs @@ -2365,7 +2365,13 @@ impl From for RuntimeFailureKind { | DispatchFailureKind::Runtime(RuntimeDispatchErrorKind::UnsupportedRunner) => { RuntimeFailureKind::Backend } - DispatchFailureKind::Runtime(RuntimeDispatchErrorKind::Unknown) => Self::Unknown, + // The fail-safe "uncategorized" redaction bucket collapses to a + // concrete internal failure rather than propagating a dedicated + // `Unknown` category downstream. `Internal` is retryable and + // surfaces to the model/user, so an unclassified dispatch error is + // no longer an opaque run-ending dead-end. See + // `docs/plans/2026-06-28-reborn-error-recoverability-audit.md`. + DispatchFailureKind::Runtime(RuntimeDispatchErrorKind::Unknown) => Self::Internal, } } } @@ -2591,9 +2597,12 @@ output_schema_ref = "schemas/test.output.json" RuntimeDispatchErrorKind::UnsupportedRunner, RuntimeFailureKind::Backend, ), + // The fail-safe "uncategorized" redaction bucket collapses to a + // concrete, surfacing `Internal` rather than a dedicated `Unknown` + // category (which no longer exists on `RuntimeFailureKind`). ( RuntimeDispatchErrorKind::Unknown, - RuntimeFailureKind::Unknown, + RuntimeFailureKind::Internal, ), ]; for (variant, expected) in cases { @@ -2845,7 +2854,6 @@ output_schema_ref = "schemas/test.output.json" assert_eq!(RuntimeFailureKind::Resource.as_str(), "resource"); assert_eq!(RuntimeFailureKind::Transient.as_str(), "transient"); assert_eq!(RuntimeFailureKind::Unavailable.as_str(), "unavailable"); - assert_eq!(RuntimeFailureKind::Unknown.as_str(), "unknown"); } #[test] @@ -2869,7 +2877,6 @@ output_schema_ref = "schemas/test.output.json" (RuntimeFailureKind::Resource, ModelVisibleToolError), (RuntimeFailureKind::Transient, RetrySameCall), (RuntimeFailureKind::Unavailable, RetrySameCall), - (RuntimeFailureKind::Unknown, ModelVisibleToolError), ]; for (kind, expected) in cases { diff --git a/crates/ironclaw_loop_support/src/capability_port.rs b/crates/ironclaw_loop_support/src/capability_port.rs index 44f7c7022cf..71be00394d2 100644 --- a/crates/ironclaw_loop_support/src/capability_port.rs +++ b/crates/ironclaw_loop_support/src/capability_port.rs @@ -2852,8 +2852,6 @@ fn runtime_failure_kind_to_loop( RuntimeFailureKind::Resource => CapabilityFailureKind::Resource, RuntimeFailureKind::Transient => CapabilityFailureKind::Transient, RuntimeFailureKind::Unavailable => CapabilityFailureKind::Unavailable, - RuntimeFailureKind::Unknown => capability_failure_kind("unknown")?, - _ => capability_failure_kind(kind.as_str())?, }) } @@ -3307,13 +3305,6 @@ mod tests { "{runtime:?}" ); } - - assert_eq!( - runtime_failure_kind_to_loop(RuntimeFailureKind::Unknown) - .expect("unknown failure kind") - .as_str(), - "unknown" - ); } #[test] diff --git a/crates/ironclaw_turns/src/run_profile/host.rs b/crates/ironclaw_turns/src/run_profile/host.rs index f76c8b751c2..ebe9599b067 100644 --- a/crates/ironclaw_turns/src/run_profile/host.rs +++ b/crates/ironclaw_turns/src/run_profile/host.rs @@ -1965,7 +1965,17 @@ pub struct CapabilityFailure { pub detail: Option, } -#[non_exhaustive] +// Deliberately NOT `#[non_exhaustive]`: the `Unknown(CapabilityFailureKindValue)` +// variant is the forward-compat / open-set escape hatch (a newer producer's +// unrecognized wire string deserializes into `Unknown`), and the manual +// `Serialize`/`Deserialize` impls below route every value through `as_str()` / +// that variant. Leaving the attribute on would force callers — notably the +// recovery classifier `capability_error_class` — to keep a wildcard `_ =>` arm, +// which silently buckets any newly-added *named* variant (e.g. a future +// `QuotaExceeded`) into a run-aborting class. Without the attribute, those +// classifiers match exhaustively, so a new named variant fails to compile until +// it is deliberately classified. See +// `docs/plans/2026-06-28-reborn-error-recoverability-audit.md` §6.1. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum CapabilityFailureKind { Authorization,