From ad908b91f0f08f24cc42ebd060f3c1a7fc5f6f84 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Wed, 7 Oct 2026 01:58:27 -0700 Subject: [PATCH] P-item refusal-reason collapse: one refusal classification, every surface a class-to-wire table RefusalCode::class (busbar-contract abi/plane) is the one reason-to-family match, total, no catch-all. The kernel's default status, the llm plane's kind_of / is_authentication / refusal_shape, the a2a, mcp, decisions and streaming refusal_render, and the admin surface's answer_for become class-to-wire tables. The streaming plane's catch-all that answered every unnamed reason as internal is gone. The llm plane's served statuses and kinds stay byte-identical: two rows become the class default, four rows keep its answers. Pins: p_item_refusal_reason_collapse_* in busbar-contract (the classification), in each new plane (only a node fault renders internal; one class, one answer), in busbar-llm (no llm refusal reads as an internal error) and in xtask (the source scan: no renderer names a reason). --- crates/busbar-contract/src/abi/plane/mod.rs | 157 +++++++++++ .../busbar-contract/src/caps/seat_verdict.rs | 10 + .../tests/p_item_refusal_reason_collapse.rs | 113 ++++++++ crates/busbar-kernel/src/plane_driver/mod.rs | 48 ++-- .../busbar-llm/tests/llm_refusal_statuses.rs | 32 +++ crates/busbar-plane-a2a/src/plane.rs | 84 ++---- crates/busbar-plane-a2a/src/tests/plane.rs | 33 +++ crates/busbar-plane-decisions/src/plane.rs | 61 ++-- .../busbar-plane-decisions/src/tests/plane.rs | 33 +++ .../busbar-plane-llm/src/exchange/refuse.rs | 58 ++-- crates/busbar-plane-llm/src/plane.rs | 61 ++-- crates/busbar-plane-llm/src/refusal.rs | 39 ++- .../tests/refusal_statuses.rs | 4 + crates/busbar-plane-mcp/src/plane.rs | 73 ++--- crates/busbar-plane-mcp/src/tests/plane.rs | 33 +++ crates/busbar-plane-streaming/src/plane.rs | 44 +-- .../busbar-plane-streaming/src/tests/codec.rs | 63 +++++ .../src/root/units_admin/admin_mount.rs | 27 +- xtask/tests/p_item_refusal_reason_collapse.rs | 263 ++++++++++++++++++ 19 files changed, 964 insertions(+), 272 deletions(-) create mode 100644 crates/busbar-contract/tests/p_item_refusal_reason_collapse.rs create mode 100644 xtask/tests/p_item_refusal_reason_collapse.rs diff --git a/crates/busbar-contract/src/abi/plane/mod.rs b/crates/busbar-contract/src/abi/plane/mod.rs index 8ab50c6c6b..1d482d8f7e 100644 --- a/crates/busbar-contract/src/abi/plane/mod.rs +++ b/crates/busbar-contract/src/abi/plane/mod.rs @@ -863,6 +863,163 @@ pub const fn reason_of(code: u32) -> Option { }) } +// ── the one refusal classification ─────────────────────────────────────────────────────────────── + +/// THE ONE REFUSAL CLASSIFICATION: which family of answer a refusal reason gets, whatever the +/// plane. This is the P-item "refusal-reason collapse" (spec DONE item 2; TODO L-ENG9). +/// +/// A refusal reason is the kernel's own word, and a client never sees it. A client sees the plane's +/// rendering of it. Before this table there were eight hand-copied reason matches, and they +/// disagreed: the kernel's default status, three renderers in one plane, one in each of four +/// others, and the admin surface's. One of them still ended in a catch-all that answered a rate +/// limit, a spent budget or a frozen group as "internal", which tells a caller the node broke when +/// it was a policy refusal, so the caller retries the wrong thing. 1.5.5 never did that: on its one +/// plane each limit reason has its own status and kind (v1.5.5 `crates/busbar/src/ingress/mod.rs: +/// 237-305`: rate 429 `rate_limit_error`, budget 429 `insufficient_quota`, frozen group 403 +/// `permission_error`), and none becomes a 500. +/// +/// So the reason-to-class grouping lives ONCE, here, and a plane holds only a class-to-wire table: +/// its own codes and words. The grouping follows the one plane 1.5.5 shipped (owner correction +/// 2026-09-28): each class has one kernel default status, and that default plus that plane's stated +/// per-reason rows reproduce every status it answered, byte for byte, against the 1.5.5 golden +/// cells. +/// +/// The match in [`RefusalCode::class`] has no `_` arm, so a code added without a class does not +/// compile. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum RefusalClass { + /// The plane could not read the request. + Unreadable, + /// The request was readable, and this node will not take it as asked: an unbillable name, a + /// replayed or superseded idempotency key. + Rejected, + /// No usable authority: no credential, a revoked one, or an exchange that never produced one. + Unauthenticated, + /// The caller is known and may not do this. + Forbidden, + /// The request, or what it needs held, is larger than the node allows. + TooLarge, + /// The caller is over its arrival rate. + Throttled, + /// The node's in-flight table, or the idempotency key's unit, is busy. + Busy, + /// A money cap in the caller's chain has no headroom. + QuotaExhausted, + /// There is nowhere for the request to go. + NotFound, + /// A destination exists, and the way to it is shut: unreachable, breaker open, or its budget + /// spent. + Unreachable, + /// The node cannot take the unit now: capacity, drain, journal, or the client left. + Unavailable, + /// The unit ran out of time. + Timeout, + /// A plane call panicked. + PlaneFault, + /// The node got something wrong and says so without saying what. + NodeFault, +} + +impl RefusalClass { + /// Every class. + pub const ALL: &'static [RefusalClass] = &[ + RefusalClass::Unreadable, + RefusalClass::Rejected, + RefusalClass::Unauthenticated, + RefusalClass::Forbidden, + RefusalClass::TooLarge, + RefusalClass::Throttled, + RefusalClass::Busy, + RefusalClass::QuotaExhausted, + RefusalClass::NotFound, + RefusalClass::Unreachable, + RefusalClass::Unavailable, + RefusalClass::Timeout, + RefusalClass::PlaneFault, + RefusalClass::NodeFault, + ]; + + /// Whether the class is a fault of this node, the one family a plane may render as its + /// internal error. Every other class is a refusal the caller is owed by name. + #[must_use] + pub const fn is_node_fault(self) -> bool { + matches!(self, RefusalClass::PlaneFault | RefusalClass::NodeFault) + } +} + +impl RefusalCode { + /// THE class of this code: the one reason-to-class match in the tree. + #[must_use] + pub const fn class(self) -> RefusalClass { + use RefusalClass as C; + match self { + RefusalCode::DecodeFailed => C::Unreadable, + RefusalCode::NoRate + | RefusalCode::Unpriced + | RefusalCode::Replayed + | RefusalCode::Superseded => C::Rejected, + RefusalCode::Unauthenticated + | RefusalCode::Revoked + | RefusalCode::SessionUnbound + | RefusalCode::SchemeNotDeclared + | RefusalCode::ChallengeExhausted => C::Unauthenticated, + RefusalCode::ScopeDenied + | RefusalCode::PoolNotPermitted + | RefusalCode::HookVeto + | RefusalCode::GroupFrozen => C::Forbidden, + RefusalCode::BodyTooLarge + | RefusalCode::CursorBudget + | RefusalCode::CredentialBudget => C::TooLarge, + RefusalCode::RateLimited => C::Throttled, + RefusalCode::InFlightCap | RefusalCode::InFlight => C::Busy, + RefusalCode::OverBudget => C::QuotaExhausted, + RefusalCode::NoDestination => C::NotFound, + RefusalCode::DestinationUnreachable + | RefusalCode::BreakerOpen + | RefusalCode::DestinationBudgetExhausted => C::Unreachable, + RefusalCode::SessionBudget + | RefusalCode::SpillBudget + | RefusalCode::ScratchExhausted + | RefusalCode::OpenSlotBusy + | RefusalCode::OverdraftCeiling + | RefusalCode::StaleSlice + | RefusalCode::DurabilityUnavailable + | RefusalCode::TierMismatch + | RefusalCode::Drain + | RefusalCode::ClientGone => C::Unavailable, + RefusalCode::Stalled | RefusalCode::DeadlineExceeded => C::Timeout, + RefusalCode::PlanePanic => C::PlaneFault, + RefusalCode::MeterDisputed + | RefusalCode::HandoffMismatch + | RefusalCode::TaskLost + | RefusalCode::SecretPlaceholder => C::NodeFault, + } + } +} + +/// The class of a kernel refusal reason ([`RefusalCode::class`] of its wire code). +#[must_use] +pub const fn class_of(reason: ReasonCode) -> RefusalClass { + wire_code(reason).class() +} + +/// The class of a refusal as a plane is handed it ([`RefusalCode::class`] of its wire code). +#[must_use] +pub fn class_of_refusal(reason: crate::unit::RefusalReason) -> RefusalClass { + class_of(ReasonCode::from(reason)) +} + +/// The class of a reason by its spelling on the journal and the wire (`ReasonCode::as_str`), for +/// every reason a plane can be handed ([`reason_of`]); `None` for any other word, the kernel's own +/// two money verdicts included. +#[must_use] +pub fn class_of_word(word: &str) -> Option { + RefusalCode::ALL + .iter() + .find(|c| reason_of(c.code()).is_some_and(|r| r.as_str() == word)) + .map(|c| c.class()) +} + // ── the Statement tail ─────────────────────────────────────────────────────────────────────────── /// A dialect's default auth style (the 1.5.5 dialect defaults), as data. diff --git a/crates/busbar-contract/src/caps/seat_verdict.rs b/crates/busbar-contract/src/caps/seat_verdict.rs index b3ac6a7430..fe9d10a62a 100644 --- a/crates/busbar-contract/src/caps/seat_verdict.rs +++ b/crates/busbar-contract/src/caps/seat_verdict.rs @@ -108,6 +108,16 @@ macro_rules! reasons { } } } + + // The same join read the other way, so a plane handed a `RefusalReason` can reach the one + // classification (`abi::plane::RefusalCode::class`) without a reason match of its own. + impl From for ReasonCode { + fn from(reason: crate::unit::RefusalReason) -> Self { + match reason { + $(crate::unit::RefusalReason::$refusal => ReasonCode::$name,)* + } + } + } }; } diff --git a/crates/busbar-contract/tests/p_item_refusal_reason_collapse.rs b/crates/busbar-contract/tests/p_item_refusal_reason_collapse.rs new file mode 100644 index 0000000000..b23b2d4aba --- /dev/null +++ b/crates/busbar-contract/tests/p_item_refusal_reason_collapse.rs @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! P-ITEM: REFUSAL-REASON COLLAPSE (spec DONE item 2, `docs/design/BUSBAR-1.6.0.md`, "All P-item +//! behaviours match 1.5.5"; the drive log's P1/P2, commit 470351a480; TODO L-ENG9). +//! +//! THE 1.5.5 BEHAVIOUR. 1.5.5 had one plane (owner correction 2026-09-28). On it +//! every limit reason reached the caller as its own status and kind, and none of them became an +//! internal error: a rate limit 429 `rate_limit_error`, a spent budget 429 `insufficient_quota` +//! (400 on bedrock), a frozen group 403 `permission_error` (v1.5.5 +//! `crates/busbar/src/ingress/mod.rs:237-305`). The bug was a plane answering a policy refusal as +//! "this node broke", which a caller retries the wrong way. +//! +//! THE ROOT. The reason-to-family decision had been hand-copied eight times, and the copies had +//! drifted; one of them still ended in a catch-all that answered every reason it did not name as +//! internal. The fix is ONE classification +//! (`busbar_contract::abi::plane::RefusalCode::class`), with each surface holding only a +//! class-to-wire table. This file pins the classification; `xtask/tests/ +//! p_item_refusal_reason_collapse.rs` reads the renderers' source and proves none of them holds a +//! reason match of its own again. + +use busbar_contract::abi::plane::{ + class_of, class_of_refusal, class_of_word, wire_code, RefusalClass, RefusalCode, +}; +use busbar_contract::caps::ReasonCode; +use busbar_contract::unit::RefusalReason; + +/// Every reason has exactly one class, read the same way from each of its three spellings. +#[test] +fn p_item_refusal_reason_collapse_every_reason_has_one_class_by_every_spelling() { + assert_eq!(ReasonCode::ALL.len(), RefusalCode::ALL.len()); + for reason in ReasonCode::ALL { + let class = class_of(*reason); + assert_eq!(wire_code(*reason).class(), class, "{reason:?}"); + let refusal = RefusalReason::from(*reason); + assert_eq!(ReasonCode::from(refusal), *reason, "the bridge round-trips"); + assert_eq!( + class_of_refusal(refusal), + class, + "{reason:?} as a plane is handed it" + ); + // By its spelling: every reason a plane can be handed; the kernel's own two money verdicts + // never reach a plane (`reason_of`) and read as no reason. + let kernel_only = matches!( + reason, + ReasonCode::OverdraftCeiling | ReasonCode::StaleSlice + ); + assert_eq!( + class_of_word(reason.as_str()), + (!kernel_only).then_some(class), + "{reason:?} by its spelling" + ); + } + assert_eq!(class_of_word("no_such_reason"), None); +} + +/// Every class holds at least one reason: a class nothing lands in is a renderer arm nobody reads. +#[test] +fn p_item_refusal_reason_collapse_every_class_is_reached() { + for class in RefusalClass::ALL { + assert!( + ReasonCode::ALL.iter().any(|r| class_of(*r) == *class), + "{class:?} holds no reason" + ); + } +} + +/// The reasons 1.5.5's one surface answered keep a family of their own, and none of them is a +/// node fault: each is a refusal the caller is owed by name. +#[test] +fn p_item_refusal_reason_collapse_the_1_5_5_reasons_keep_their_own_family() { + for (reason, class) in [ + // v1.5.5 ingress/mod.rs:237-258: a rate limit is 429 `rate_limit_error`. + (ReasonCode::RateLimited, RefusalClass::Throttled), + // v1.5.5 ingress/mod.rs:268-285: a spent budget is `insufficient_quota`. + (ReasonCode::OverBudget, RefusalClass::QuotaExhausted), + // v1.5.5 ingress/mod.rs:288-297: a frozen group is 403 `permission_error`. + (ReasonCode::GroupFrozen, RefusalClass::Forbidden), + // The recorded 1.5.5 cells of these reasons (`request|{unauthenticated,malformed, + // upstream_down}`, `http.crosscut|413|*`). + (ReasonCode::Unauthenticated, RefusalClass::Unauthenticated), + (ReasonCode::DecodeFailed, RefusalClass::Unreadable), + ( + ReasonCode::DestinationUnreachable, + RefusalClass::Unreachable, + ), + (ReasonCode::BodyTooLarge, RefusalClass::TooLarge), + ] { + assert_eq!(class_of(reason), class, "{reason:?}"); + assert!(!class.is_node_fault(), "{reason:?} is no node fault"); + } +} + +/// Only a fault of this node is a node fault. Every admission, capacity, rate, budget, breaker, +/// drain or deadline reason is a refusal the caller is told the family of. +#[test] +fn p_item_refusal_reason_collapse_only_the_nodes_own_faults_are_node_faults() { + let faults: Vec = ReasonCode::ALL + .iter() + .copied() + .filter(|r| class_of(*r).is_node_fault()) + .collect(); + assert_eq!( + faults, + [ + ReasonCode::MeterDisputed, + ReasonCode::HandoffMismatch, + ReasonCode::PlanePanic, + ReasonCode::TaskLost, + ReasonCode::SecretPlaceholder, + ] + ); +} diff --git a/crates/busbar-kernel/src/plane_driver/mod.rs b/crates/busbar-kernel/src/plane_driver/mod.rs index 10720034ca..e4d3fb8b69 100644 --- a/crates/busbar-kernel/src/plane_driver/mod.rs +++ b/crates/busbar-kernel/src/plane_driver/mod.rs @@ -71,8 +71,9 @@ use busbar_contract::abi::mechanism::call::{ }; use busbar_contract::abi::mechanism::ticket::Ticket; use busbar_contract::abi::plane::{ - reason_code, ArriveIn, ArriveOut, OutField, RefusalIn, RefusalOut, RefusalStatus, UnitCount, - REFUSAL_ANY_DIALECT, REFUSAL_ARRIVE, REFUSAL_GATE, REFUSAL_KERNEL, ROUTE_LOCAL, ROUTE_SESSION, + class_of, reason_code, ArriveIn, ArriveOut, OutField, RefusalClass, RefusalIn, RefusalOut, + RefusalStatus, UnitCount, REFUSAL_ANY_DIALECT, REFUSAL_ARRIVE, REFUSAL_GATE, REFUSAL_KERNEL, + ROUTE_LOCAL, ROUTE_SESSION, }; use busbar_contract::abi::sdk::door::{blank_in, blank_out}; use busbar_contract::caps::{ @@ -180,28 +181,37 @@ impl DriverConfig { } } -/// Whether `reason` refuses a unit at authentication. +/// Whether `reason` refuses a unit at authentication: its class is +/// [`RefusalClass::Unauthenticated`] (the one classification, `busbar_contract::abi::plane`). fn is_authentication(reason: ReasonCode) -> bool { - matches!( - reason, - ReasonCode::Unauthenticated - | ReasonCode::Revoked - | ReasonCode::SchemeNotDeclared - | ReasonCode::SessionUnbound - ) + class_of(reason) == RefusalClass::Unauthenticated } /// The status the kernel hands `refusal` for a reason, when the deployment states no other. +/// +/// A class-to-status table over the one classification ([`class_of`]); the kernel holds no reason +/// match of its own. A plane whose dialect answers a reason differently states a row for it +/// ([`DriverConfig::refusal_statuses`]), which is how a plane keeps every status 1.5.5 answered. pub fn refusal_status(reason: ReasonCode) -> u32 { - match reason { - ReasonCode::DecodeFailed | ReasonCode::SchemeNotDeclared => 400, - ReasonCode::Unauthenticated | ReasonCode::Revoked | ReasonCode::SessionUnbound => 401, - ReasonCode::ScopeDenied | ReasonCode::PoolNotPermitted | ReasonCode::HookVeto => 403, - ReasonCode::BodyTooLarge => 413, - ReasonCode::RateLimited | ReasonCode::OverBudget | ReasonCode::GroupFrozen => 429, - ReasonCode::DestinationUnreachable | ReasonCode::PlanePanic => 502, - ReasonCode::DeadlineExceeded | ReasonCode::Stalled => 504, - _ => 503, + class_status(class_of(reason)) +} + +/// The kernel's default status for one refusal class. +pub const fn class_status(class: RefusalClass) -> u32 { + match class { + RefusalClass::Unreadable => 400, + RefusalClass::Unauthenticated => 401, + RefusalClass::Forbidden => 403, + RefusalClass::TooLarge => 413, + RefusalClass::Throttled | RefusalClass::QuotaExhausted => 429, + RefusalClass::PlaneFault => 502, + RefusalClass::Timeout => 504, + RefusalClass::Rejected + | RefusalClass::Busy + | RefusalClass::NotFound + | RefusalClass::Unreachable + | RefusalClass::Unavailable + | RefusalClass::NodeFault => 503, } } diff --git a/crates/busbar-llm/tests/llm_refusal_statuses.rs b/crates/busbar-llm/tests/llm_refusal_statuses.rs index e47ba74ca4..7268b16da0 100644 --- a/crates/busbar-llm/tests/llm_refusal_statuses.rs +++ b/crates/busbar-llm/tests/llm_refusal_statuses.rs @@ -155,3 +155,35 @@ fn the_rows_pass_the_validator() { Ok(()) ); } + +/// P-ITEM: REFUSAL-REASON COLLAPSE on the llm surface, the one surface 1.5.5 had (spec DONE item 2; +/// owner correction 2026-09-28). 1.5.5 answered every limit reason with its own status and kind and +/// none of them as an internal error (v1.5.5 `crates/busbar/src/ingress/mod.rs:237-305`). For +/// every dialect and every reason the driver can refuse for, the status the driver chooses is never +/// a bare 500, and the kind the plane renders is the generic `api_error` only for the node's own +/// faults and for a timeout (whose 504 is its own status). The classes come from the one +/// classification; the rows above keep every recorded 1.5.5 status. +#[test] +fn p_item_refusal_reason_collapse_no_llm_refusal_reads_as_an_internal_error() { + use busbar_contract::abi::plane::{class_of, RefusalClass}; + use busbar_plane_llm::exchange::refuse::kind_of; + let config = config(); + let mut wrong = Vec::new(); + for d in SIX { + for reason in ReasonCode::ALL { + let class = class_of(*reason); + let status = config.status(dialect(d), *reason); + let kind = kind_of(reason.as_str(), u16::try_from(status).expect("a status")); + if status == 500 { + wrong.push(format!("{d} {reason:?} answers a bare 500")); + } + let generic = kind == busbar_contract::protocol::KIND_API_ERROR; + if generic != (class.is_node_fault() || class == RefusalClass::Timeout) { + wrong.push(format!( + "{d} {reason:?} ({class:?}) reads as {kind} at {status}" + )); + } + } + } + assert!(wrong.is_empty(), "{}", wrong.join("\n")); +} diff --git a/crates/busbar-plane-a2a/src/plane.rs b/crates/busbar-plane-a2a/src/plane.rs index 9a4ff3d377..c028d7ee53 100644 --- a/crates/busbar-plane-a2a/src/plane.rs +++ b/crates/busbar-plane-a2a/src/plane.rs @@ -12,6 +12,7 @@ //! so every draft below hands the loop a body the kernel does not have to re-walk. The plane once //! handed back an empty table because the arena could not allocate one; it can, and this does. +use busbar_contract::abi::plane::{class_of_refusal, RefusalClass}; use busbar_contract::bounded::{BoundedVec, FactValue, Facts, Ir, ScratchBytes, Span}; use busbar_contract::dest::{DestinationFacts, EgressBody, Leg, RoutePlan, VerifiedDestination}; use busbar_contract::ids::{AdminVerbId, LaneId, SchemeAlt}; @@ -201,83 +202,54 @@ fn has(body: &[u8], pointer: &str) -> bool { /// must compare against the rig's recorded answers on the day it switches this plane on. That is /// stated here rather than left for someone to discover. fn refusal_render(reason: RefusalReason) -> (i64, &'static str) { - // THE MATCH IS TOTAL — there is no `_` arm. A2A's JSON-RPC binding names a small set of codes, - // so a busbar-specific condition rides the NEAREST defined binding with the real reason kept out - // of the words rather than a code the specification does not define (the rule the legacy plane's - // `rpcerror.rs` states: "a busbar-specific condition is mapped to the NEAREST binding ... rather - // than to a code the specification does not define"). The whole point of removing the catch-all - // is that a reason with no home is a COMPILE error here, never a silent collapse to an internal - // fault — the defect this exhaustive form exists to make impossible: a rate-limit, a breaker or a - // drain answered as "this node broke" tells the caller to retry the wrong thing. - match reason { + // A class-to-wire table over the one classification (`busbar_contract::abi::plane:: + // RefusalClass`; the P-item "refusal-reason collapse"). Which family a reason belongs to is + // decided once, there; this plane says only how each family reads on its wire. A2A's JSON-RPC + // binding names a small set of codes, so a busbar-specific condition rides the NEAREST defined + // binding with the real reason kept out of the words rather than a code the specification does + // not define (the rule the legacy plane's `rpcerror.rs` states). The match has no `_` arm: a + // class with no home is a compile error, never a silent collapse to an internal fault -- a rate + // limit, a breaker or a drain answered as "this node broke" tells the caller to retry the wrong + // thing. + match class_of_refusal(reason) { // The caller's request was not one this node could read or take. - RefusalReason::BodyTooLarge => (jsonrpc::CODE_INVALID_REQUEST, "the request is too large"), - RefusalReason::DecodeFailed => ( + RefusalClass::TooLarge => (jsonrpc::CODE_INVALID_REQUEST, "the request is too large"), + RefusalClass::Unreadable => ( jsonrpc::CODE_INVALID_REQUEST, "the request could not be read", ), - RefusalReason::SchemeNotDeclared - | RefusalReason::CredentialRejected - | RefusalReason::SessionUnbound - | RefusalReason::CredentialBudget => ( + RefusalClass::Unauthenticated => ( jsonrpc::CODE_INVALID_REQUEST, "the request did not carry usable authority", ), // The caller is known and may not do this. - RefusalReason::ScopeMissing - | RefusalReason::Vetoed - | RefusalReason::Revoked - | RefusalReason::PoolNotPermitted => ( + RefusalClass::Forbidden => ( jsonrpc::CODE_UNSUPPORTED_OPERATION, "the caller may not perform this operation", ), // There is nowhere for it to go. - RefusalReason::NoDestination => ( + RefusalClass::NotFound => ( jsonrpc::CODE_INVALID_PARAMS, "no agent is reachable for this request", ), // Every busbar-specific admission / capacity / rate / budget / breaker / drain refusal. The // A2A JSON-RPC binding defines no code of its own for any of these, so each rides the nearest - // one — `UnsupportedOperation`, which is how the legacy plane answers its own admission - // refusals too — with a neutral message that leaks nothing about the money, the buckets or + // one -- `UnsupportedOperation`, which is how the legacy plane answers its own admission + // refusals too -- with a neutral message that leaks nothing about the money, the buckets or // the store. A caller learns it was refused here and nothing more. - RefusalReason::InFlightCap - | RefusalReason::CursorBudget - | RefusalReason::SessionBudget - | RefusalReason::OpenSlotBusy - | RefusalReason::OverBudget - | RefusalReason::GroupFrozen - | RefusalReason::Unpriced - | RefusalReason::OverdraftCeiling - | RefusalReason::StaleSlice - | RefusalReason::TierMismatch - | RefusalReason::SpillBudget - | RefusalReason::ScratchExhausted - | RefusalReason::RateLimited - | RefusalReason::ChallengeExhausted - | RefusalReason::NoRate - | RefusalReason::Replayed - | RefusalReason::InFlight - | RefusalReason::DestinationBudgetExhausted - | RefusalReason::BreakerOpen - | RefusalReason::DestinationUnreachable - | RefusalReason::Drain - | RefusalReason::Superseded - | RefusalReason::ClientGone - | RefusalReason::DeadlineExceeded - | RefusalReason::Stalled => ( + RefusalClass::Rejected + | RefusalClass::Throttled + | RefusalClass::Busy + | RefusalClass::QuotaExhausted + | RefusalClass::Unreachable + | RefusalClass::Unavailable + | RefusalClass::Timeout => ( jsonrpc::CODE_UNSUPPORTED_OPERATION, "the request could not be served at this time", ), - // A genuine node-internal fault — this node did break, and the caller is owed that fact and - // not a false policy refusal. Listed explicitly (never a catch-all) so a new reason cannot - // join this arm by accident. - RefusalReason::DurabilityUnavailable - | RefusalReason::MeterDisputed - | RefusalReason::HandoffMismatch - | RefusalReason::PlanePanic - | RefusalReason::TaskLost - | RefusalReason::SecretPlaceholder => ( + // A genuine node-internal fault -- this node did break, and the caller is owed that fact and + // not a false policy refusal. + RefusalClass::PlaneFault | RefusalClass::NodeFault => ( jsonrpc::CODE_INTERNAL, "the request could not be served at this time", ), diff --git a/crates/busbar-plane-a2a/src/tests/plane.rs b/crates/busbar-plane-a2a/src/tests/plane.rs index dae1c46dd6..2a8891a79b 100644 --- a/crates/busbar-plane-a2a/src/tests/plane.rs +++ b/crates/busbar-plane-a2a/src/tests/plane.rs @@ -404,3 +404,36 @@ fn a_request_naming_no_carried_agent_reaches_none() { "the one agent there is" ); } + +/// P-ITEM: REFUSAL-REASON COLLAPSE (spec DONE item 2, "All P-item behaviours match 1.5.5"; drive +/// log P1, commit 470351a480; TODO L-ENG9). 1.5.5's one surface gave every limit reason its own +/// status and kind and answered none of them as an internal error (v1.5.5 +/// `crates/busbar/src/ingress/mod.rs:237-305`). On this plane: a reason renders as the internal +/// code exactly when its class is a node fault, and every reason of one class renders the same +/// answer, so the reason-to-family decision is the one classification's +/// (`busbar_contract::abi::plane::RefusalCode::class`) and never this plane's own. +#[test] +fn p_item_refusal_reason_collapse_only_a_node_fault_is_internal_and_one_class_one_answer() { + use busbar_contract::abi::plane::{reason_of, RefusalClass, RefusalCode}; + let mut answers: Vec<(RefusalClass, (i64, &'static str))> = Vec::new(); + for code in RefusalCode::ALL { + // Two codes are the kernel's own money verdicts and never reach a plane (`reason_of`). + let Some(reason) = reason_of(code.code()) else { + continue; + }; + let class = code.class(); + let answer = refusal_render(busbar_contract::unit::RefusalReason::from(reason)); + assert_eq!( + answer.0 == crate::jsonrpc::CODE_INTERNAL, + class.is_node_fault(), + "{code:?} (class {class:?}) renders {answer:?}" + ); + match answers.iter().find(|(c, _)| *c == class) { + Some((_, first)) => assert_eq!( + *first, answer, + "{code:?} answers differently from the rest of {class:?}" + ), + None => answers.push((class, answer)), + } + } +} diff --git a/crates/busbar-plane-decisions/src/plane.rs b/crates/busbar-plane-decisions/src/plane.rs index dbbfb3cd44..b0d2835976 100644 --- a/crates/busbar-plane-decisions/src/plane.rs +++ b/crates/busbar-plane-decisions/src/plane.rs @@ -5,6 +5,7 @@ //! not a price. Nothing in this file opens a connection, reads a file, reads a clock other than the //! one the context hands it, or keeps a byte across a call. +use busbar_contract::abi::plane::{class_of_refusal, RefusalClass}; use busbar_contract::bounded::{FactValue, Facts, Ir, ScratchBytes}; use busbar_contract::dest::{DestinationFacts, EgressBody, Leg, RoutePlan, VerifiedDestination}; use busbar_contract::grammar::{ArrivalLocation, Location}; @@ -98,61 +99,35 @@ impl DecisionPlane { /// message. THE MATCH IS TOTAL — no `_` arm — so a reason with no home here is a compile error, /// never a silent collapse to an internal fault. fn refusal_render(reason: RefusalReason) -> (&'static str, &'static str) { - match reason { - RefusalReason::BodyTooLarge => ("invalid_request", "the request is too large"), - RefusalReason::DecodeFailed => ("invalid_request", "the request could not be read"), - RefusalReason::SchemeNotDeclared - | RefusalReason::CredentialRejected - | RefusalReason::SessionUnbound - | RefusalReason::CredentialBudget => ( + // A class-to-wire table over the one classification (`busbar_contract::abi::plane:: + // RefusalClass`; the P-item "refusal-reason collapse"): which family a reason belongs to is + // decided once, there. + match class_of_refusal(reason) { + RefusalClass::TooLarge => ("invalid_request", "the request is too large"), + RefusalClass::Unreadable => ("invalid_request", "the request could not be read"), + RefusalClass::Unauthenticated => ( "invalid_request", "the request did not carry usable authority", ), - RefusalReason::ScopeMissing - | RefusalReason::Vetoed - | RefusalReason::Revoked - | RefusalReason::PoolNotPermitted => ( + RefusalClass::Forbidden => ( "unsupported_operation", "the caller may not perform this operation", ), - RefusalReason::NoDestination => ( + RefusalClass::NotFound => ( "invalid_params", "no decision provider is reachable for this request", ), - RefusalReason::InFlightCap - | RefusalReason::CursorBudget - | RefusalReason::SessionBudget - | RefusalReason::OpenSlotBusy - | RefusalReason::OverBudget - | RefusalReason::GroupFrozen - | RefusalReason::Unpriced - | RefusalReason::OverdraftCeiling - | RefusalReason::StaleSlice - | RefusalReason::TierMismatch - | RefusalReason::SpillBudget - | RefusalReason::ScratchExhausted - | RefusalReason::RateLimited - | RefusalReason::ChallengeExhausted - | RefusalReason::NoRate - | RefusalReason::Replayed - | RefusalReason::InFlight - | RefusalReason::DestinationBudgetExhausted - | RefusalReason::BreakerOpen - | RefusalReason::DestinationUnreachable - | RefusalReason::Drain - | RefusalReason::Superseded - | RefusalReason::ClientGone - | RefusalReason::DeadlineExceeded - | RefusalReason::Stalled => ( + RefusalClass::Rejected + | RefusalClass::Throttled + | RefusalClass::Busy + | RefusalClass::QuotaExhausted + | RefusalClass::Unreachable + | RefusalClass::Unavailable + | RefusalClass::Timeout => ( "unsupported_operation", "the request could not be served at this time", ), - RefusalReason::DurabilityUnavailable - | RefusalReason::MeterDisputed - | RefusalReason::HandoffMismatch - | RefusalReason::PlanePanic - | RefusalReason::TaskLost - | RefusalReason::SecretPlaceholder => { + RefusalClass::PlaneFault | RefusalClass::NodeFault => { ("internal", "the request could not be served at this time") } } diff --git a/crates/busbar-plane-decisions/src/tests/plane.rs b/crates/busbar-plane-decisions/src/tests/plane.rs index 26ecf07339..3625a875c6 100644 --- a/crates/busbar-plane-decisions/src/tests/plane.rs +++ b/crates/busbar-plane-decisions/src/tests/plane.rs @@ -273,3 +273,36 @@ fn the_provider_is_the_declared_and_written_session_fact() { assert_eq!(ingress_provider_fact(DecisionPlane::new(TWO)), None); assert_eq!(ingress_provider_fact(DecisionPlane::EMPTY), None); } + +/// P-ITEM: REFUSAL-REASON COLLAPSE (spec DONE item 2, "All P-item behaviours match 1.5.5"; drive +/// log P1/P2 (the same defect on this plane), commit 470351a480; TODO L-ENG9). 1.5.5's one surface gave every limit reason its own +/// status and kind and answered none of them as an internal error (v1.5.5 +/// `crates/busbar/src/ingress/mod.rs:237-305`). On this plane: a reason renders as the internal +/// code exactly when its class is a node fault, and every reason of one class renders the same +/// answer, so the reason-to-family decision is the one classification's +/// (`busbar_contract::abi::plane::RefusalCode::class`) and never this plane's own. +#[test] +fn p_item_refusal_reason_collapse_only_a_node_fault_is_internal_and_one_class_one_answer() { + use busbar_contract::abi::plane::{reason_of, RefusalClass, RefusalCode}; + let mut answers: Vec<(RefusalClass, (&'static str, &'static str))> = Vec::new(); + for code in RefusalCode::ALL { + // Two codes are the kernel's own money verdicts and never reach a plane (`reason_of`). + let Some(reason) = reason_of(code.code()) else { + continue; + }; + let class = code.class(); + let answer = refusal_render(busbar_contract::unit::RefusalReason::from(reason)); + assert_eq!( + answer.0 == "internal", + class.is_node_fault(), + "{code:?} (class {class:?}) renders {answer:?}" + ); + match answers.iter().find(|(c, _)| *c == class) { + Some((_, first)) => assert_eq!( + *first, answer, + "{code:?} answers differently from the rest of {class:?}" + ), + None => answers.push((class, answer)), + } + } +} diff --git a/crates/busbar-plane-llm/src/exchange/refuse.rs b/crates/busbar-plane-llm/src/exchange/refuse.rs index b95c5dfa8a..8b767ccc01 100644 --- a/crates/busbar-plane-llm/src/exchange/refuse.rs +++ b/crates/busbar-plane-llm/src/exchange/refuse.rs @@ -11,7 +11,7 @@ //! dialect's own vendor sentence) from the reason; every other message is the kernel's own text, //! which names the facts only the kernel has (a group, a window, a model with no rate). -use busbar_contract::abi::plane::reason_of; +use busbar_contract::abi::plane::{class_of_word, reason_of, RefusalClass}; use busbar_contract::protocol::{ ProtocolDecl, APPLICATION_JSON, KIND_API_ERROR, KIND_INSUFFICIENT_QUOTA, KIND_INVALID_REQUEST, KIND_NOT_FOUND, KIND_OVERLOADED, KIND_PERMISSION, KIND_RATE_LIMIT, KIND_REQUEST_TOO_LARGE, @@ -81,22 +81,34 @@ pub fn render( /// The kind a kernel refusal wears, by its reason; an unknown reason by its status family. #[must_use] pub fn kind_of(reason: &str, status: u16) -> &'static str { - match reason { - "rate_limited" | "in_flight" | "in_flight_cap" => KIND_RATE_LIMIT, - "over_budget" => KIND_INSUFFICIENT_QUOTA, - "group_frozen" | "scope_denied" | "pool_not_permitted" | "hook_veto" => KIND_PERMISSION, - "body_too_large" | "cursor_budget" | "credential_budget" => KIND_REQUEST_TOO_LARGE, - "no_rate" | "unpriced" | "decode_failed" | "replayed" | "superseded" => { - KIND_INVALID_REQUEST - } - "no_destination" => KIND_NOT_FOUND, - "meter_disputed" | "handoff_mismatch" | "plane_panic" | "task_lost" - | "secret_placeholder" => KIND_API_ERROR, - _ if status >= 500 && status != 503 => KIND_API_ERROR, - _ if status >= 500 => KIND_OVERLOADED, - _ if status == 429 => KIND_RATE_LIMIT, - _ if status == 403 => KIND_PERMISSION, - _ if status == 413 => KIND_REQUEST_TOO_LARGE, + // A class-to-kind table over the one classification: this plane holds no reason match. + match class_of_word(reason) { + Some(RefusalClass::Unreadable | RefusalClass::Rejected) => KIND_INVALID_REQUEST, + Some(RefusalClass::Forbidden) => KIND_PERMISSION, + Some(RefusalClass::TooLarge) => KIND_REQUEST_TOO_LARGE, + Some(RefusalClass::Throttled | RefusalClass::Busy) => KIND_RATE_LIMIT, + Some(RefusalClass::QuotaExhausted) => KIND_INSUFFICIENT_QUOTA, + Some(RefusalClass::NotFound) => KIND_NOT_FOUND, + Some(RefusalClass::PlaneFault | RefusalClass::NodeFault) => KIND_API_ERROR, + // The class names no kind of its own here: the status the kernel chose says it. + Some( + RefusalClass::Unauthenticated + | RefusalClass::Unreachable + | RefusalClass::Unavailable + | RefusalClass::Timeout, + ) + | None => kind_of_status(status), + } +} + +/// The kind a status alone reads as. +fn kind_of_status(status: u16) -> &'static str { + match status { + 503 => KIND_OVERLOADED, + s if s >= 500 => KIND_API_ERROR, + 429 => KIND_RATE_LIMIT, + 403 => KIND_PERMISSION, + 413 => KIND_REQUEST_TOO_LARGE, _ => KIND_INVALID_REQUEST, } } @@ -111,16 +123,10 @@ pub fn model_not_found(model: &str, shaped: Option<&str>) -> String { ) } -/// Whether a reason is an authentication refusal: those answer in the dialect's own vendor terms. +/// Whether a reason is an authentication refusal (its class is `Unauthenticated`): those answer in +/// the dialect's own vendor terms. fn is_authentication(reason: &str) -> bool { - matches!( - reason, - "unauthenticated" - | "revoked" - | "scheme_not_declared" - | "session_unbound" - | "challenge_exhausted" - ) + class_of_word(reason) == Some(RefusalClass::Unauthenticated) } /// RENDER A KERNEL REFUSAL in `envelope`'s dialect: `reason` is the reason's code on the plane ABI, diff --git a/crates/busbar-plane-llm/src/plane.rs b/crates/busbar-plane-llm/src/plane.rs index 09eee34040..2a607fcbc0 100644 --- a/crates/busbar-plane-llm/src/plane.rs +++ b/crates/busbar-plane-llm/src/plane.rs @@ -5,6 +5,7 @@ //! asks for. The interesting reading is in the codec crate; the interesting decisions are in the //! units. What is here is the wiring, and it is meant to stay boring enough to check by eye. +use busbar_contract::abi::plane::{class_of_refusal, RefusalClass}; use busbar_contract::bounded::{ BoundedVec, FactValue, Facts, Ir, ScratchBytes, Span, MAX_RESPONSE_PTRS, }; @@ -168,53 +169,25 @@ fn finish_of(stop: Option) -> FinishClass { /// The status and kind token one refusal reason wears on the wire. /// /// The reason code itself never reaches a client: what reaches a client is this dialect's own -/// rendering of the pair below, written by the dialect's own error writer. +/// rendering of the pair below, written by the dialect's own error writer. A class-to-wire table +/// over the one classification (`busbar_contract::abi::plane::RefusalClass`): which family a reason +/// belongs to is decided once, there, and not here. fn refusal_shape(reason: RefusalReason) -> (u16, &'static str) { - match reason { - RefusalReason::CredentialRejected - | RefusalReason::SessionUnbound - | RefusalReason::SchemeNotDeclared => (401, KIND_AUTHENTICATION), - RefusalReason::Revoked | RefusalReason::ScopeMissing | RefusalReason::Vetoed => { - (403, KIND_PERMISSION) + match class_of_refusal(reason) { + RefusalClass::Unauthenticated => (401, KIND_AUTHENTICATION), + RefusalClass::Forbidden => (403, KIND_PERMISSION), + RefusalClass::TooLarge => (413, KIND_REQUEST_TOO_LARGE), + RefusalClass::Throttled | RefusalClass::Busy | RefusalClass::QuotaExhausted => { + (429, KIND_RATE_LIMIT) + } + RefusalClass::Unreadable | RefusalClass::Rejected | RefusalClass::NotFound => { + (400, KIND_INVALID_REQUEST) + } + RefusalClass::Unreachable | RefusalClass::Unavailable | RefusalClass::Timeout => { + (503, KIND_OVERLOADED) } - RefusalReason::BodyTooLarge - | RefusalReason::CursorBudget - | RefusalReason::CredentialBudget => (413, KIND_REQUEST_TOO_LARGE), - RefusalReason::InFlightCap - | RefusalReason::SessionBudget - | RefusalReason::OpenSlotBusy - | RefusalReason::OverBudget - | RefusalReason::GroupFrozen - | RefusalReason::OverdraftCeiling => (429, KIND_RATE_LIMIT), - RefusalReason::NoDestination | RefusalReason::Unpriced => (400, KIND_INVALID_REQUEST), - RefusalReason::DurabilityUnavailable - | RefusalReason::StaleSlice - | RefusalReason::TierMismatch => (503, KIND_OVERLOADED), - // The reasons the kernel could always raise and this dialect had no rendering for. Each - // joins the family it belongs to rather than acquiring a status of its own: a client learns - // the shape of the refusal, never which of the node's ceilings it met. - RefusalReason::ChallengeExhausted => (401, KIND_AUTHENTICATION), - RefusalReason::PoolNotPermitted => (403, KIND_PERMISSION), - RefusalReason::RateLimited | RefusalReason::InFlight => (429, KIND_RATE_LIMIT), - RefusalReason::DecodeFailed - | RefusalReason::NoRate - | RefusalReason::Replayed - | RefusalReason::Superseded => (400, KIND_INVALID_REQUEST), - RefusalReason::SpillBudget - | RefusalReason::ScratchExhausted - | RefusalReason::DestinationBudgetExhausted - | RefusalReason::BreakerOpen - | RefusalReason::DestinationUnreachable - | RefusalReason::Stalled - | RefusalReason::Drain - | RefusalReason::ClientGone - | RefusalReason::DeadlineExceeded => (503, KIND_OVERLOADED), // Node-side faults: the node got something wrong, and says so without saying what. - RefusalReason::MeterDisputed - | RefusalReason::HandoffMismatch - | RefusalReason::PlanePanic - | RefusalReason::TaskLost - | RefusalReason::SecretPlaceholder => (500, KIND_API_ERROR), + RefusalClass::PlaneFault | RefusalClass::NodeFault => (500, KIND_API_ERROR), } } diff --git a/crates/busbar-plane-llm/src/refusal.rs b/crates/busbar-plane-llm/src/refusal.rs index 0f27050021..9d320fa184 100644 --- a/crates/busbar-plane-llm/src/refusal.rs +++ b/crates/busbar-plane-llm/src/refusal.rs @@ -11,16 +11,25 @@ //! | unauthenticated | bedrock | 403 | `AccessDeniedException` | //! | unauthenticated | gemini | 400 | `API_KEY_INVALID` | //! | over budget | bedrock | 400 | `ServiceQuotaExceededException` | -//! | destination unreachable | every dialect | 503 | the overloaded envelope | -//! | group frozen | every dialect | 403 | `permission_error`, "... group 'X' is disabled" | //! | no rate | every dialect | 400 | `invalid_request_error`, "no configured rate for model 'X'" | //! | no destination | every dialect | 404 | `not_found_error`, the dialect's model-not-found sentence | +//! | scheme not declared | every dialect | 400 | none: this plane's status before the one classification | +//! | challenge exhausted | every dialect | 503 | none: this plane's status before the one classification | +//! | cursor budget | every dialect | 503 | none: this plane's status before the one classification | +//! | credential budget | every dialect | 503 | none: this plane's status before the one classification | //! -//! The group-frozen row is pinned from 1.5.5's source (its limit refusal for a disabled group: 403, -//! the permission kind) until the oracle records the cell (ARCHITECT ruling, 2026-09-30). The -//! no-destination row is pinned from 1.5.5's source (a model that names no pool and no configured -//! model answers the dialect's not-found envelope, 404) until the oracle records its cell (ARCHITECT -//! ruling Q-FL3, 2026-10-02). The rest are proved against recorded cells. +//! The no-destination row is pinned from 1.5.5's source (a model that names no pool and no +//! configured model answers the dialect's not-found envelope, 404) until the oracle records its cell +//! (ARCHITECT ruling Q-FL3, 2026-10-02). The rest of the first four are proved against recorded +//! cells. +//! +//! The kernel's default is a status per refusal CLASS (`busbar_contract::abi::plane::RefusalClass`, +//! the one classification; the P-item "refusal-reason collapse"). Two rows this plane used to state +//! are now the class default and are gone: destination unreachable answers 503 (its class, +//! `Unreachable`; proved against the recorded `upstream_down` cells) and a frozen group 403 (its +//! class, `Forbidden`; 1.5.5's limit refusal for a disabled group). The last four rows go the other +//! way: their reasons joined a class whose default differs from what this plane answered, and no +//! 1.5.5 cell records them, so each keeps this plane's answer and no llm byte moves. //! //! A hook's veto is not stated here: the hook's own status rides the refusal. @@ -70,6 +79,14 @@ pub mod reason { pub const NO_RATE: u32 = 17; /// `no_destination`: the arrival's model names no pool and no configured model. pub const NO_DESTINATION: u32 = 19; + /// `scheme_not_declared`: the plane narrowed to an auth scheme its claim never declared. + pub const SCHEME_NOT_DECLARED: u32 = 10; + /// `challenge_exhausted`: a challenge exchange ran past its round or byte bound. + pub const CHALLENGE_EXHAUSTED: u32 = 13; + /// `cursor_budget`: the node-global connection-cursor budget is exhausted. + pub const CURSOR_BUDGET: u32 = 1; + /// `credential_budget`: the per-connection credential slab could not hold the credential. + pub const CREDENTIAL_BUDGET: u32 = 2; } const fn row(dialect: u32, reason: u32, status: u32) -> RefusalStatus { @@ -82,12 +99,14 @@ const fn row(dialect: u32, reason: u32, status: u32) -> RefusalStatus { } /// The plane's refusal statuses, in its statement's order. -pub const REFUSAL_STATUSES: [RefusalStatus; 7] = [ +pub const REFUSAL_STATUSES: [RefusalStatus; 9] = [ row(dialect_index("bedrock"), reason::UNAUTHENTICATED, 403), row(dialect_index("gemini"), reason::UNAUTHENTICATED, 400), row(dialect_index("bedrock"), reason::OVER_BUDGET, 400), - row(REFUSAL_ANY_DIALECT, reason::DESTINATION_UNREACHABLE, 503), - row(REFUSAL_ANY_DIALECT, reason::GROUP_FROZEN, 403), row(REFUSAL_ANY_DIALECT, reason::NO_RATE, 400), row(REFUSAL_ANY_DIALECT, reason::NO_DESTINATION, 404), + row(REFUSAL_ANY_DIALECT, reason::SCHEME_NOT_DECLARED, 400), + row(REFUSAL_ANY_DIALECT, reason::CHALLENGE_EXHAUSTED, 503), + row(REFUSAL_ANY_DIALECT, reason::CURSOR_BUDGET, 503), + row(REFUSAL_ANY_DIALECT, reason::CREDENTIAL_BUDGET, 503), ]; diff --git a/crates/busbar-plane-llm/tests/refusal_statuses.rs b/crates/busbar-plane-llm/tests/refusal_statuses.rs index e3c1cb24ea..28698ab851 100644 --- a/crates/busbar-plane-llm/tests/refusal_statuses.rs +++ b/crates/busbar-plane-llm/tests/refusal_statuses.rs @@ -15,6 +15,10 @@ fn every_stated_reason_code_is_the_reason_it_is_named_for() { (reason::GROUP_FROZEN, "group_frozen"), (reason::NO_RATE, "no_rate"), (reason::NO_DESTINATION, "no_destination"), + (reason::SCHEME_NOT_DECLARED, "scheme_not_declared"), + (reason::CHALLENGE_EXHAUSTED, "challenge_exhausted"), + (reason::CURSOR_BUDGET, "cursor_budget"), + (reason::CREDENTIAL_BUDGET, "credential_budget"), ] { assert_eq!( reason_of(code).map(|r| r.as_str()), diff --git a/crates/busbar-plane-mcp/src/plane.rs b/crates/busbar-plane-mcp/src/plane.rs index a3a70a066a..fb2a8941ab 100644 --- a/crates/busbar-plane-mcp/src/plane.rs +++ b/crates/busbar-plane-mcp/src/plane.rs @@ -12,6 +12,7 @@ //! so every draft below hands the loop a body the kernel does not have to re-walk. The plane once //! handed back an empty table because the arena could not allocate one; it can, and this does. +use busbar_contract::abi::plane::{class_of_refusal, RefusalClass}; use busbar_contract::bounded::{BoundedVec, FactValue, Facts, Ir, ScratchBytes, Span}; use busbar_contract::dest::{DestinationFacts, EgressBody, Leg, RoutePlan, VerifiedDestination}; use busbar_contract::ids::{AdminVerbId, LaneId, SchemeAlt}; @@ -363,78 +364,50 @@ fn bool_literal(raw: &[u8]) -> Option { /// TEXT, which the composition root must compare against the battery's recorded answers on the day /// it switches this plane on. That is stated here rather than left for someone to discover. fn refusal_render(reason: RefusalReason) -> (i64, &'static str) { - // THE MATCH IS TOTAL — there is no `_` arm. Before this, only nine of the 42 reasons were mapped - // and the rest collapsed to `CODE_INTERNAL`, so a rate limit, an open breaker, a drain or a spent - // budget reached the caller as "this node broke" — a node fault a client retries the wrong way. - // This protocol has its own code for a policy refusal (`CODE_REFUSED`), so a busbar admission / - // rate / budget refusal is a policy refusal and says so; only a genuine node fault is internal, - // and each is listed explicitly so a new reason is a compile error, never a silent collapse. - match reason { - RefusalReason::BodyTooLarge => (jsonrpc::CODE_INVALID_REQUEST, "the request is too large"), - RefusalReason::DecodeFailed => ( + // A class-to-wire table over the one classification (`busbar_contract::abi::plane:: + // RefusalClass`; the P-item "refusal-reason collapse"). Which family a reason belongs to is + // decided once, there; this plane says only how each family reads on its wire. The match has no + // `_` arm. Before the exhaustive form only nine reasons were mapped and the rest collapsed to + // `CODE_INTERNAL`, so a rate limit, an open breaker, a drain or a spent budget reached the caller + // as "this node broke". This protocol has its own code for a policy refusal (`CODE_REFUSED`), so + // a busbar admission / rate / budget refusal is a policy refusal and says so; only a genuine node + // fault is internal. + match class_of_refusal(reason) { + RefusalClass::TooLarge => (jsonrpc::CODE_INVALID_REQUEST, "the request is too large"), + RefusalClass::Unreadable => ( jsonrpc::CODE_INVALID_REQUEST, "the request could not be read", ), - RefusalReason::SchemeNotDeclared - | RefusalReason::CredentialRejected - | RefusalReason::SessionUnbound - | RefusalReason::CredentialBudget => ( + RefusalClass::Unauthenticated => ( jsonrpc::CODE_INVALID_REQUEST, "the request did not carry usable authority", ), // The caller is known and may not do this. This protocol has its own code for a policy // refusal, and it is outside the range the specification reserves for itself. - RefusalReason::ScopeMissing - | RefusalReason::Vetoed - | RefusalReason::Revoked - | RefusalReason::PoolNotPermitted => ( + RefusalClass::Forbidden => ( jsonrpc::CODE_REFUSED, "the caller may not perform this operation", ), // There is nowhere for it to go, or the way there is shut, which this protocol names // specifically. - RefusalReason::NoDestination - | RefusalReason::DestinationUnreachable - | RefusalReason::BreakerOpen - | RefusalReason::DestinationBudgetExhausted => ( + RefusalClass::NotFound | RefusalClass::Unreachable => ( jsonrpc::CODE_UPSTREAM_UNAVAILABLE, "no server is reachable for this request", ), // Every busbar-specific admission / capacity / rate / budget / drain refusal. A policy said // no; the caller is told that and nothing about the money, the buckets or the store. - RefusalReason::InFlightCap - | RefusalReason::CursorBudget - | RefusalReason::SessionBudget - | RefusalReason::OpenSlotBusy - | RefusalReason::OverBudget - | RefusalReason::GroupFrozen - | RefusalReason::Unpriced - | RefusalReason::OverdraftCeiling - | RefusalReason::StaleSlice - | RefusalReason::TierMismatch - | RefusalReason::SpillBudget - | RefusalReason::ScratchExhausted - | RefusalReason::RateLimited - | RefusalReason::ChallengeExhausted - | RefusalReason::NoRate - | RefusalReason::Replayed - | RefusalReason::InFlight - | RefusalReason::Drain - | RefusalReason::Superseded - | RefusalReason::ClientGone - | RefusalReason::DeadlineExceeded - | RefusalReason::Stalled => ( + RefusalClass::Rejected + | RefusalClass::Throttled + | RefusalClass::Busy + | RefusalClass::QuotaExhausted + | RefusalClass::Unavailable + | RefusalClass::Timeout => ( jsonrpc::CODE_REFUSED, "the request could not be served at this time", ), - // A genuine node-internal fault — this node did break, and the caller is owed that fact and + // A genuine node-internal fault -- this node did break, and the caller is owed that fact and // not a false policy refusal. - RefusalReason::DurabilityUnavailable - | RefusalReason::MeterDisputed - | RefusalReason::HandoffMismatch - | RefusalReason::PlanePanic - | RefusalReason::TaskLost - | RefusalReason::SecretPlaceholder => ( + RefusalClass::PlaneFault | RefusalClass::NodeFault => ( jsonrpc::CODE_INTERNAL, "the request could not be served at this time", ), diff --git a/crates/busbar-plane-mcp/src/tests/plane.rs b/crates/busbar-plane-mcp/src/tests/plane.rs index 2ca09b5a82..61027dbeb5 100644 --- a/crates/busbar-plane-mcp/src/tests/plane.rs +++ b/crates/busbar-plane-mcp/src/tests/plane.rs @@ -259,3 +259,36 @@ fn the_codec_state_counts() { codec.rounds_asked = codec.rounds_asked.saturating_add(1); assert_eq!((codec.events_read, codec.rounds_asked), (1, 1)); } + +/// P-ITEM: REFUSAL-REASON COLLAPSE (spec DONE item 2, "All P-item behaviours match 1.5.5"; drive +/// log P2, commit 470351a480; TODO L-ENG9). 1.5.5's one surface gave every limit reason its own +/// status and kind and answered none of them as an internal error (v1.5.5 +/// `crates/busbar/src/ingress/mod.rs:237-305`). On this plane: a reason renders as the internal +/// code exactly when its class is a node fault, and every reason of one class renders the same +/// answer, so the reason-to-family decision is the one classification's +/// (`busbar_contract::abi::plane::RefusalCode::class`) and never this plane's own. +#[test] +fn p_item_refusal_reason_collapse_only_a_node_fault_is_internal_and_one_class_one_answer() { + use busbar_contract::abi::plane::{reason_of, RefusalClass, RefusalCode}; + let mut answers: Vec<(RefusalClass, (i64, &'static str))> = Vec::new(); + for code in RefusalCode::ALL { + // Two codes are the kernel's own money verdicts and never reach a plane (`reason_of`). + let Some(reason) = reason_of(code.code()) else { + continue; + }; + let class = code.class(); + let answer = refusal_render(busbar_contract::unit::RefusalReason::from(reason)); + assert_eq!( + answer.0 == crate::jsonrpc::CODE_INTERNAL, + class.is_node_fault(), + "{code:?} (class {class:?}) renders {answer:?}" + ); + match answers.iter().find(|(c, _)| *c == class) { + Some((_, first)) => assert_eq!( + *first, answer, + "{code:?} answers differently from the rest of {class:?}" + ), + None => answers.push((class, answer)), + } + } +} diff --git a/crates/busbar-plane-streaming/src/plane.rs b/crates/busbar-plane-streaming/src/plane.rs index 164257b6a9..c23232ed22 100644 --- a/crates/busbar-plane-streaming/src/plane.rs +++ b/crates/busbar-plane-streaming/src/plane.rs @@ -718,28 +718,40 @@ impl SessionPlane for StreamingPlane { /// `busbar-plane-admin`'s own table): the caller learns the CLASS of refusal and nothing about why /// this node reached it. fn refusal_render(reason: busbar_contract::unit::RefusalReason) -> (&'static str, &'static str) { - use busbar_contract::unit::RefusalReason as R; - match reason { - R::BodyTooLarge | R::DecodeFailed | R::SchemeNotDeclared | R::SecretPlaceholder => { - ("invalid_request", "the request could not be read") - } - R::CredentialRejected | R::SessionUnbound | R::CredentialBudget => { - ("unauthorized", "the session did not carry usable authority") - } - R::ScopeMissing | R::Vetoed | R::Revoked | R::PoolNotPermitted => ( + use busbar_contract::abi::plane::{class_of_refusal, RefusalClass as C}; + // A class-to-wire table over the one classification (the P-item "refusal-reason collapse"). + // There is no `_` arm: this table used to end in one that answered every reason it did not + // name as "internal", so a spent budget, a frozen group, a replayed key or a deadline told the + // caller the node had broken, and the caller retried the wrong thing. Only a node fault is + // "internal" now; every other class is a refusal the caller is told the class of. + match class_of_refusal(reason) { + C::Unreadable | C::TooLarge => ("invalid_request", "the request could not be read"), + C::Rejected => ( + "invalid_request", + "the session could not be opened at this time", + ), + C::Unauthenticated => ("unauthorized", "the session did not carry usable authority"), + C::Forbidden => ( "forbidden", "the caller may not open a session for this operation", ), - R::RateLimited | R::InFlightCap | R::OpenSlotBusy | R::SessionBudget => { - ("rate_limited", "too many sessions at once") - } - R::NoDestination | R::DestinationUnreachable | R::BreakerOpen | R::Drain => ( + C::Throttled | C::Busy => ("rate_limited", "too many sessions at once"), + C::QuotaExhausted => ( + "rate_limited", + "the session could not be opened at this time", + ), + C::NotFound | C::Unreachable => ( "unavailable", "no provider is reachable for this session right now", ), - // Everything else is this node saying no for a reason that is this node's own — the money, - // the buckets, the journal. A caller is told it failed here and nothing more. - _ => ("internal", "the session could not be opened at this time"), + C::Unavailable | C::Timeout => ( + "unavailable", + "the session could not be opened at this time", + ), + // The node's own fault. A caller is told it failed here and nothing more. + C::PlaneFault | C::NodeFault => { + ("internal", "the session could not be opened at this time") + } } } diff --git a/crates/busbar-plane-streaming/src/tests/codec.rs b/crates/busbar-plane-streaming/src/tests/codec.rs index 348b75e680..b14edb83fb 100644 --- a/crates/busbar-plane-streaming/src/tests/codec.rs +++ b/crates/busbar-plane-streaming/src/tests/codec.rs @@ -1275,6 +1275,69 @@ fn a_refusal_renders_an_opaque_code_not_the_internal_reason() { } } +/// P-ITEM: REFUSAL-REASON COLLAPSE (spec DONE item 2, "All P-item behaviours match 1.5.5"; TODO +/// L-ENG9). This plane's refusal table ended in `_ => "internal"`, so a spent budget, a frozen +/// group, a replayed key, a superseded unit or a deadline told the caller the node had broken. +/// 1.5.5's one surface answered none of its limit reasons as an internal error (v1.5.5 +/// `crates/busbar/src/ingress/mod.rs:237-305`). Pinned through the plane's own `encode_refusal` +/// bytes: a refusal reads `internal` exactly when its class is a node fault, and every reason of +/// one class reads the same, so the family is the one classification's +/// (`busbar_contract::abi::plane::RefusalCode::class`) and never this plane's own. +#[test] +fn p_item_refusal_reason_collapse_only_a_node_fault_is_internal_and_one_class_one_answer() { + use busbar_contract::abi::plane::{reason_of, RefusalClass, RefusalCode}; + use busbar_contract::unit::{Refusal, RefusalReason, Step}; + + let plane = openai_plane(); + let arena = LeakPlaneAlloc; + let config = EmptyConfig; + let transport = WsStack::new("/v1/realtime"); + let labels = Labels::new(); + let c = ctx(&arena, &config, &transport, &labels); + let mut answers: Vec<(RefusalClass, (String, String))> = Vec::new(); + for code in RefusalCode::ALL { + // Two codes are the kernel's own money verdicts and never reach a plane (`reason_of`). + let Some(reason) = reason_of(code.code()) else { + continue; + }; + let class = code.class(); + let refusal = Refusal { + step: Step::Decode, + reason: RefusalReason::from(reason), + retry_after_secs: None, + stream: None, + correlates: None, + }; + let bytes = plane + .encode_refusal(&refusal, None, None, &c) + .expect("a refusal renders"); + let parsed: serde_json::Value = + serde_json::from_slice(bytes.as_slice()).expect("the refusal is this dialect's JSON"); + let answer = ( + parsed["error"]["code"] + .as_str() + .unwrap_or_default() + .to_string(), + parsed["error"]["message"] + .as_str() + .unwrap_or_default() + .to_string(), + ); + assert_eq!( + answer.0 == "internal", + class.is_node_fault(), + "{code:?} (class {class:?}) renders {answer:?}" + ); + match answers.iter().find(|(c, _)| *c == class) { + Some((_, first)) => assert_eq!( + *first, answer, + "{code:?} answers differently from the rest of {class:?}" + ), + None => answers.push((class, answer)), + } + } +} + /// A barge-in on an OPEN turn opens the turn that takes over. /// /// The scheduler reads the interrupt fact off an open and nowhere else — that is the one dispatch diff --git a/crates/busbar/src/root/units_admin/admin_mount.rs b/crates/busbar/src/root/units_admin/admin_mount.rs index f65aeb047b..fac0ec6d87 100644 --- a/crates/busbar/src/root/units_admin/admin_mount.rs +++ b/crates/busbar/src/root/units_admin/admin_mount.rs @@ -307,23 +307,34 @@ pub(crate) fn door_answer() -> AdminAnswer { /// /// The vocabulary is the previous release's admin envelope and nothing here invents a status: each /// arm is a reason the loop can end on paired with the status that release already gave the same -/// condition. `forbidden` stays the answer for the two authorization endings AND for an ending this -/// table does not name, so an ending nobody has mapped cannot quietly become a new status on a +/// condition. `forbidden` stays the answer for the authorization endings and for every class the +/// previous release gave no status of its own, so no ending can quietly become a new status on a /// surface a caller has pinned. #[cfg(feature = "root-admin")] pub(crate) fn answer_for(outcome: Outcome) -> AdminAnswer { + use busbar_contract::abi::plane::{class_of, RefusalClass}; let (status, code) = match outcome { - Outcome::Refused(_, reason) | Outcome::Failed(_, reason) => match reason { + // A class-to-wire table over the one classification + // (`busbar_contract::abi::plane::RefusalClass`; the P-item "refusal-reason collapse"). + Outcome::Refused(_, reason) | Outcome::Failed(_, reason) => match class_of(reason) { // A body or a verb the plane could not read is a bad request, not a denied one. - ReasonCode::DecodeFailed => (400, "invalid_request"), + RefusalClass::Unreadable => (400, "invalid_request"), // Nothing on this surface answers that method and path. - ReasonCode::NoDestination => (404, "not_found"), + RefusalClass::NotFound => (404, "not_found"), // The caller is inside its rights and the node is over a limit. - ReasonCode::OverBudget | ReasonCode::InFlightCap => (429, "rate_limited"), + RefusalClass::QuotaExhausted | RefusalClass::Busy => (429, "rate_limited"), // The node cannot record what the operation would do, so it does not do it. An // administrative write that cannot be journalled is unavailability, not refusal. - ReasonCode::DurabilityUnavailable | ReasonCode::StaleSlice => (503, "unavailable"), - _ => (403, "forbidden"), + RefusalClass::Unavailable => (503, "unavailable"), + RefusalClass::Rejected + | RefusalClass::Unauthenticated + | RefusalClass::Forbidden + | RefusalClass::TooLarge + | RefusalClass::Throttled + | RefusalClass::Unreachable + | RefusalClass::Timeout + | RefusalClass::PlaneFault + | RefusalClass::NodeFault => (403, "forbidden"), }, _ => (403, "forbidden"), }; diff --git a/xtask/tests/p_item_refusal_reason_collapse.rs b/xtask/tests/p_item_refusal_reason_collapse.rs new file mode 100644 index 0000000000..9b2d4bdd42 --- /dev/null +++ b/xtask/tests/p_item_refusal_reason_collapse.rs @@ -0,0 +1,263 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! P-ITEM: REFUSAL-REASON COLLAPSE, the source half (spec DONE item 2, "All P-item behaviours match +//! 1.5.5"; the drive log's P1/P2, commit 470351a480; TODO L-ENG9). +//! +//! Eight hand-copied reason-to-family matches had drifted apart, and one still answered every +//! reason it did not name as an internal error. 1.5.5 answered each limit reason with its own +//! status and kind and none as a 500 (v1.5.5 `crates/busbar/src/ingress/mod.rs:237-305`). The fix +//! is ONE classification, `RefusalCode::class` in `busbar-contract`'s plane ABI, with every +//! renderer a class-to-wire table; `crates/busbar-contract/tests/p_item_refusal_reason_collapse.rs` +//! pins the classification itself. +//! +//! This file reads the renderers' source and proves none of them holds a reason match of its own, +//! so no renderer can drift from the others again. It lives here, beside the gates, because it +//! names every plane's renderer and the tree's crates may not name one another's planes. + +use std::path::{Path, PathBuf}; + +/// The renderers, by file and function: each turns a reason into wire bytes, and each must ask the +/// one classification rather than decide for itself. +const RENDERERS: &[(&str, &[&str])] = &[ + ( + "crates/busbar-kernel/src/plane_driver/mod.rs", + &["refusal_status", "class_status", "is_authentication"], + ), + ( + "crates/busbar-plane-llm/src/exchange/refuse.rs", + &["kind_of", "kind_of_status", "is_authentication"], + ), + ( + "crates/busbar-plane-llm/src/plane.rs", + &["refusal_shape", "refusal_message"], + ), + ("crates/busbar-plane-a2a/src/plane.rs", &["refusal_render"]), + ("crates/busbar-plane-mcp/src/plane.rs", &["refusal_render"]), + ( + "crates/busbar-plane-decisions/src/plane.rs", + &["refusal_render"], + ), + ( + "crates/busbar-plane-streaming/src/plane.rs", + &["refusal_render"], + ), + ( + "crates/busbar/src/root/units_admin/admin_mount.rs", + &["answer_for"], + ), +]; + +/// The renderers that used to match a reason by its spelling rather than its variant. +const SPELLED: &[&str] = &["crates/busbar-plane-llm/src/exchange/refuse.rs"]; + +/// Where the one classification lives; its match must name every reason. +const CLASSIFIER: (&str, &str) = ("crates/busbar-contract/src/abi/plane/mod.rs", "class"); + +/// The reason vocabulary's one written table: `Kernel => "spelling", Contract,`. +const VOCABULARY: &str = "crates/busbar-contract/src/caps/seat_verdict.rs"; + +fn root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("..") +} + +fn read(file: &str) -> String { + std::fs::read_to_string(root().join(file)).unwrap_or_else(|e| panic!("{file} is readable: {e}")) +} + +/// One reason: its kernel name, its spelling, its contract name. +struct Reason { + kernel: String, + spelling: String, + contract: String, +} + +/// Every reason, read off the `reasons!` table. +fn vocabulary() -> Vec { + let src = read(VOCABULARY); + let table = &src[src.find("reasons! {").expect("the reasons! table")..]; + let table = &table[..table.find("\n}\n").expect("the table's end")]; + let reasons: Vec = table + .lines() + .filter_map(|line| { + let line = line.trim(); + let (kernel, rest) = line.split_once(" => \"")?; + let (spelling, rest) = rest.split_once("\", ")?; + let contract = rest.strip_suffix(',')?; + Some(Reason { + kernel: kernel.to_string(), + spelling: spelling.to_string(), + contract: contract.to_string(), + }) + }) + .collect(); + assert!( + reasons.len() >= 42, + "the reasons! table read as {} reasons", + reasons.len() + ); + reasons +} + +/// The body of `fn name` in `src`, braces matched; `None` when the file declares no such function. +fn body_of<'a>(src: &'a str, name: &str) -> Option<&'a str> { + let start = [format!("fn {name}("), format!("fn {name}<")] + .iter() + .find_map(|head| src.find(head.as_str()))?; + let open = start + src[start..].find('{')?; + let mut depth = 0usize; + for (i, c) in src[open..].char_indices() { + match c { + '{' => depth += 1, + '}' => { + depth -= 1; + if depth == 0 { + return Some(&src[open..=open + i]); + } + } + _ => {} + } + } + None +} + +/// Every reason variant as a match arm names it: under each of the three enums that spell the +/// vocabulary, or the short alias a renderer used for one of them. +fn variant_paths(reasons: &[Reason]) -> Vec { + let mut out = Vec::new(); + for r in reasons { + for (path, name) in [ + ("ReasonCode", &r.kernel), + ("RefusalCode", &r.kernel), + ("RefusalReason", &r.contract), + ("R", &r.contract), + ("R", &r.kernel), + ] { + out.push(format!("{path}::{name}")); + } + } + out.sort(); + out.dedup(); + out +} + +/// Whether `body` names `path` as a whole path (not as the prefix of a longer name). +fn names(body: &str, path: &str) -> bool { + body.match_indices(path).any(|(i, _)| { + let before = body[..i].chars().next_back(); + let after = body[i + path.len()..].chars().next(); + !before.is_some_and(|c| c.is_alphanumeric() || c == '_') + && !after.is_some_and(|c| c.is_alphanumeric() || c == '_') + }) +} + +/// What `RENDERERS` finds wrong in `read_file`'s view of the tree. +fn findings(reasons: &[Reason], read_file: &dyn Fn(&str) -> String) -> Vec { + let paths = variant_paths(reasons); + let mut found = Vec::new(); + for (file, fns) in RENDERERS { + let src = read_file(file); + for name in *fns { + let Some(body) = body_of(&src, name) else { + found.push(format!("{file} declares no fn {name}")); + continue; + }; + for p in &paths { + if names(body, p) { + found.push(format!("{file} fn {name} names the reason {p}")); + } + } + if SPELLED.contains(file) { + for r in reasons { + if body.contains(&format!("\"{}\"", r.spelling)) { + found.push(format!( + "{file} fn {name} names the reason by its spelling {}", + r.spelling + )); + } + } + } + } + } + found +} + +/// THE SOURCE SCAN: `RefusalCode::class` is the only reason-to-family match. No renderer names a +/// reason variant, and none of the spelled ones names a reason by its spelling, so a renderer can +/// only say how a family reads on its own wire. +#[test] +fn p_item_refusal_reason_collapse_class_is_the_only_reason_match() { + let reasons = vocabulary(); + let found = findings(&reasons, &|f| read(f)); + assert!(found.is_empty(), "{}", found.join("\n")); +} + +/// The one classification names every reason, so the scan above is not passing because nothing +/// classifies at all. +#[test] +fn p_item_refusal_reason_collapse_the_classifier_names_every_reason() { + let reasons = vocabulary(); + let src = read(CLASSIFIER.0); + let body = body_of(&src, CLASSIFIER.1).expect("RefusalCode::class"); + for r in &reasons { + assert!( + names(body, &format!("RefusalCode::{}", r.kernel)), + "the classifier has no arm for {}", + r.kernel + ); + } +} + +/// The scan bites: a renderer put back the old way (a reason match ending in a catch-all) is +/// caught, and a class arm whose name a reason shares is not mistaken for one. +#[test] +fn p_item_refusal_reason_collapse_the_scan_catches_a_reason_match() { + let reasons = vocabulary(); + let old = r#"fn refusal_render(reason: busbar_contract::unit::RefusalReason) -> (&'static str, &'static str) { + use busbar_contract::unit::RefusalReason as R; + match reason { + R::RateLimited | R::InFlightCap => ("rate_limited", "too many"), + _ => ("internal", "no"), + } +} +"#; + let planted = findings(&reasons, &|f| { + if f == "crates/busbar-plane-streaming/src/plane.rs" { + old.to_string() + } else { + read(f) + } + }); + assert_eq!( + planted, + [ + "crates/busbar-plane-streaming/src/plane.rs fn refusal_render names the reason R::InFlightCap", + "crates/busbar-plane-streaming/src/plane.rs fn refusal_render names the reason R::RateLimited", + ] + ); + let spelled = r#"fn kind_of(reason: &str, status: u16) -> &'static str { + match reason { "over_budget" => "q", _ => "x" } +} +fn kind_of_status(status: u16) -> &'static str { "x" } +fn is_authentication(reason: &str) -> bool { false } +"#; + let planted = findings(&reasons, &|f| { + if f == SPELLED[0] { + spelled.to_string() + } else { + read(f) + } + }); + assert_eq!( + planted, + [format!( + "{} fn kind_of names the reason by its spelling over_budget", + SPELLED[0] + )] + ); + assert!(!names( + "RefusalClass::Unauthenticated =>", + "R::Unauthenticated" + )); + assert!(!names("ReasonCode::OverBudgetX", "ReasonCode::OverBudget")); +}