From 29d8132fc0d4fdedcd7bd1af3d36460de8586e56 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:02:14 -0700 Subject: [PATCH 01/59] plane/approvals: delete the kernel's dead copy of the plane's ask-state seal (K2-H3) The caller-facing ask-state seal is the plane's own: its crate seals the payload over the host's `sign` (ARCHITECT Q-L3B-ASK). The kernel still carried a dead copy, with AskState, Sealer, Rejected, digest_arguments and the two MAC domains, spelled with the plane token escaped. Nothing called it. Core keeps the spent-approval ledger, DEFAULT_TTL_SECS and nonce. The copy's tests (askstate_tests.rs) go with it, and the signing_secret docs no longer name the deleted sealer. RED: approvals_tests the_kernel_holds_no_plane_named_seal_domain scans the kernel's sources for the escaped plane token and the seal's domain segment. --- crates/busbar-kernel/src/governance/state.rs | 13 +- crates/busbar-kernel/src/plane/approvals.rs | 349 +----------------- .../src/plane/tests/approvals_tests.rs | 55 +++ .../src/plane/tests/askstate_tests.rs | 322 ---------------- 4 files changed, 74 insertions(+), 665 deletions(-) create mode 100644 crates/busbar-kernel/src/plane/tests/approvals_tests.rs delete mode 100644 crates/busbar-kernel/src/plane/tests/askstate_tests.rs diff --git a/crates/busbar-kernel/src/governance/state.rs b/crates/busbar-kernel/src/governance/state.rs index 4398ffb482..58bffd5a39 100644 --- a/crates/busbar-kernel/src/governance/state.rs +++ b/crates/busbar-kernel/src/governance/state.rs @@ -480,17 +480,16 @@ impl GovState { /// is read. /// /// Deliberately returns the secret rather than a ready-made HMAC, so this module keeps knowing - /// nothing about what any consumer seals: a plane's ask-state codec - /// ([`crate::plane::approvals::Sealer::derive`]) does its own domain-separated derivation, which is - /// what keeps its blobs and this module's virtual-key tokens unable to verify as one another. - /// Secret-equivalent: never log it, never put it in a `Debug`. + /// nothing about what any consumer seals: a consumer does its own domain-separated derivation, + /// which is what keeps its blobs and this module's virtual-key tokens unable to verify as one + /// another. Secret-equivalent: never log it, never put it in a `Debug`. /// /// The FLEET-SHARED property is the reason this is usable for a state seal at all. One logical /// caller-facing exchange spans several independent requests, which different nodes may serve; /// a per-process key would make the second request fail on whichever node did not mint the first. - // Its consumer is the ask-state sealer (`crate::plane::approvals::Sealer`), driven by whichever - // plane seals caller-facing state; with no such plane installed it has no caller. Unconditional - // allow — the neutral seam names no plane feature. + // No in-core consumer: a plane seals its caller-facing state over the host's `sign` (ARCHITECT + // Q-L3B-ASK; the in-core ask-state sealer is deleted, audit K2-H3). Unconditional allow — the + // neutral seam names no plane feature. #[allow(dead_code)] pub fn signing_secret(&self) -> Option<[u8; 32]> { self.signing_material().map(|m| m.signer.secret_bytes()) diff --git a/crates/busbar-kernel/src/plane/approvals.rs b/crates/busbar-kernel/src/plane/approvals.rs index 129f47ea34..5b80f9b78b 100644 --- a/crates/busbar-kernel/src/plane/approvals.rs +++ b/crates/busbar-kernel/src/plane/approvals.rs @@ -1,82 +1,21 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright (C) 2026 Busbar Inc and contributors -//! THE SEALED `requestState` — minted by busbar, verified by busbar, and bound to the caller. +//! THE HOST'S HALF OF A PLANE'S CALLER-FACING APPROVAL: the spent-approval ledger that makes an +//! approval single-use, the short replay window a sealed approval lives for, and the nonce a plane +//! seals into one. //! -//! ## Why busbar MINTS state rather than relaying an upstream's -//! -//! `mrtr.mdx:232` makes it a `MUST` that a server reject `requestState` that fails verification, and -//! `mrtr.mdx:130` says the value is "meaningful only to the server" — a client "MUST NOT inspect, -//! parse, modify, or make any assumptions about its contents". Those two sentences together mean a -//! relayer cannot be conformant: an upstream's state is opaque to busbar by construction, so busbar -//! could only forward it blind, and the verification would then happen one hop away, at an upstream, -//! on behalf of a caller that upstream never authenticated. -//! -//! That is not merely inelegant. `mrtr.mdx:235` requires the state to bind **the authenticated -//! principal**, "rejecting state presented by a different principal" — and the only principal an -//! upstream can see is BUSBAR, identically for every one of busbar's callers. Relayed state -//! therefore binds to busbar rather than to the caller, so caller A's state is redeemable by caller -//! B and the upstream cannot tell the difference. Minting closes that by construction: the principal -//! in the sealed payload is the inbound key, so the cross-caller replay is a signature failure. -//! -//! ## What is sealed, and why each field is in there -//! -//! Every element `mrtr.mdx:234-237` lists as a `SHOULD`, plus two busbar can offer and the spec does -//! not ask for: -//! -//! | field | why | -//! |---|---| -//! | `principal` | `mrtr.mdx:235`. Caller A's state cannot be redeemed by caller B. | -//! | `method` + `capability` | `mrtr.mdx:237` — "an identifier for the originating request". State minted on `prompts/get` cannot be spent on `tools/call`, nor one tool's on another's. | -//! | `args_digest` | the rest of `mrtr.mdx:237` — "a digest of its salient parameters". | -//! | `issued_at` + `ttl` | `mrtr.mdx:236`, the short expiry. | -//! | `nonce` | two mints of an identical payload MUST differ, or the `multi-round` scenario's `r2.requestState !== r1.requestState` can never hold. A deterministic seal fails it. | -//! | `round` | THE LOOP BOUND. See below — this is the field without which the caller-facing loop has no cap at all. | -//! | `generation` | the catalogue generation the ask was minted under. State minted before an approval was revoked is refused after it — which the spec does not require and busbar can give for free, because dispatch already re-validates against a generation. | -//! -//! ## The round index is the bound, and it has to live IN the seal -//! -//! The upstream-facing loop ([`super::inputreq`]) is in-process, so its counter is a local variable -//! and the bound is per dispatch by construction. The caller-facing loop is not: it is spread across -//! INDEPENDENT requests over a stateless transport, with no session, because this revision has none. -//! A counter held in -//! memory between them would be a session by another name — the same objection -//! `client/jsonrpc.rs` already makes about `InputRequiredLoop`. So the count rides inside the -//! integrity-protected payload, where the caller can neither read it nor rewind it. Without that, -//! the cap is unenforceable: a caller replays round-1 state for ever and every replay is a fresh, -//! uncapped round. -//! -//! ## Integrity -//! -//! HMAC-SHA256 under a key DERIVED from `auth.signing_key` — the deployment's fleet-shared secret, -//! which is what lets one logical exchange span requests that different nodes may serve. Derived -//! rather than used raw, so this seal and the virtual-key token signer can never be confused for one -//! another even if one of them is later changed. Verification is `Mac::verify_slice`, which is -//! constant-time; the decode is strict base64url with no padding, because a decoder that tolerated -//! trailing bytes would accept the conformance suite's `+ "-TAMPERED"` mutation and the scenario -//! would go green while the property it names was absent. -//! -//! No signing key ⇒ no sealer ⇒ NO ASK. That is the fail-closed posture and it is deliberate: an ask -//! busbar cannot seal is an ask busbar cannot verify the answer to, and emitting one would be -//! inviting a retry it would have to trust. - -// D3 Phase-C: the ask-state seal PODs + crypto (`AskState`, `Rejected`, `Sealer`, mint/open, and -// the `DERIVE_DOMAIN`/`MAC_DOMAIN` and the ring HMAC-SHA256 they need) now live in the neutral -// substrate so a plane holds the seal without naming core. Re-exported here so every in-core call -// site (the sibling-crate caller that decides an ask, the tests) is unchanged. -// -// The neutral ask-state helpers `DEFAULT_TTL_SECS` (the short replay window) and `digest_arguments` -// (the salient-parameter digest) relocated to the substrate seal beside the PODs — pure `mrtr` data + -// SHA-256/`hex`, no core reach — and are re-exported here so `crate::plane::approvals::{DEFAULT_TTL_SECS, -// digest_arguments}` still resolves for the tests and for that sibling-crate caller. +//! The seal itself — the sealed payload, its MAC domains, mint/open and the request match — is the +//! plane's own, in the plane's crate, sealed over the host's `sign` (ARCHITECT Q-L3B-ASK). Core +//! holds no plane-named seal domain and no copy of the payload (audit K2-H3). /// THE SPENT-APPROVAL LEDGER — what makes an approval SINGLE-USE. /// /// ## What the seal could not do on its own /// -/// Everything else about this module is a statement the seal itself can carry: who it was minted -/// for, what request, which round, until when. Single use is the one property that cannot ride -/// inside the blob, because a caller presenting the identical blob a second time presents an +/// Everything else about an approval is a statement the plane's seal itself can carry: who it was +/// minted for, what request, which round, until when. Single use is the one property that cannot +/// ride inside the blob, because a caller presenting the identical blob a second time presents an /// identical, perfectly valid blob. The only thing that can tell the second presentation from the /// first is a RECORD THAT THE FIRST HAPPENED — and until this existed there was none, so an operator /// who gated a money-moving action behind a confirmation got confirm-once-execute-many. @@ -214,289 +153,27 @@ impl SpentTokenLedger { } } -#[cfg(test)] -#[path = "tests/askstate_tests.rs"] -mod askstate_tests; - // ONE APPROVAL, REDEEMED ONCE — across a restart and across a fleet. Judged through the served // door at the composition root (`crates/busbar/src/root/tests/door_steps.rs`, `spent_ledger`; // ARCHITECT Q-L3B-ASK): the plane door mints and redeems the sealed state over the host's `sign` // and spends it by the host's one-time `records.claim`, and only the root composes a door's // served path (P3 DEL-MCP). -// ==== merged from busbar-substrate (W4.b P2 engine drain) ==== -use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use base64::Engine as _; -use ring::hmac; -use serde::{Deserialize, Serialize}; - /// The DEFAULT life of a sealed state. Short, per `mrtr.mdx:236`: a caller answering an elicitation /// is a human at a prompt, not a batch job, and every second of validity is a second of replay /// window. pub const DEFAULT_TTL_SECS: u64 = 300; -/// The digest of a request's salient parameters, per `mrtr.mdx:237`. -/// -/// Over the SERIALISED value rather than a field walk: the point is that the retry asks for the same -/// thing the ask was issued about, and any field of `arguments` can change what is being asked for. -/// `serde_json::Value`'s object representation is a `BTreeMap`, so the serialisation is key-ordered -/// and two equal values digest equally regardless of the order the caller sent them in. -pub fn digest_arguments(arguments: &serde_json::Value) -> String { - let bytes = serde_json::to_vec(arguments).unwrap_or_default(); - busbar_kernel_ledger::digest::sha256_hex(&bytes) -} - /// A fresh nonce. `getrandom` is the same fail-closed entropy source key secrets use; a failure is /// not survivable here, because a predictable nonce is a `multi-round` scenario that passes by /// accident and a replay window that is wider than it looks. /// /// The draw itself is governance's one 128-bit hex draw (`governance::generate_binding_generation`); -/// it is named here beside the seal so a plane mints a nonce through `plane::approvals::nonce`. +/// it is named here beside the ledger so a plane mints a nonce through `plane::approvals::nonce`. pub fn nonce() -> Result { crate::governance::generate_binding_generation() } -/// Domain separation for the derived key. Changing these BYTES invalidates every outstanding state, -/// which is the correct behaviour for a payload-format change — so the bytes are frozen. The plane -/// segment is written `\x6d` ('m') rather than the literal token: this is the ask-state plane's -/// domain, whose true home is the plane crate, reached there through the sealer seam. Until that -/// cross-crate move the neutral substrate carries the exact bytes without -/// spelling the plane in its source. -const DERIVE_DOMAIN: &[u8] = b"busbar/\x6dcp/askstate/derive/v1"; - -/// Domain separation for the MAC itself, prepended to the signed bytes. Belt and braces beside the -/// key derivation: even a deployment that somehow reused the raw key elsewhere cannot have one of -/// its blobs verify here. Same frozen bytes and same `\x6d` plane-token neutralisation as -/// [`DERIVE_DOMAIN`]. -const MAC_DOMAIN: &[u8] = b"busbar/\x6dcp/askstate/v1\0"; - -/// The sealed payload. Field names are short because this rides in a JSON body on every retry, and -/// the wire form is an implementation detail no client may parse (`mrtr.mdx:130`). -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct AskState { - /// The AUTHENTICATED PRINCIPAL — the inbound key's stable id. `mrtr.mdx:235`. - #[serde(rename = "p")] - pub principal: String, - /// The JSON-RPC method the ask was issued on: `tools/call` or `prompts/get`. - #[serde(rename = "m")] - pub method: String, - /// The NAMESPACED capability — `{server}_{tool}`, or the namespaced prompt name. - #[serde(rename = "c")] - pub capability: String, - /// A digest of the request's salient parameters. `mrtr.mdx:237`. - #[serde(rename = "d")] - pub args_digest: String, - /// The catalogue generation the ask was minted under. - #[serde(rename = "g")] - pub generation: u64, - /// Which round of the caller-facing loop this state ENDS. The retry that presents it is round - /// `round + 1`, and that is what the per-server cap is compared against. - #[serde(rename = "r")] - pub round: u32, - /// Fresh randomness, so two mints of an otherwise identical payload differ. - #[serde(rename = "n")] - pub nonce: String, - /// Unix seconds at mint. - #[serde(rename = "i")] - pub issued_at: u64, - /// Seconds of validity from `issued_at`. - #[serde(rename = "t")] - pub ttl_secs: u64, - /// The principal's ROOTS EPOCH at mint — present exactly when the exchange this state - /// continues includes a `roots/list` ask, absent otherwise so unrelated confirmations cannot - /// be invalidated by a roots announcement. See `crate::mcp::roots` for the mechanism and - /// [`Rejected::StaleRoots`] for the refusal a mismatch produces. - #[serde(rename = "e", default, skip_serializing_if = "Option::is_none")] - pub roots_epoch: Option, -} - -/// Why a presented `requestState` was refused. Every arm is a REFUSAL — there is no arm that means -/// "accept anyway" and none that means "re-prompt". `mrtr.mdx:232` and the conformance suite's -/// `tampered-state` comment agree on that: a complete result OR a fresh ask in answer to tampered -/// state both mean the server did not reject it. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Rejected { - /// Not two base64url segments, or the payload is not the JSON this module writes. - Malformed, - /// The MAC did not verify. THE TAMPER ARM. - BadSignature, - /// Past `issued_at + ttl_secs`. - Expired, - /// Sealed for a different authenticated principal. The cross-caller replay `mrtr.mdx:235` names. - WrongPrincipal, - /// Sealed for a different method, capability, or argument set. - WrongRequest, - /// Sealed under a catalogue generation that is no longer live — an approval moved underneath it. - WrongGeneration, - /// ALREADY REDEEMED. Perfectly valid, for this caller, for this request, inside its window — and - /// already spent on the call it was minted to approve. See `SpentTokenLedger`. - AlreadySpent, - /// SEALED UNDER A ROOTS EPOCH THE CALLER ITSELF HAS SINCE MOVED. The state carried a roots - /// answer, and the caller sent `notifications/roots/list_changed` after it was minted — so - /// redeeming it would dispatch on roots the caller just disavowed. See `crate::mcp::roots`. - StaleRoots, -} - -impl Rejected { - /// The stable audit reason word. Named here so a new arm cannot land without one. - pub fn audit_reason(self) -> &'static str { - match self { - Rejected::Malformed => "state_malformed", - Rejected::BadSignature => "state_bad_signature", - Rejected::Expired => "state_expired", - Rejected::WrongPrincipal => "state_wrong_principal", - Rejected::WrongRequest => "state_wrong_request", - Rejected::WrongGeneration => "state_wrong_generation", - Rejected::AlreadySpent => "state_already_spent", - Rejected::StaleRoots => "state_stale_roots", - } - } -} - -impl std::fmt::Display for Rejected { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - // ONE message for every arm that a FORGER can reach, deliberately. The distinctions above - // exist for the audit row; telling a caller WHICH check its forged state failed is an - // oracle for forging a better one, and `mrtr.mdx:269-272` names the caller as the attacker - // for exactly this field. - // - // `StaleRoots` is the one deliberate exception, and the oracle argument is why it is safe: - // the epoch is compared only AFTER the MAC verified, so the only party that can ever read - // this message is the caller busbar genuinely minted the state for — and the trigger was - // that caller's own `notifications/roots/list_changed`. It learns nothing it did not - // announce itself, and it needs the distinct remedy: restart the exchange, answer with - // current roots. The unified message's remedy (there is none) would be wrong. - match self { - Rejected::StaleRoots => f.write_str( - "`requestState` was minted before you sent `notifications/roots/list_changed`, and \ - the exchange it continues includes a `roots/list` answer — which your notification \ - declared stale. Retry the request without `requestState` to restart the exchange \ - and answer with your current roots.", - ), - _ => f.write_str( - "`requestState` failed integrity verification and was refused. It is an opaque \ - value minted by this server for one caller, one request and a short window; it \ - cannot be modified, reused by another caller, replayed after it lapses, or \ - presented on a different request.", - ), - } - } -} - -/// THE SEAL. Holds a key derived from the deployment's signing key; its `Debug` never prints it. -#[derive(Clone)] -pub struct Sealer { - key: [u8; 32], -} - -impl std::fmt::Debug for Sealer { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Sealer") - .field("key", &"") - .finish() - } -} - -impl Sealer { - /// Derive the ask-state key from the deployment's `auth.signing_key` secret. - /// - /// A DERIVATION rather than the raw bytes: the same secret is the ed25519 virtual-key signer, - /// and two unrelated uses of one secret should not be able to produce a blob the other accepts. - pub fn derive(signing_secret: &[u8; 32]) -> Self { - let out = hmac::sign( - &hmac::Key::new(hmac::HMAC_SHA256, signing_secret), - DERIVE_DOMAIN, - ); - let out = out.as_ref(); - let mut key = [0u8; 32]; - key.copy_from_slice(&out[..32]); - Self { key } - } - - /// MINT a sealed state for `state`. The returned string is what goes on the wire as - /// `requestState`, and it is opaque to every party but this one. - pub fn mint(&self, state: &AskState) -> String { - let payload = serde_json::to_vec(state).expect("AskState serialises"); - let encoded = URL_SAFE_NO_PAD.encode(&payload); - let tag = self.tag(encoded.as_bytes()); - format!("{encoded}.{}", URL_SAFE_NO_PAD.encode(tag)) - } - - /// OPEN a presented state: decode, verify the MAC, check the expiry. Nothing here consults the - /// request — that is [`AskState::matches`], kept separate so the crypto has no way to be made - /// context-dependent by a later edit. - pub fn open(&self, blob: &str, now: u64) -> Result { - let (encoded, sig) = blob.split_once('.').ok_or(Rejected::Malformed)?; - // STRICT decode. `-` and `_` are base64url characters, so the conformance suite's - // `+ "-TAMPERED"` decodes rather than erroring — and lands on a tag of the wrong length and - // the wrong bytes, which is why the length check and the MAC check are both here and why - // neither may be relaxed into "starts with". - let tag = URL_SAFE_NO_PAD - .decode(sig.as_bytes()) - .map_err(|_| Rejected::Malformed)?; - let mut signed = Vec::with_capacity(MAC_DOMAIN.len() + encoded.len()); - signed.extend_from_slice(MAC_DOMAIN); - signed.extend_from_slice(encoded.as_bytes()); - // CONSTANT TIME, and it also rejects a tag of the wrong length rather than comparing a - // prefix. A `==` on `Vec` would be neither. - hmac::verify(&self.mac_key(), &signed, &tag).map_err(|_| Rejected::BadSignature)?; - - let payload = URL_SAFE_NO_PAD - .decode(encoded.as_bytes()) - .map_err(|_| Rejected::Malformed)?; - let state: AskState = serde_json::from_slice(&payload).map_err(|_| Rejected::Malformed)?; - // The expiry is checked here rather than in `matches` because it is a property of the state - // alone, and because a state that has lapsed must be refused even by a caller that gets - // every other field right. - if now > state.issued_at.saturating_add(state.ttl_secs) { - return Err(Rejected::Expired); - } - Ok(state) - } - - /// The MAC over the domain tag and the encoded payload. - fn tag(&self, encoded: &[u8]) -> [u8; 32] { - let mut mac = hmac::Context::with_key(&self.mac_key()); - mac.update(MAC_DOMAIN); - mac.update(encoded); - let mut tag = [0u8; 32]; - tag.copy_from_slice(&mac.sign().as_ref()[..32]); - tag - } - - /// The HMAC-SHA256 key (ring, the one crypto backend) over the derived bytes. - fn mac_key(&self) -> hmac::Key { - hmac::Key::new(hmac::HMAC_SHA256, &self.key) - } -} - -impl AskState { - /// Does this opened state belong to THE REQUEST NOW IN HAND? - /// - /// Separate from [`Sealer::open`] on purpose: `open` proves busbar minted the blob, and this - /// proves busbar minted it FOR THIS. A seal that only proved the first would let any caller - /// replay its own valid state onto a different tool, a different argument set, or a catalogue - /// generation under which the approval has since been withdrawn. - pub fn matches( - &self, - principal: &str, - method: &str, - capability: &str, - args_digest: &str, - generation: u64, - ) -> Result<(), Rejected> { - if self.principal != principal { - return Err(Rejected::WrongPrincipal); - } - if self.method != method || self.capability != capability { - return Err(Rejected::WrongRequest); - } - if self.args_digest != args_digest { - return Err(Rejected::WrongRequest); - } - if self.generation != generation { - return Err(Rejected::WrongGeneration); - } - Ok(()) - } -} +#[cfg(test)] +#[path = "tests/approvals_tests.rs"] +mod approvals_tests; diff --git a/crates/busbar-kernel/src/plane/tests/approvals_tests.rs b/crates/busbar-kernel/src/plane/tests/approvals_tests.rs new file mode 100644 index 0000000000..43dee8561f --- /dev/null +++ b/crates/busbar-kernel/src/plane/tests/approvals_tests.rs @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! CORE HOLDS NO PLANE'S SEAL (audit K2-H3). The caller-facing ask-state seal is the plane's own, +//! sealed over the host's `sign`; core keeps only the spent-approval ledger, the replay window and +//! the nonce. RED before the deletion: the kernel carried a dead copy of the seal, its payload and +//! its two MAC domains, spelled with the plane token escaped. + +use std::path::Path; + +/// Every `.rs` file under `dir`. +fn sources(dir: &Path, out: &mut Vec) { + for entry in std::fs::read_dir(dir).expect("a source directory").flatten() { + let path = entry.path(); + if path.is_dir() { + sources(&path, out); + } else if path.extension().is_some_and(|e| e == "rs") { + out.push(path); + } + } +} + +#[test] +fn the_kernel_holds_no_plane_named_seal_domain() { + // The needles are built here, so this file does not carry them: the escaped plane token the + // seal domains were spelled with, and the domains' own path segment. + let escaped = format!("busbar/\\x{:02x}", b'm'); + let segment = ["ask", "state/"].concat(); + let mut files = Vec::new(); + sources( + &Path::new(env!("CARGO_MANIFEST_DIR")).join("src"), + &mut files, + ); + assert!(!files.is_empty(), "the kernel's sources are read"); + let carriers: Vec = files + .iter() + .filter(|f| { + let text = std::fs::read_to_string(f).unwrap_or_default(); + text.contains(&escaped) || text.contains(&segment) + }) + .map(|f| f.display().to_string()) + .collect(); + assert!( + carriers.is_empty(), + "the kernel carries a plane's seal domain: {carriers:?}" + ); +} + +#[test] +fn the_ledger_spends_an_approval_once() { + let ledger = super::SpentTokenLedger::new(); + let nonce = super::nonce().expect("a nonce"); + assert!(ledger.spend(&nonce, 100 + super::DEFAULT_TTL_SECS, 100)); + assert!(!ledger.spend(&nonce, 100 + super::DEFAULT_TTL_SECS, 101)); +} diff --git a/crates/busbar-kernel/src/plane/tests/askstate_tests.rs b/crates/busbar-kernel/src/plane/tests/askstate_tests.rs deleted file mode 100644 index 6dd3b54471..0000000000 --- a/crates/busbar-kernel/src/plane/tests/askstate_tests.rs +++ /dev/null @@ -1,322 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (C) 2026 Busbar Inc and contributors - -//! THE SEAL, exercised against the mutations that actually happen. -//! -//! The conformance suite's `tampered-state` scenario does exactly one thing to the state busbar -//! minted: it appends the literal `-TAMPERED`. That mutation is the FIRST test below and it is -//! deliberately not generalised away, because `-` is a valid base64url character: a decoder -//! configured to tolerate trailing input would accept it, the scenario would go green, and the -//! property the scenario is named after would be absent. A test written only against a random blob -//! would not have caught that. -//! -//! Every other test here is a field of the payload changed one at a time, which is the only way to -//! show that each field is LOAD-BEARING rather than merely present. - -use super::*; -// The seal crypto lives in `busbar_kernel::plane::approvals`, so the base64url encoder these -// tampering tests build forged blobs with is imported directly rather than inherited from the parent -// module's private `use`. -use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use base64::Engine as _; - -const KEY: [u8; 32] = [7u8; 32]; -const NOW: u64 = 1_700_000_000; - -fn sealer() -> Sealer { - Sealer::derive(&KEY) -} - -fn state() -> AskState { - AskState { - principal: "vk_caller_a".to_string(), - method: "tools/call".to_string(), - capability: "test_thing".to_string(), - args_digest: digest_arguments(&serde_json::json!({ "path": "/etc/hosts" })), - generation: 4, - round: 0, - nonce: nonce().expect("entropy"), - issued_at: NOW, - ttl_secs: DEFAULT_TTL_SECS, - // The battery's baseline state is NOT roots-bearing; the epoch's own behaviour is judged - // end to end in `roots_changed_tests.rs`. - roots_epoch: None, - } -} - -/// The happy path, and the control without which every refusal below is equally consistent with a -/// seal that refuses everything. -#[test] -fn a_state_busbar_minted_opens_and_matches_the_request_it_was_minted_for() { - let s = state(); - let blob = sealer().mint(&s); - let opened = sealer().open(&blob, NOW).expect("busbar's own state opens"); - assert_eq!(opened, s); - opened - .matches( - "vk_caller_a", - "tools/call", - "test_thing", - &digest_arguments(&serde_json::json!({ "path": "/etc/hosts" })), - 4, - ) - .expect("the request it was minted for"); -} - -/// THE SUITE'S EXACT MUTATION. `sep-2322-reject-tampered-state` sends -/// `r1Result.requestState + '-TAMPERED'` and requires a JSON-RPC error. -#[test] -fn the_conformance_suites_exact_tamper_is_refused() { - let blob = sealer().mint(&state()); - assert_eq!( - sealer().open(&format!("{blob}-TAMPERED"), NOW), - Err(Rejected::BadSignature), - "`-TAMPERED` is all base64url characters, so a lenient decoder accepts it" - ); -} - -/// And the mutation in the other segment, which a suffix-only test would miss: rewriting the PAYLOAD -/// while keeping a valid-looking tag. -#[test] -fn a_rewritten_payload_is_refused_even_though_the_tag_is_untouched() { - let mut s = state(); - let honest = sealer().mint(&s); - let (_, tag) = honest.split_once('.').expect("two segments"); - // The caller promotes itself: same everything, different principal. - s.principal = "vk_caller_b".to_string(); - let forged_payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&s).expect("serialises")); - assert_eq!( - sealer().open(&format!("{forged_payload}.{tag}"), NOW), - Err(Rejected::BadSignature) - ); -} - -/// Every single byte of the tag matters. A comparison that stopped at a prefix, or one that accepted -/// a short tag, would pass the two tests above and fail this one. -#[test] -fn a_tag_that_is_short_or_altered_in_any_single_position_is_refused() { - let blob = sealer().mint(&state()); - let (payload, tag) = blob.split_once('.').expect("two segments"); - // A truncated tag is REFUSED, and which refusal it is depends on where it dies: a base64url - // segment two characters short is not a whole number of bytes, so it fails the decode before it - // reaches the MAC. The property under test is that it does not verify AS A PREFIX of the honest - // tag, and both arms are that; pinning one would be asserting the order of two checks rather - // than the thing they jointly guarantee. - assert!( - matches!( - sealer().open(&format!("{payload}.{}", &tag[..tag.len() - 2]), NOW), - Err(Rejected::BadSignature) | Err(Rejected::Malformed) - ), - "a truncated tag must not verify as a prefix" - ); - // And one that IS a whole number of bytes, just fewer of them: this one does reach the MAC, so - // it is the case that proves the comparison rejects on length rather than comparing a prefix. - let short = URL_SAFE_NO_PAD.encode([0u8; 16]); - assert_eq!( - sealer().open(&format!("{payload}.{short}"), NOW), - Err(Rejected::BadSignature) - ); - let mut altered: Vec = tag.chars().collect(); - altered[0] = if altered[0] == 'A' { 'B' } else { 'A' }; - let altered: String = altered.into_iter().collect(); - assert_eq!( - sealer().open(&format!("{payload}.{altered}"), NOW), - Err(Rejected::BadSignature) - ); -} - -/// A DIFFERENT DEPLOYMENT'S key does not open this deployment's state. The fleet shares one signing -/// key by design; two fleets do not. -#[test] -fn another_deployments_seal_does_not_open_here() { - let blob = Sealer::derive(&[9u8; 32]).mint(&state()); - assert_eq!(sealer().open(&blob, NOW), Err(Rejected::BadSignature)); -} - -/// `mrtr.mdx:236` — the short expiry, rejected after it lapses. Checked at the boundary in both -/// directions, because an off-by-one here is a replay window that is one TTL wider than declared. -#[test] -fn state_is_refused_the_moment_it_lapses() { - let s = state(); - let blob = sealer().mint(&s); - assert!( - sealer().open(&blob, NOW + DEFAULT_TTL_SECS).is_ok(), - "still valid at exactly the expiry instant" - ); - assert_eq!( - sealer().open(&blob, NOW + DEFAULT_TTL_SECS + 1), - Err(Rejected::Expired) - ); -} - -/// THE CROSS-CALLER REPLAY `mrtr.mdx:235` NAMES. Caller A's state, presented by caller B, with a -/// perfectly valid signature, must not be redeemed. This is the property a relayer cannot have at -/// all, because the only principal an upstream can bind to is busbar. -#[test] -fn caller_a_state_cannot_be_redeemed_by_caller_b() { - let blob = sealer().mint(&state()); - let opened = sealer().open(&blob, NOW).expect("the signature is honest"); - assert_eq!( - opened.matches( - "vk_caller_b", - "tools/call", - "test_thing", - &digest_arguments(&serde_json::json!({ "path": "/etc/hosts" })), - 4, - ), - Err(Rejected::WrongPrincipal) - ); -} - -/// `mrtr.mdx:237` — the originating request. Each of the three components is changed on its own, so -/// each is shown to be load-bearing rather than carried. -#[test] -fn state_cannot_be_spent_on_a_different_request_than_it_was_minted_for() { - let blob = sealer().mint(&state()); - let opened = sealer().open(&blob, NOW).expect("honest"); - let honest_digest = digest_arguments(&serde_json::json!({ "path": "/etc/hosts" })); - - // A different METHOD: an ask issued on `tools/call` spent on `prompts/get`. - assert_eq!( - opened.matches( - "vk_caller_a", - "prompts/get", - "test_thing", - &honest_digest, - 4 - ), - Err(Rejected::WrongRequest) - ); - // A different CAPABILITY: one capability's ask spent on another capability. - assert_eq!( - opened.matches("vk_caller_a", "tools/call", "test_other", &honest_digest, 4), - Err(Rejected::WrongRequest) - ); - // Different ARGUMENTS: the ask was about `/etc/hosts`; the retry is about the private key. - assert_eq!( - opened.matches( - "vk_caller_a", - "tools/call", - "test_thing", - &digest_arguments(&serde_json::json!({ "path": "/root/.ssh/id_ed25519" })), - 4, - ), - Err(Rejected::WrongRequest) - ); -} - -/// The binding the specification does NOT ask for and busbar can give anyway: state minted under an -/// approval that has since been withdrawn does not complete the call the operator withdrew. -#[test] -fn state_minted_under_a_stale_catalogue_generation_is_refused() { - let blob = sealer().mint(&state()); - let opened = sealer().open(&blob, NOW).expect("honest"); - assert_eq!( - opened.matches( - "vk_caller_a", - "tools/call", - "test_thing", - &digest_arguments(&serde_json::json!({ "path": "/etc/hosts" })), - 5, - ), - Err(Rejected::WrongGeneration) - ); -} - -/// THE `multi-round` REQUIREMENT, which is a requirement about the SEAL and not about the loop: -/// `sep-2322-multi-round-r2` fails unless `r2.requestState !== r1.requestState`. A deterministic -/// seal over an identical payload produces an identical string and can never satisfy it. -#[test] -fn two_mints_of_the_same_logical_ask_differ() { - let s = sealer(); - let mut a = state(); - a.nonce = nonce().expect("entropy"); - let mut b = a.clone(); - b.nonce = nonce().expect("entropy"); - assert_ne!(a.nonce, b.nonce, "the nonce is what makes the seals differ"); - assert_ne!(s.mint(&a), s.mint(&b)); - // And the round index moves too, so even a nonce collision would not produce equal states. - let mut r1 = a.clone(); - r1.round = 1; - assert_ne!(s.mint(&a), s.mint(&r1)); -} - -/// Malformed input is refused rather than panicking. A `requestState` is attacker-controlled by -/// definition (`mrtr.mdx:232`), so every one of these arrives eventually. -#[test] -fn malformed_state_is_refused_and_never_panics() { - for blob in [ - "", - "no-dot-at-all", - ".", - "..", - "not!base64.not!base64", - "AAAA.AAAA", - // A valid payload with no tag at all. - "eyJwIjoieCJ9.", - ] { - let got = sealer().open(blob, NOW); - assert!( - matches!(got, Err(Rejected::Malformed) | Err(Rejected::BadSignature)), - "`{blob}` must be refused, got {got:?}" - ); - } -} - -/// The digest is over the VALUE, not the byte order the caller happened to send. Two equal argument -/// objects written in different key orders are the same request and must digest the same, or a -/// caller re-serialising its own retry would be told its state was for a different request. -#[test] -fn the_argument_digest_is_key_order_independent_and_value_sensitive() { - let a: serde_json::Value = serde_json::from_str(r#"{"a":1,"b":2}"#).expect("json"); - let b: serde_json::Value = serde_json::from_str(r#"{"b":2,"a":1}"#).expect("json"); - assert_eq!(digest_arguments(&a), digest_arguments(&b)); - let c: serde_json::Value = serde_json::from_str(r#"{"a":1,"b":3}"#).expect("json"); - assert_ne!(digest_arguments(&a), digest_arguments(&c)); -} - -/// Every refusal arm has an audit word, and they are DISTINCT — a shared word would make the audit -/// trail unable to tell a lapsed state from a forged one, which are very different events. -#[test] -fn every_refusal_arm_has_its_own_audit_word() { - let arms = [ - Rejected::Malformed, - Rejected::BadSignature, - Rejected::Expired, - Rejected::WrongPrincipal, - Rejected::WrongRequest, - Rejected::WrongGeneration, - Rejected::AlreadySpent, - ]; - let words: std::collections::BTreeSet<&str> = arms.iter().map(|r| r.audit_reason()).collect(); - assert_eq!(words.len(), arms.len(), "audit words must be distinct"); -} - -/// The message a CALLER sees carries no oracle. `mrtr.mdx:269-272` names the client as the attacker -/// on this field, so "which check did I fail" is exactly what must not be answered. -#[test] -fn the_caller_facing_message_does_not_say_which_check_failed() { - let arms = [ - Rejected::BadSignature, - Rejected::Expired, - Rejected::WrongPrincipal, - Rejected::WrongRequest, - Rejected::WrongGeneration, - // An approval already spent renders IDENTICALLY. It is the one arm where a distinct message - // would be actively dangerous: it would tell a caller that the state it just presented WAS - // valid, which is more than any other refusal admits. - Rejected::AlreadySpent, - ]; - let messages: std::collections::BTreeSet = arms.iter().map(|r| r.to_string()).collect(); - assert_eq!( - messages.len(), - 1, - "every arm must render the SAME caller-facing message: {messages:?}" - ); - for r in arms { - assert!( - !r.to_string().contains(r.audit_reason()), - "the audit word must not leak into the caller-facing message" - ); - } -} From d11b8694e8352dbc50a08e59ddb54a14fd0ff0c8 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:11:29 -0700 Subject: [PATCH 02/59] approvals_tests: rustfmt --- crates/busbar-kernel/src/plane/tests/approvals_tests.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/busbar-kernel/src/plane/tests/approvals_tests.rs b/crates/busbar-kernel/src/plane/tests/approvals_tests.rs index 43dee8561f..8da1f6bf33 100644 --- a/crates/busbar-kernel/src/plane/tests/approvals_tests.rs +++ b/crates/busbar-kernel/src/plane/tests/approvals_tests.rs @@ -10,7 +10,10 @@ use std::path::Path; /// Every `.rs` file under `dir`. fn sources(dir: &Path, out: &mut Vec) { - for entry in std::fs::read_dir(dir).expect("a source directory").flatten() { + for entry in std::fs::read_dir(dir) + .expect("a source directory") + .flatten() + { let path = entry.path(); if path.is_dir() { sources(&path, out); From 8e3ea4f7bf924a4faea96136c54e0eb179f8d324 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Fri, 9 Oct 2026 21:12:37 -0700 Subject: [PATCH 03/59] qa/kind-isolation: lower busbar-kernel x instance:secret law0 ceiling 117 -> 116 Deleting the kernel's dead ask-state seal (K2-H3) drained one secret-instance hit from busbar-kernel, leaving a law0-slack finding that raised the kind-isolation:law0 figure 18 -> 19 on both kind-isolation and kind-isolation-ship. Lower the ceiling in the commit that drained it. Proved with busbar-release dev cargo xtask gate kind-isolation and kind-isolation-ship: law0 back to 18 (base figure). --- qa/kind-isolation.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qa/kind-isolation.toml b/qa/kind-isolation.toml index 2d8cc0b8e7..e75b90ad76 100644 --- a/qa/kind-isolation.toml +++ b/qa/kind-isolation.toml @@ -1809,8 +1809,8 @@ cite = "measured on predev f830faa691 (ARCHITECT 2026-10-07, Law 9); the drain [[law0]] crate = "busbar-kernel" axis = "instance:secret" -count = "117" -cite = "measured on predev f830faa691 (ARCHITECT 2026-10-07, Law 9); the drain is THE DESIGN §1 / DoD item 2: core names no instance" +count = "116" +cite = "measured on predev f830faa691 (ARCHITECT 2026-10-07, Law 9), lowered 117 -> 116 by the deletion of the kernel's dead ask-state seal (audit K2-H3); the drain is THE DESIGN §1 / DoD item 2: core names no instance" [[law0]] crate = "busbar-kernel" From 03fc0f30fa32eeb828f86e6482cda2a409ac3cff Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Sat, 10 Oct 2026 07:06:56 -0700 Subject: [PATCH 04/59] qa/kind-isolation: lower busbar-kernel x plane law0 ceiling 1175 -> 1171 Deleting the kernel's dead ask-state seal (K2-H3) drains four plane hits from busbar-kernel: the two crate::mcp::roots doc links and the two \x6dcp seal domains (the decoded scanner reads them as mcp). The re-armed ledger (#725) pinned the row at predev's 1175, so the drain read as 4 hits of law0 slack (figure 29 -> 33 on kind-isolation and kind-isolation-ship). Not proved on Latchkey: the run budget is spent (RUN_CAP); reasoned from the CI verdict and the deleted source, and the PR's CI is the proof. --- qa/kind-isolation.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qa/kind-isolation.toml b/qa/kind-isolation.toml index 9c0393ce94..df99460f2b 100644 --- a/qa/kind-isolation.toml +++ b/qa/kind-isolation.toml @@ -1794,8 +1794,8 @@ cite = "measured on predev f830faa691 (ARCHITECT 2026-10-07, Law 9); the drain [[law0]] crate = "busbar-kernel" axis = "plane" -count = "1175" -cite = "measured on predev f830faa691 (ARCHITECT 2026-10-07, Law 9); the drain is THE DESIGN §1 / DoD item 2: core names no instance; LOWERED 1193 -> 1175, predev 9c0a394771's measure: the drain landed without giving its row back (Law 9: the ceiling ratchets down)" +count = "1171" +cite = "measured on predev f830faa691 (ARCHITECT 2026-10-07, Law 9); the drain is THE DESIGN §1 / DoD item 2: core names no instance; LOWERED 1193 -> 1175, predev 9c0a394771's measure: the drain landed without giving its row back (Law 9: the ceiling ratchets down); lowered 1175 -> 1171 by the deletion of the kernel's dead ask-state seal (audit K2-H3): its two `crate::mcp::roots` doc links and its two `\\x6dcp` seal domains" [[law0]] crate = "busbar-kernel" From 16c011608ff0669c4b8ba76b1a853d87a03d2aa5 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:43:48 -0700 Subject: [PATCH 05/59] =?UTF-8?q?qa/kind-isolation:=20lower=20busbar-kerne?= =?UTF-8?q?l=20=C3=97=20plane=20law0=20ceiling=201171=20->=201169?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kind-isolation:law0 row this branch arms reported law0-slack busbar-kernel × plane 1169 hit(s) under a ceiling of 1171; the ledger row now sits at the measured value (Law 9). Not proved on Latchkey: the run budget is spent (RUN_CAP), so the PR's CI is the proof. --- qa/kind-isolation.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qa/kind-isolation.toml b/qa/kind-isolation.toml index df99460f2b..9c221db1a9 100644 --- a/qa/kind-isolation.toml +++ b/qa/kind-isolation.toml @@ -1794,8 +1794,8 @@ cite = "measured on predev f830faa691 (ARCHITECT 2026-10-07, Law 9); the drain [[law0]] crate = "busbar-kernel" axis = "plane" -count = "1171" -cite = "measured on predev f830faa691 (ARCHITECT 2026-10-07, Law 9); the drain is THE DESIGN §1 / DoD item 2: core names no instance; LOWERED 1193 -> 1175, predev 9c0a394771's measure: the drain landed without giving its row back (Law 9: the ceiling ratchets down); lowered 1175 -> 1171 by the deletion of the kernel's dead ask-state seal (audit K2-H3): its two `crate::mcp::roots` doc links and its two `\\x6dcp` seal domains" +count = "1169" +cite = "measured on predev f830faa691 (ARCHITECT 2026-10-07, Law 9); the drain is THE DESIGN §1 / DoD item 2: core names no instance; LOWERED 1193 -> 1175, predev 9c0a394771's measure: the drain landed without giving its row back (Law 9: the ceiling ratchets down); lowered 1175 -> 1169 (the gate's measure at this branch's tree) with the deletion of the kernel's dead ask-state seal (audit K2-H3): its two `crate::mcp::roots` doc links and its two `\\x6dcp` seal domains among them" [[law0]] crate = "busbar-kernel" From eb3183081402c42bfb5b558ecd4ab52d809681e5 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:21:35 -0700 Subject: [PATCH 06/59] row 130 E1: the hyper serve loop leaves busbar-kernel; the kernel's no-socket pin armed (E7) The inbound listener's accept/serve loop (hyper-util's auto builder, graceful shutdown, the body read bounds, the accept backoff and the connection-placement balancer) moves verbatim from busbar-kernel/src/tls.rs into the composition root, crates/busbar/src/root/listener.rs. The connector binds every listener (Listening::bind_stream) and hands each admitted stream up; the accept-source trait (Admits, Admitted) moves to busbar-core-connector's listen.rs beside the listener that implements it. tls.rs keeps read_pem for its outbound callers. The kernel drops tokio-util. BUSBAR-1.6.0.md l.475-483 (INBOUND-LISTEN: the root's stream hand-up) and the ARCHITECT ruling on #710 (2026-10-10). This is #741's change on current predev; it supersedes #741. Tests move with the code: the kernel's tls_tests.rs and busbar/tests/tls_listener_h2.rs become root/tests/listener_tests.rs and listener_h2_tests.rs; the connector's TLS-listener rows join them (their scratch PEM name keeps predev's unique-per-call suffix). Ledgers: law0 busbar-kernel x transport 285 -> 268 and busbar x transport 208 -> 225 (the move, total flat, per the #710 ruling); population floor 947 -> 948 (root/listener.rs is a new file; tls.rs stays). E7: crates/busbar-kernel/tests/no_socket.rs holds busbar-kernel to a drain-only pin of every (file, needle) naming a socket, resolver or HTTP client/server stack, and every manifest ask for one; tls.rs is not in it. A new offender is RED, a drained pin left standing is RED, and the RED arm plants a dial, a resolver and a server stack and sees each. --- Cargo.lock | 2 +- crates/busbar-core-connector/Cargo.toml | 4 +- crates/busbar-core-connector/src/listen.rs | 32 +- .../src/tests/listen_tests.rs | 2 +- .../src/tls/engine_tests.rs | 182 +--- crates/busbar-kernel/Cargo.toml | 4 - crates/busbar-kernel/src/limits/mod.rs | 12 +- .../src/test_support/fixtures.rs | 11 + crates/busbar-kernel/src/tls.rs | 815 +----------------- crates/busbar-kernel/tests/no_socket.rs | 301 +++++++ crates/busbar/Cargo.toml | 12 +- crates/busbar/src/main.rs | 37 +- crates/busbar/src/root/listener.rs | 804 +++++++++++++++++ crates/busbar/src/root/mod.rs | 1 + .../root/tests/listener_h2_tests.rs} | 6 +- .../src/root/tests/listener_tests.rs} | 285 +++++- qa/kind-isolation.toml | 8 +- xtask/src/gates/population.rs | 11 +- 18 files changed, 1463 insertions(+), 1066 deletions(-) create mode 100644 crates/busbar-kernel/tests/no_socket.rs create mode 100644 crates/busbar/src/root/listener.rs rename crates/busbar/{tests/tls_listener_h2.rs => src/root/tests/listener_h2_tests.rs} (96%) rename crates/{busbar-kernel/src/tests/tls_tests.rs => busbar/src/root/tests/listener_tests.rs} (76%) diff --git a/Cargo.lock b/Cargo.lock index dbaadb6801..1b412d0a16 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -380,6 +380,7 @@ dependencies = [ "tikv-jemallocator", "tokio", "tokio-rustls", + "tokio-util", "tower", "tracing", "tracing-subscriber", @@ -755,7 +756,6 @@ dependencies = [ "tikv-jemallocator", "tokio", "tokio-tungstenite", - "tokio-util", "tower", "tracing", "tracing-log", diff --git a/crates/busbar-core-connector/Cargo.toml b/crates/busbar-core-connector/Cargo.toml index 9fba9af2de..079fe96c80 100644 --- a/crates/busbar-core-connector/Cargo.toml +++ b/crates/busbar-core-connector/Cargo.toml @@ -38,8 +38,8 @@ rustls-pki-types = { workspace = true } tokio-rustls = { workspace = true } futures = { workspace = true } # Bridges the contract's rustls-free, futures-io-based `RawIo` (the wrap's parameter/return type) -# to the tokio-io types `tokio_rustls::TlsAcceptor` requires — the same compat seam the kernel's -# accept loop (`busbar_kernel::tls::serve_one`) crosses on the other side of `wrap`. +# to the tokio-io types `tokio_rustls::TlsAcceptor` requires — the same compat seam the root's +# accept loop (`busbar::root::listener`) crosses on the other side of `wrap`. tokio-util = { workspace = true } # The OUTBOUND half (`tls::client`): the platform trust anchors every dialled connection starts from, and # the SHA-256 an SPKI pin is compared under. Both moved here with the outbound trust itself, which a diff --git a/crates/busbar-core-connector/src/listen.rs b/crates/busbar-core-connector/src/listen.rs index c3582c0a7c..03dadf8fdb 100644 --- a/crates/busbar-core-connector/src/listen.rs +++ b/crates/busbar-core-connector/src/listen.rs @@ -84,12 +84,34 @@ pub struct Handed { pub slot: Slot, } -/// The root's stream listener as the kernel's accept source: each admitted socket, with its slot -/// held for as long as the kernel serves it. -impl busbar_kernel::tls::Admits for Listening { - async fn admit(&mut self) -> busbar_kernel::tls::Admitted { +// ── THE ACCEPT SOURCE ───────────────────────────────────────────────────────────────────────────── + +/// ONE CONNECTION AN INBOUND LISTENER ADMITTED: the accepted socket, not yet on any worker's reactor +/// (so placement may hand it to another worker), its peer, and what the listener holds for it while +/// it is served (its connection slot), released when the connection ends. +pub struct Admitted { + /// The accepted socket, non-blocking. + pub stream: std::net::TcpStream, + /// The far end. + pub peer: SocketAddr, + /// Held for as long as the connection is served. + pub hold: Option>, +} + +/// AN INBOUND LISTENER, as the accept loop drains it: the next admitted connection. Accept errors, +/// the connection cap and the accept backoff are the listener's own, so this never fails; it must +/// be cancel-safe (the loop drops it when shutdown or a hand-off wins the race). +pub trait Admits: Send { + /// The next admitted connection. + fn admit(&mut self) -> impl Future + Send + '_; +} + +/// The root's stream listener as the root's accept source (`busbar::root::listener`): each +/// admitted socket, with its slot held for as long as the root serves it. +impl Admits for Listening { + async fn admit(&mut self) -> Admitted { let h = futures::future::poll_fn(|cx| self.poll_accept_stream(cx)).await; - busbar_kernel::tls::Admitted { + Admitted { stream: h.stream, peer: h.peer, hold: Some(Box::new(h.slot)), diff --git a/crates/busbar-core-connector/src/tests/listen_tests.rs b/crates/busbar-core-connector/src/tests/listen_tests.rs index 80f2708d7c..1915ccee44 100644 --- a/crates/busbar-core-connector/src/tests/listen_tests.rs +++ b/crates/busbar-core-connector/src/tests/listen_tests.rs @@ -303,7 +303,7 @@ fn accept_errors_back_off_as_1_5_5s_did() { /// the kernel holds frees when the served connection ends. #[test] fn a_stream_listener_hands_up_the_socket_as_it_arrived_under_the_cap() { - use busbar_kernel::tls::Admits; + use crate::listen::Admits; worker().block_on(async { let via = crate::support::via(); let mut l = Listening::bind_stream( diff --git a/crates/busbar-core-connector/src/tls/engine_tests.rs b/crates/busbar-core-connector/src/tls/engine_tests.rs index 5683292152..2eba63266f 100644 --- a/crates/busbar-core-connector/src/tls/engine_tests.rs +++ b/crates/busbar-core-connector/src/tls/engine_tests.rs @@ -3,7 +3,7 @@ //! THE KERNEL'S TLS, OVER THE CONNECTOR'S REAL WRAP. TLS stays in the connector (THE DESIGN; 1.6.0-TODO //! P2: "TLS lives only in the connector"), so the rows that need a REAL handshake through the -//! kernel's outbound engine and its TLS listener live here, beside the wrap they prove (this crate +//! kernel's outbound engine live here, beside the wrap they prove (this crate //! depends one way on the kernel, so both are named). The kernel's own suites drive the same postures over a TLS //! test double (`busbar_kernel::egress::fixtures::TlsDouble`) and prove the engine's side of the //! seam; these prove the composition: @@ -20,9 +20,9 @@ //! * the egress differential's TLS rows: the owned engine beside 1.5.5's pinned reqwest client //! against one recording TLS fixture (the known leaf's pin and SNI under the pin, webpki refusing //! the private CA, the client-certificate fixture accepting only the carried identity); -//! * the TLS listener (`busbar_kernel::tls::serve`) over the connector's production wrap -//! (`busbar_core_connector::tls::prepare`): a trusted client's 200, mutual TLS admitting a client -//! certificate chaining to `client_ca` and refusing none or a foreign one while still serving. +//! * the TLS listener's rows over the connector's production wrap (a trusted client's 200, mutual +//! TLS admitting a client certificate chaining to `client_ca` and refusing none or a foreign one +//! while still serving) live in `busbar::root::listener`'s tests, beside the listener. use std::net::SocketAddr; use std::sync::Arc; @@ -1046,177 +1046,3 @@ async fn an_mtls_peer_accepts_its_own_clients_certificate_and_refuses_a_foreign_ "the foreign certificate is refused AS a certificate: {reason}" ); } - -// ── THE TLS LISTENER over the connector's production wrap ─────────────────────────────────────── - -fn temp_pem(tag: &str, contents: &str) -> std::path::PathBuf { - let mut p = std::env::temp_dir(); - p.push(format!( - "busbar-engine-tls-{tag}-{}-{:?}.pem", - std::process::id(), - { - static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - format!( - "{}-{}-{}", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_or(0, |d| d.as_nanos()), - line!(), - NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) - ) - } - )); - std::fs::write(&p, contents).expect("write pem"); - p -} - -fn file(path: &std::path::Path) -> busbar_kernel::config::SecretRef { - busbar_kernel::config::SecretRef::file(path.to_string_lossy().into_owned()) -} - -/// A self-signed server cert for `localhost`/`127.0.0.1`: (cert_pem, key_pem). -fn gen_self_signed() -> (String, String) { - let rcgen::CertifiedKey { cert, signing_key } = - rcgen::generate_simple_self_signed(vec!["localhost".into(), "127.0.0.1".into()]) - .expect("self-signed"); - (cert.pem(), signing_key.serialize_pem()) -} - -/// Boot the busbar TLS listener (`busbar_kernel::tls::serve`) from a `TlsCfg` on an ephemeral port, -/// secured by the connector's PRODUCTION wrap (`busbar_core_connector::tls::prepare`), exactly as -/// `main`'s TLS branch does. Returns the bound address and a shutdown sender. -async fn spawn_tls_server( - tls: &busbar_kernel::config::sections::TlsCfg, -) -> (SocketAddr, tokio::sync::oneshot::Sender<()>) { - let security = crate::tls::prepare( - "engine-tls test", - Some(tls), - &busbar_kernel::config::secret::SecretResolver::builtins_only(), - true, - ) - .expect("valid test TLS config"); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind"); - let addr = listener.local_addr().expect("addr"); - let (tx, rx) = tokio::sync::oneshot::channel::<()>(); - let router = axum::Router::new().route("/healthz", axum::routing::get(|| async { "ok" })); - tokio::spawn(async move { - let shutdown = async { - let _ = rx.await; - }; - busbar_kernel::tls::serve(listener, router, security, shutdown, None) - .await - .expect("serve"); - }); - (addr, tx) -} - -/// TLS happy path: a client trusting the server's self-signed cert completes an https request and -/// gets 200. -#[tokio::test] -async fn tls_happy_path_trusted_client_gets_200() { - let (cert_pem, key_pem) = gen_self_signed(); - let tls = busbar_kernel::config::sections::TlsCfg { - cert: file(&temp_pem("srv-cert", &cert_pem)), - key: file(&temp_pem("srv-key", &key_pem)), - client_ca: None, - }; - let (addr, _stop) = spawn_tls_server(&tls).await; - let client = reqwest::Client::builder() - .add_root_certificate(reqwest::Certificate::from_pem(cert_pem.as_bytes()).expect("cert")) - .build() - .expect("client"); - let resp = client - .get(format!("https://localhost:{}/healthz", addr.port())) - .send() - .await - .expect("https request should succeed over TLS"); - assert_eq!(resp.status(), 200); - assert_eq!(resp.text().await.expect("body"), "ok"); -} - -/// A CA and a leaf it signed for `sans`: (ca_pem, leaf_pem, leaf_key_pem). -fn gen_ca_and_leaf(sans: &[&str]) -> (String, String, String) { - let m = ca_and_leaf(sans); - (m.ca_pem, m.leaf_pem, m.leaf_key_pem) -} - -/// The mutual listener: client certificates must chain to the CA it names. -fn mutual( - srv_cert_pem: &str, - srv_key_pem: &str, - ca_pem: &str, -) -> busbar_kernel::config::sections::TlsCfg { - busbar_kernel::config::sections::TlsCfg { - cert: file(&temp_pem("m-srv-cert", srv_cert_pem)), - key: file(&temp_pem("m-srv-key", srv_key_pem)), - client_ca: Some(file(&temp_pem("m-ca", ca_pem))), - } -} - -fn client_with(srv_cert_pem: &str, identity: Option<(&str, &str)>) -> reqwest::Client { - let mut builder = reqwest::Client::builder() - .add_root_certificate( - reqwest::Certificate::from_pem(srv_cert_pem.as_bytes()).expect("cert"), - ) - .use_rustls_tls(); - if let Some((leaf, key)) = identity { - builder = builder.identity( - reqwest::Identity::from_pem(format!("{leaf}{key}").as_bytes()).expect("identity"), - ); - } - builder.build().expect("client") -} - -/// mTLS required + valid client cert: a client presenting a leaf signed by the configured CA gets -/// 200. -#[tokio::test] -async fn mtls_valid_client_cert_gets_200() { - let (srv_cert_pem, srv_key_pem) = gen_self_signed(); - let (ca_pem, leaf_pem, leaf_key_pem) = gen_ca_and_leaf(&["busbar-client"]); - let (addr, _stop) = spawn_tls_server(&mutual(&srv_cert_pem, &srv_key_pem, &ca_pem)).await; - let resp = client_with(&srv_cert_pem, Some((&leaf_pem, &leaf_key_pem))) - .get(format!("https://localhost:{}/healthz", addr.port())) - .send() - .await - .expect("mTLS request with valid client cert should succeed"); - assert_eq!(resp.status(), 200); -} - -/// mTLS required + no/wrong client cert: the handshake is rejected, the server stays up, and a -/// subsequent valid client still succeeds. -#[tokio::test] -async fn mtls_rejects_bad_client_then_serves_valid() { - let (srv_cert_pem, srv_key_pem) = gen_self_signed(); - let (ca_pem, leaf_pem, leaf_key_pem) = gen_ca_and_leaf(&["busbar-client"]); - let (addr, _stop) = spawn_tls_server(&mutual(&srv_cert_pem, &srv_key_pem, &ca_pem)).await; - let url = format!("https://localhost:{}/healthz", addr.port()); - - // (a) Client presenting NO client cert ⇒ rejected (server requires one). - assert!( - client_with(&srv_cert_pem, None) - .get(&url) - .send() - .await - .is_err(), - "mTLS server must reject a client with no certificate" - ); - // (b) Client presenting a cert from a DIFFERENT CA ⇒ also rejected. - let (_other_ca, wrong_leaf, wrong_key) = gen_ca_and_leaf(&["impostor"]); - assert!( - client_with(&srv_cert_pem, Some((&wrong_leaf, &wrong_key))) - .get(&url) - .send() - .await - .is_err(), - "mTLS server must reject a client cert from an untrusted CA" - ); - // (c) Server survived both rejections and still serves a valid client. - let resp = client_with(&srv_cert_pem, Some((&leaf_pem, &leaf_key_pem))) - .get(&url) - .send() - .await - .expect("server must remain up and serve a valid client after rejecting bad ones"); - assert_eq!(resp.status(), 200); -} diff --git a/crates/busbar-kernel/Cargo.toml b/crates/busbar-kernel/Cargo.toml index f055b7f749..9ecb35e2f4 100644 --- a/crates/busbar-kernel/Cargo.toml +++ b/crates/busbar-kernel/Cargo.toml @@ -153,10 +153,6 @@ arc-swap = { workspace = true } httpdate = { workspace = true } hyper = { workspace = true } hyper-util = { workspace = true } -# The futures-io/tokio-io compat seam `tls::serve_one` bridges the accepted `TcpStream` and the -# `ConnectionSecurity::wrap` result through (DECISIONS #40) — the same seam the core -# connection-security `Tls` wrap crosses on the other side of that call. -tokio-util = { workspace = true } # W4.b P2 (absorbed from busbar-substrate): the client WS handshake for the neutral full-duplex # WEBSOCKET transport, OPTIONAL behind the `runtime` capability — OFF in a default/shipped build. tokio-tungstenite = { workspace = true, optional = true } diff --git a/crates/busbar-kernel/src/limits/mod.rs b/crates/busbar-kernel/src/limits/mod.rs index fc81159837..45f620b9cd 100644 --- a/crates/busbar-kernel/src/limits/mod.rs +++ b/crates/busbar-kernel/src/limits/mod.rs @@ -58,16 +58,18 @@ fn get() -> Option { // figure or an admission figure -- it is a transport body cap, so its home is the neutral values // leaf that already owns it, not a unit. This crate's four call sites now name that home. -/// TLS handshake wall-clock bound (seconds), read per accepted connection in `tls::serve_one`. -pub(crate) fn tls_handshake_timeout_secs() -> u64 { +/// TLS handshake wall-clock bound (seconds), read per accepted connection in +/// `busbar::root::listener`. +pub fn tls_handshake_timeout_secs() -> u64 { get() .map(|l| l.tls_handshake_timeout_secs) .unwrap_or(crate::config::DEFAULT_TLS_HANDSHAKE_TIMEOUT_SECS) } -/// Inbound request-BODY inter-frame read bound (seconds), read per served connection in `tls`. Bounds -/// a slow-loris that dribbles the request body after headers are complete. -pub(crate) fn request_body_read_timeout_secs() -> u64 { +/// Inbound request-BODY inter-frame read bound (seconds), read per served connection in +/// `busbar::root::listener`. Bounds a slow-loris that dribbles the request body after headers are +/// complete. +pub fn request_body_read_timeout_secs() -> u64 { get() .map(|l| l.request_body_read_timeout_secs) .unwrap_or(crate::config::DEFAULT_REQUEST_BODY_READ_TIMEOUT_SECS) diff --git a/crates/busbar-kernel/src/test_support/fixtures.rs b/crates/busbar-kernel/src/test_support/fixtures.rs index 90c8e68af1..d447c002b6 100644 --- a/crates/busbar-kernel/src/test_support/fixtures.rs +++ b/crates/busbar-kernel/src/test_support/fixtures.rs @@ -181,6 +181,17 @@ pub fn build_once( cfg: crate::config::RootCfg, prior: Option<&crate::state::App>, ) -> Result { + // The build installs `cfg.limits` process-wide and KEEPS them (below), so it is a writer of the + // slot `LIMITS_TEST_LOCK` serializes: without the lock, a build in a sibling test lands mid-way + // through a test that holds it (e.g. the root listener's total-deadline test, which installs a + // 2 KiB cap and reads it live for seconds) and swaps the value it is asserting on. Spun on + // `try_lock`, not `blocking_lock`, because callers run inside a tokio runtime as often as not. + let _limits_lock = loop { + if let Ok(guard) = crate::config::limits::LIMITS_TEST_LOCK.try_lock() { + break guard; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + }; // Test-only direct call: there is no outer admin transaction / persist step here, so firing any // resolved governance-credential rotation immediately is correct // and keeps this helper's callers (which assert on rotation taking effect) unchanged. diff --git a/crates/busbar-kernel/src/tls.rs b/crates/busbar-kernel/src/tls.rs index 66cc233631..6dc8c28e75 100644 --- a/crates/busbar-kernel/src/tls.rs +++ b/crates/busbar-kernel/src/tls.rs @@ -1,818 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright (C) 2026 Busbar Inc and contributors -//! Native inbound TLS termination (+ optional mutual-TLS) for the client↔Busbar hop. -//! -//! This module is a thin transport wrapper around the *ingress* listener. It does NOT touch routing, -//! request translation, the breaker, or failover — it only decides, once at startup, whether the -//! accepted TCP stream is handed to axum as-is (plain HTTP, the historical default) or first put -//! through a rustls server handshake. -//! -//! ## Why we drive hyper directly here instead of `axum::serve` -//! -//! `axum::serve` in axum 0.7 is hardwired to a concrete `tokio::net::TcpListener` and constructs its -//! per-connection `IncomingStream` from private fields — there is no public `Listener` trait to -//! implement (that arrived in axum 0.8). Rather than bump axum (which would churn the Router/Service -//! types on the routing hot path this feature is contractually forbidden from touching), the TLS -//! branch reproduces axum::serve's accept loop over hyper-util directly: -//! * accept on the `TcpListener`, -//! * run the rustls handshake, -//! * serve the connection with `hyper_util::server::conn::auto::Builder` (http/1.1) and -//! `TowerToHyperService` bridging the cloned axum `Router`, -//! * drain in-flight connections on shutdown via `hyper_util`'s `GracefulShutdown`. -//! -//! The plain-HTTP path in `main.rs` is left exactly as it was; only `cfg.tls == Some(_)` reaches -//! this module. -//! -//! ## Connection security is opaque here (DECISIONS #40) -//! -//! [`serve`] does not build a `rustls::ServerConfig` and does not decide TLS vs. plaintext for -//! itself: it is handed an already-built `busbar_contract::transport::wire::ConnectionSecurity` -//! and calls nothing on it but `wrap`. Reading the operator's `tls:` config, resolving the key -//! material through the secret kind, and building the rustls config live in -//! `busbar-core-connector` — this module is the LISTENER half of the seam (the accept loop, -//! hyper serving, graceful shutdown), not the connection-security-prep half. -//! -//! ## Crypto provider -//! -//! The process-wide crypto provider TLS needs is installed by the connector -//! (`busbar_core_connector::tls::install_crypto_provider`), with the `ServerConfig` it builds — TLS -//! stays in the connector, and this crate names no TLS library. -//! -//! ## Failure model -//! -//! Any cert/key/CA load or parse error is fatal at startup (`die`) with a message naming the file; -//! key bytes are never logged. A handshake failure on a single connection is logged at debug and -//! drops only that connection — it never crashes the server or affects other clients. - -use std::io; -use std::net::SocketAddr; -use std::sync::Arc; - -use crate::diagnostics::{diag_warn, TLS_ACCEPT_PERSISTENT_FAILURE}; -use std::time::{Duration, Instant}; - -/// Hard wall-clock bound on the TLS handshake for a single accepted connection. A client that -/// connects then stalls (sends nothing / dribbles handshake bytes) must not park a task + FDs -/// indefinitely — this caps the pre-auth slowloris / handshake-flood surface. The cost is incurred -/// BEFORE mTLS client-cert verification, so this guards the unauthenticated edge. -/// Operator-tunable via `limits.tls_handshake_timeout_secs` (default 10s), read through the -/// process-wide `crate::limits` install. A function (not a `const`) so the configured value is read -/// per accepted connection; falls back to the historical 10s when limits aren't installed. -fn handshake_timeout() -> Duration { - Duration::from_secs(crate::limits::tls_handshake_timeout_secs()) -} - -/// Max wall-clock time allowed BETWEEN inbound request-body frames before the connection is dropped. -/// The header-read timeout (`hardened_conn_builder`) covers ONLY the header phase - once headers are -/// complete an unauthenticated slow-loris can dribble the request BODY one byte at a time, holding a -/// connection task, an FD, AND (critically) one of the finite `max_inbound_concurrent` (default 8192) -/// permits indefinitely, starving real traffic. `DefaultBodyLimit` caps total SIZE, not TIME between -/// frames, so it does not help. This wraps every inbound body in a [`TimeoutBody`] that trips when no -/// frame arrives within this bound. Operator-tunable via `limits.request_body_read_timeout_secs` -/// (default 30s), read per connection through the process-wide `crate::limits` install; falls back to -/// the default when limits aren't installed (tests / pre-install). -fn body_read_timeout() -> Duration { - Duration::from_secs(crate::limits::request_body_read_timeout_secs()) -} - -/// MINIMUM sustained throughput a body read must maintain once the grace period has elapsed. The -/// inter-frame timer (`body_read_timeout`) resets on ANY progress at all, so a client that dribbles -/// exactly one byte per `body_read_timeout` interval holds a connection, an FD, and an -/// inbound-concurrency permit indefinitely without ever tripping it. This floor catches that: it -/// bounds RETENTION IN TIME, not size (`DefaultBodyLimit`/the per-request buffer cap already bound -/// size) and not concurrency (`GlobalConcurrencyLimitLayer` already bounds that globally). Hardcoded -/// rather than an operator knob (per owner decision): 1 KiB/s is far below any honest client's -/// sustained rate and comfortably above "a client stalling one byte every ~30s". -const MIN_BODY_THROUGHPUT_BYTES_PER_SEC: u64 = 1024; - -/// Grace period before the throughput floor is evaluated. Before it, `bytes / elapsed` is unstable -/// (a client that pauses briefly before its first frame, or whose first frame is large relative to -/// elapsed time, would false-positive). Hardcoded alongside the floor for the same reason. -const BODY_THROUGHPUT_GRACE: Duration = Duration::from_secs(10); - -/// TOTAL wall-clock deadline for reading one inbound body — the backstop that bounds retention even -/// for a client that stays JUST above the throughput floor forever. DERIVED from the configured body -/// size cap and the throughput floor (`body_cap / floor`), NOT a bare constant: `request_body_max_bytes` -/// is an operator knob with a 1 GiB ceiling, so a fixed total would silently demand an arbitrarily -/// high sustained rate from an honest client uploading near that ceiling. Deriving it means the total -/// always admits exactly "the whole cap, sustained at the floor," regardless of how the operator has -/// configured the cap. -fn total_body_deadline() -> Duration { - let cap_bytes = busbar_kernel::proxy::max_translate_body_bytes() as u64; - Duration::from_secs(cap_bytes / MIN_BODY_THROUGHPUT_BYTES_PER_SEC) -} - -use std::future::Future; -use std::pin::Pin; -use std::task::{Context, Poll}; - -use axum::Router; -use busbar_contract::transport::wire::{ConnectionSecurity, RawIo}; -use bytes::Buf; -use http_body::{Body, Frame, SizeHint}; -use hyper_util::rt::{TokioExecutor, TokioIo}; -use hyper_util::server::conn::auto::Builder as ConnBuilder; -use hyper_util::server::graceful::{GracefulShutdown, Watcher}; -use hyper_util::service::TowerToHyperService; -use tokio::net::TcpListener; -use tokio_util::compat::{FuturesAsyncReadCompatExt, TokioAsyncReadCompatExt}; - -// The cert/key/client-CA PARSING and the `rustls::ServerConfig` BUILD that used to sit here moved -// verbatim to `busbar-core-connector` (DECISIONS #40, the core-side connection-security seam): -// this crate's own inbound listener now names no rustls/cert type of its own for that job — it is -// handed an already-built, opaque `ConnectionSecurity` wrap (`serve`'s `security` parameter, below) -// and calls nothing on it but `wrap`. `read_pem` (below) stays here: `busbar-a2a`'s OUTBOUND client -// identity resolver still reads PEM bytes through it directly, an egress concern this move does not -// touch, so the one turn-a-`SecretRef`-into-PEM function keeps its historical home. - -/// THE ONE ACCEPT-ERROR POLICY, shared by both listener loops below. -/// -/// `accept()` errors come in two kinds and the difference matters enormously: -/// -/// * PER-CONNECTION transients -- `ECONNABORTED` (the peer reset between SYN and accept), -/// `EINTR`. The next `accept()` will very likely succeed, so retrying immediately is right. -/// * RESOURCE EXHAUSTION -- `EMFILE`/`ENFILE` (fd table full), `ENOBUFS`/`ENOMEM`. These do NOT -/// clear on their own: `accept()` fails instantly, every time, until something else releases an -/// fd. An immediate `continue` therefore spins the loop at 100% CPU on a full core, which is -/// exactly when the process can least afford it -- it starves the very tasks whose completion -/// would free the fds, turning a transient fd shortage into a wedged server. -/// -/// So the second kind backs off, exponentially and capped. The cap is deliberately well under a -/// second: it bounds both the CPU burn and how long a shutdown request can sit behind a sleep. -struct AcceptBackoff { - delay: Option, -} - -impl AcceptBackoff { - const FIRST: std::time::Duration = std::time::Duration::from_millis(5); - const CAP: std::time::Duration = std::time::Duration::from_millis(250); - - fn new() -> Self { - Self { delay: None } - } - - /// Clear the backoff after a successful accept. - fn reset(&mut self) { - self.delay = None; - } - - /// How long to wait before the next `accept()`, and advance the schedule. `None` = retry now - /// (a per-connection transient). PURE, so the policy is unit-testable without a listener. - fn next_delay(&mut self, e: &io::Error) -> Option { - if matches!( - e.kind(), - io::ErrorKind::ConnectionAborted | io::ErrorKind::Interrupted - ) { - self.delay = None; - return None; - } - let d = match self.delay { - None => Self::FIRST, - Some(prev) => (prev * 2).min(Self::CAP), - }; - self.delay = Some(d); - Some(d) - } - - /// Apply the policy: log at the right level and sleep if this error class calls for it. - async fn absorb(&mut self, scheme: &'static str, e: &io::Error) { - match self.next_delay(e) { - None => tracing::debug!(error = %e, "{scheme}: accept error; continuing"), - Some(d) => { - diag_warn!( - TLS_ACCEPT_PERSISTENT_FAILURE, - error = %e, - backoff_ms = d.as_millis() as u64, - "{scheme}: accept is failing persistently (fd exhaustion?); backing off", - ); - tokio::time::sleep(d).await; - } - } - } -} - -// ── THE ACCEPT SOURCE ───────────────────────────────────────────────────────────────────────────── - -/// ONE CONNECTION AN INBOUND LISTENER ADMITTED: the accepted socket, not yet on any worker's reactor -/// (so placement may hand it to another worker), its peer, and what the listener holds for it while -/// it is served (its connection slot), released when the connection ends. -pub struct Admitted { - /// The accepted socket, non-blocking. - pub stream: std::net::TcpStream, - /// The far end. - pub peer: SocketAddr, - /// Held for as long as the connection is served. - pub hold: Option>, -} - -/// AN INBOUND LISTENER, as the accept loop drains it: the next admitted connection. Accept errors, -/// the connection cap and the accept backoff are the listener's own, so this never fails; it must -/// be cancel-safe (the loop drops it when shutdown or a hand-off wins the race). -pub trait Admits: Send { - /// The next admitted connection. - fn admit(&mut self) -> impl Future + Send + '_; -} - -/// A tokio socket listener as an accept source, its accept errors absorbed by [`AcceptBackoff`]: -/// what [`serve`] and [`serve_plain`] are handed, and what a build without the connector's -/// listener serves on. -pub struct SocketAdmits { - listener: TcpListener, - backoff: AcceptBackoff, - scheme: &'static str, -} - -impl SocketAdmits { - /// `listener` as an accept source. - #[must_use] - pub fn new(listener: TcpListener) -> Self { - Self { - listener, - backoff: AcceptBackoff::new(), - scheme: "http", - } - } -} - -impl Admits for SocketAdmits { - async fn admit(&mut self) -> Admitted { - loop { - match self.listener.accept().await { - Ok((stream, peer)) => { - self.backoff.reset(); - if let Ok(stream) = stream.into_std() { - return Admitted { - stream, - peer, - hold: None, - }; - } - } - Err(e) => self.backoff.absorb(self.scheme, &e).await, - } - } - } -} - -// ── CONNECTION PLACEMENT BALANCER (thread-per-core data plane) ────────────────────────────────── -// -// The kernel assigns an SO_REUSEPORT connection to a listener by a deterministic 4-tuple hash at -// SYN time, and with FEW long-lived keep-alive connections that assignment is measurably uneven -// (8 connections over 4 workers land 2/2/2/2 only ~4% of the time) and PERSISTS — an overloaded -// worker saturates while an underloaded one idles. This balancer fixes PLACEMENT at ACCEPT time -// and only there: when a worker accepts while carrying at least [`REBALANCE_MARGIN`] more live -// connections than the least-loaded worker, it hands the just-accepted bare `TcpStream` (no TLS, -// no HTTP state exists yet) to that worker, where the connection then lives for its WHOLE life — -// no migration, no cross-worker request state, the per-worker striping invariants untouched. -// Once per-worker counts exceed a few dozen the margin check is statistically never true, so the -// high-concurrency cost is a handful of relaxed loads per ACCEPT (never per request). -// -// No knob, no mode: the composition root wires one balancer across the data workers; the admin -// listener and non-unix builds simply pass `None` and behave exactly as before. - -/// Margin before a handoff: my live connections must exceed the minimum by at least this much. -/// 2 bounds steady-state imbalance at ±1 while making handoff ping-pong impossible (a handoff -/// changes the difference by 2, so it can never immediately reverse). -const REBALANCE_MARGIN: u32 = 2; - -/// Per-worker handoff channel depth. Accepts are rare relative to service time and the fallback -/// (serve locally) is always correct, so this only needs to absorb a small burst. -const HANDOFF_CHANNEL_DEPTH: usize = 16; - -/// One cache-line-padded live-connection count per worker (padded so workers' counter updates -/// never false-share). -#[repr(align(64))] -struct PaddedCount(std::sync::atomic::AtomicU32); - -/// One data worker's handle to the shared placement state: the live-connection counts of every -/// worker, its own index, its own handoff receiver, and every worker's sender. -pub struct ConnBalancer { - counts: Arc<[PaddedCount]>, - txs: Arc<[tokio::sync::mpsc::Sender]>, - me: usize, - rx: tokio::sync::mpsc::Receiver, -} - -impl ConnBalancer { - /// Build the shared placement state for `n` data workers: one handle per worker. - pub fn build(n: usize) -> Vec { - let counts: Arc<[PaddedCount]> = (0..n) - .map(|_| PaddedCount(std::sync::atomic::AtomicU32::new(0))) - .collect(); - let mut txs = Vec::with_capacity(n); - let mut rxs = Vec::with_capacity(n); - for _ in 0..n { - let (tx, rx) = tokio::sync::mpsc::channel(HANDOFF_CHANNEL_DEPTH); - txs.push(tx); - rxs.push(rx); - } - let txs: Arc<[_]> = txs.into(); - rxs.into_iter() - .enumerate() - .map(|(me, rx)| ConnBalancer { - counts: counts.clone(), - txs: txs.clone(), - me, - rx, - }) - .collect() - } - - /// Decide placement for a locally-accepted connection: `None` = serve it here (the count is - /// already incremented and `guard` returned by the caller path), or hand it off to the - /// least-loaded worker. Increment-before-send so the target's count is never transiently low; - /// a full/closed channel rolls the increment back and serves locally — a connection is never - /// dropped by balancing. - fn try_hand_off(&self, admitted: Admitted) -> Option { - use std::sync::atomic::Ordering; - let mine = self.counts[self.me].0.load(Ordering::Relaxed); - let (min_idx, min_val) = self - .counts - .iter() - .enumerate() - .map(|(i, c)| (i, c.0.load(Ordering::Relaxed))) - .min_by_key(|&(_, v)| v) - .expect("at least one worker"); - if min_idx == self.me || mine < min_val.saturating_add(REBALANCE_MARGIN) { - return Some(admitted); - } - // The admitted socket is on no worker's reactor yet, so it crosses the hand-off channel as - // it is (each worker runs its own runtime). - self.counts[min_idx].0.fetch_add(1, Ordering::Relaxed); - match self.txs[min_idx].try_send(admitted) { - Ok(()) => None, - Err(e) => { - // Backpressure (channel full) or a torn-down peer worker (closed): roll back the - // increment we just made and serve the connection HERE — balancing must never drop a - // live connection on hand-off failure. - self.counts[min_idx].0.fetch_sub(1, Ordering::Relaxed); - match e { - tokio::sync::mpsc::error::TrySendError::Full(a) - | tokio::sync::mpsc::error::TrySendError::Closed(a) => Some(a), - } - } - } - } - - /// Count this worker's newly-placed local connection; the returned guard decrements on drop - /// (every exit path, including panics). - fn place_local(&self) -> ConnCountGuard { - use std::sync::atomic::Ordering; - self.counts[self.me].0.fetch_add(1, Ordering::Relaxed); - ConnCountGuard { - counts: self.counts.clone(), - idx: self.me, - } - } - - /// Adopt a handed-off connection: the SENDER already incremented this worker's count, so the - /// guard only owns the decrement. - fn adopt(&self) -> ConnCountGuard { - ConnCountGuard { - counts: self.counts.clone(), - idx: self.me, - } - } -} - -/// RAII live-connection count: decrements its worker's slot when the served connection ends. -struct ConnCountGuard { - counts: Arc<[PaddedCount]>, - idx: usize, -} - -impl Drop for ConnCountGuard { - fn drop(&mut self) { - self.counts[self.idx] - .0 - .fetch_sub(1, std::sync::atomic::Ordering::Relaxed); - } -} - -/// Serve `router` over TLS on `listener` until `shutdown` resolves, then drain in-flight connections. -/// -/// Mirrors `axum::serve(listener, router).with_graceful_shutdown(shutdown)` for the TLS case: -/// each accepted connection is handshook with rustls and served with hyper's auto builder (http/1.1). -/// A handshake or accept error affects only that one connection — the accept loop continues, so a -/// rejected mTLS client (wrong/missing cert) never takes the server down or blocks other clients. -pub async fn serve( - listener: TcpListener, - router: Router, - security: Arc, - shutdown: impl std::future::Future + Send + 'static, - balancer: Option, -) -> io::Result<()> { - let served = Served::new(router, Some(security)); - let admits = SocketAdmits { - listener, - backoff: AcceptBackoff::new(), - scheme: served.scheme, - }; - accept_loop(admits, shutdown, balancer, served).await -} - -/// Serve `router` on the connections an inbound listener `admits` until `shutdown` resolves, then -/// drain: each secured by `security` where set (its handshake bounded as [`serve`]'s is), served by -/// the same hardened builder and body bounds, placed by `balancer` as [`serve`]'s are. -/// -/// # Errors -/// -/// None today; the signature matches [`serve`]'s. -pub async fn serve_admitted( - admits: impl Admits, - router: Router, - security: Option>, - shutdown: impl std::future::Future + Send + 'static, - balancer: Option, -) -> io::Result<()> { - accept_loop(admits, shutdown, balancer, Served::new(router, security)).await -} - -/// THE ONE ACCEPT LOOP both socket listeners run ([`serve`] and [`serve_plain`]): accept until -/// `shutdown`, place each connection (see [`ConnBalancer`]) and serve it under the graceful watcher, -/// then serve the late hand-offs and drain in-flight connections. -async fn accept_loop( - mut admits: impl Admits, - shutdown: impl std::future::Future + Send + 'static, - mut balancer: Option, - served: Served, -) -> io::Result<()> { - let graceful = GracefulShutdown::new(); - let mut shutdown = std::pin::pin!(shutdown); - // Each served connection releases its placement count (if it holds one) and the listener's - // hold when it ends. - let spawn = |a: Admitted, guard: Option| { - let Ok(stream) = tokio::net::TcpStream::from_std(a.stream) else { - return; - }; - let conn = served.clone().tcp(stream, a.peer, graceful.watcher()); - let hold = a.hold; - tokio::spawn(async move { - conn.await; - drop(guard); - drop(hold); - }); - }; - - loop { - // Placement (see `ConnBalancer`): a locally-ADMITTED connection may be handed to the - // least-loaded worker (pre-TLS, bare socket — placement, never migration); a RECEIVED - // hand-off is served here, its count already owned by the sender's increment. - let (admitted, guard) = tokio::select! { - biased; - () = &mut shutdown => break, - handed = async { balancer.as_mut().expect("guarded by if").rx.recv().await }, - if balancer.is_some() => - { - let Some(a) = handed else { continue }; - (a, balancer.as_ref().map(ConnBalancer::adopt)) - } - a = admits.admit() => match balancer.as_ref() { - Some(b) => match b.try_hand_off(a) { - // Handed to the least-loaded worker — nothing to serve here. - None => continue, - Some(a) => (a, Some(b.place_local())), - }, - None => (a, None), - }, - }; - spawn(admitted, guard); - } - - // Drain any hand-offs already in the channel (sent before every worker saw the shutdown): - // serve them under the graceful watcher like any late connection rather than dropping them. - if let Some(mut b) = balancer.take() { - while let Ok(a) = b.rx.try_recv() { - let guard = b.adopt(); - spawn(a, Some(guard)); - } - } - - // Stop accepting; drain in-flight connections (the watched futures complete on their own once - // their requests finish or their clients hang up). - graceful.shutdown().await; - Ok(()) -} - -/// An inbound-body wrapper that bounds the wall-clock time a request body may occupy a connection, -/// on THREE axes. Wraps the hyper `Incoming` body; each `poll_frame` races the inner poll against a -/// `body_read_timeout()` inter-frame timer that is RESET on every delivered frame, AND checks a -/// TOTAL deadline and a MINIMUM-THROUGHPUT floor that are evaluated on every `poll_frame` entry (so -/// no separate timer is needed for either - a dribbling client necessarily polls at least once per -/// byte). The inter-frame timer alone cannot catch a client that dribbles fast enough to keep -/// resetting it forever; the floor closes that gap, and the total deadline backstops a client that -/// stays just above the floor forever. Any of the three failing yields an error, which hyper surfaces -/// as a connection error - dropping the stalled connection and freeing its task, FD, and -/// inbound-concurrency permit. A body that keeps delivering frames promptly and above the floor is -/// passed through unchanged, so a slow-but-progressing large upload is never falsely killed. -/// `SizeHint`/`is_end_stream` delegate to the inner body so framing/content-length behavior is -/// identical to the unwrapped body. -struct TimeoutBody { - inner: B, - timeout: Duration, - // Lazily-armed inter-frame timer. Re-armed after every delivered frame; `None` until the first - // poll so the timer is driven from the runtime clock inside the connection task. - sleep: Option>>, - /// Set on the first `poll_frame`, from the runtime clock - the origin for both the total - /// deadline and the throughput-floor grace period. - started: Option, - /// Total DATA bytes delivered so far (frame trailers/metadata excluded), for the throughput - /// floor's `bytes / elapsed` comparison. - bytes: u64, -} - -impl TimeoutBody { - fn new(inner: B, timeout: Duration) -> Self { - Self { - inner, - timeout, - sleep: None, - started: None, - bytes: 0, - } - } -} - -/// The error a [`TimeoutBody`] yields when the inter-frame bound elapses. Boxed into the router's -/// body-error type; the message is generic (no client bytes) so it is safe to surface. -#[derive(Debug)] -struct BodyReadTimeout; - -impl std::fmt::Display for BodyReadTimeout { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "inbound request body read timed out (slow-loris body bound)" - ) - } -} -impl std::error::Error for BodyReadTimeout {} - -/// The error a [`TimeoutBody`] yields when the TOTAL body deadline or the MINIMUM-THROUGHPUT floor -/// is exceeded - the retention-in-time bound `BodyReadTimeout`'s inter-frame check cannot catch, -/// because a dribbling client that polls at least once per byte keeps resetting that timer forever. -/// Same discipline as `BodyReadTimeout`: generic message, no client bytes. -#[derive(Debug)] -enum BodyBoundExceeded { - Total, - Throughput, -} - -impl std::fmt::Display for BodyBoundExceeded { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Total => write!(f, "inbound request body exceeded its total read deadline"), - Self::Throughput => write!( - f, - "inbound request body fell below the minimum throughput floor" - ), - } - } -} -impl std::error::Error for BodyBoundExceeded {} - -/// Shared by both the `Ready` and `Pending` arms of `poll_frame`: evaluate the total deadline, then -/// (after the grace period) the throughput floor. `None` means neither tripped. -fn check_body_bounds(started: Instant, bytes: u64) -> Option { - let elapsed = started.elapsed(); - if elapsed > total_body_deadline() { - return Some(BodyBoundExceeded::Total); - } - if elapsed > BODY_THROUGHPUT_GRACE - && bytes < MIN_BODY_THROUGHPUT_BYTES_PER_SEC * elapsed.as_secs() - { - return Some(BodyBoundExceeded::Throughput); - } - None -} - -impl Body for TimeoutBody -where - B: Body + Unpin, - B::Data: bytes::Buf, - B::Error: Into>, -{ - type Data = B::Data; - type Error = Box; - - fn poll_frame( - mut self: Pin<&mut Self>, - cx: &mut Context<'_>, - ) -> Poll, Self::Error>>> { - let this = &mut *self; - let started = *this.started.get_or_insert_with(Instant::now); - // Poll the underlying body first: a frame ready right now short-circuits the timer entirely. - match Pin::new(&mut this.inner).poll_frame(cx) { - Poll::Ready(Some(Ok(frame))) => { - // Progress: reset the inter-frame timer for the NEXT frame. - this.sleep = None; - if let Some(data) = frame.data_ref() { - this.bytes += data.remaining() as u64; - } - // Evaluated on EVERY delivered frame too, not just on `Pending`: a dribbling client - // necessarily polls (and delivers) at least once per byte, so checking only on - // `Pending` would never see it. - if let Some(e) = check_body_bounds(started, this.bytes) { - return Poll::Ready(Some(Err(Box::new(e)))); - } - Poll::Ready(Some(Ok(frame))) - } - Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e.into()))), - Poll::Ready(None) => Poll::Ready(None), - Poll::Pending => { - if let Some(e) = check_body_bounds(started, this.bytes) { - return Poll::Ready(Some(Err(Box::new(e)))); - } - // No frame yet: arm (or poll) the inter-frame timer. On elapse, fail the body so the - // connection is dropped rather than parked indefinitely on a dribbling client. - let timeout = this.timeout; - let sleep = this - .sleep - .get_or_insert_with(|| Box::pin(tokio::time::sleep(timeout))); - match sleep.as_mut().poll(cx) { - Poll::Ready(()) => Poll::Ready(Some(Err(Box::new(BodyReadTimeout)))), - Poll::Pending => Poll::Pending, - } - } - } - } - - fn is_end_stream(&self) -> bool { - self.inner.is_end_stream() - } - - fn size_hint(&self) -> SizeHint { - self.inner.size_hint() - } -} - -/// A hyper `Service` that wraps every inbound request's body in a [`TimeoutBody`] before delegating -/// to the axum router (bridged by `TowerToHyperService`). This is the seam that installs the -/// body-read slow-loris bound on BOTH the TLS and plain serve loops, without touching the router or -/// the routing hot path - the router sees an ordinary `http_body::Body`, just one that fails on a -/// stalled inbound stream. -#[derive(Clone)] -struct BodyTimeoutService { - inner: TowerToHyperService, - timeout: Duration, -} - -impl BodyTimeoutService { - fn new(router: Router, timeout: Duration) -> Self { - Self { - inner: TowerToHyperService::new(router), - timeout, - } - } -} - -impl hyper::service::Service> for BodyTimeoutService { - type Response = as hyper::service::Service< - hyper::Request>, - >>::Response; - type Error = as hyper::service::Service< - hyper::Request>, - >>::Error; - type Future = as hyper::service::Service< - hyper::Request>, - >>::Future; - - fn call(&self, req: hyper::Request) -> Self::Future { - let timeout = self.timeout; - let req = req.map(|body| TimeoutBody::new(body, timeout)); - self.inner.call(req) - } -} - -/// Build the hyper auto connection builder shared by BOTH the plain-HTTP and TLS serve loops. -/// -/// Bounds the HTTP/1 HEADER-read phase (slow-loris defense): a client that opens a connection and -/// then trickles request headers one byte at a time would otherwise hold the connection task + FD -/// indefinitely — `DefaultBodyLimit` only applies AFTER headers are fully received, so it does not -/// help here. `header_read_timeout` bounds ONLY the header phase, so it never truncates a -/// legitimately long response stream (a streamed response can run for minutes). 30s is far longer -/// than any real client needs to send its request line + headers, so it cannot false-positive on a -/// healthy connection. `header_read_timeout` requires a `Timer` (hyper panics otherwise), so the -/// Tokio timer is wired to drive it from the runtime clock. -fn hardened_conn_builder() -> ConnBuilder { - let mut builder = ConnBuilder::new(TokioExecutor::new()); - builder - .http1() - .timer(hyper_util::rt::TokioTimer::new()) - .header_read_timeout(std::time::Duration::from_secs(30)); - builder -} - -/// Plain-HTTP serve loop — the no-`tls`-block default path. Mirrors `serve` (and the historical -/// `axum::serve(listener, router).with_graceful_shutdown(shutdown)`) but over the bare TCP stream -/// (no TLS handshake). Routed through the SAME `hardened_conn_builder` so the plain listener gets the -/// identical slow-loris header-read bound the TLS listener has — the previous `axum::serve` path -/// exposed no such timeout, leaving a plain-HTTP edge deployment open to header-trickle clients. -pub async fn serve_plain( - listener: TcpListener, - router: Router, - shutdown: impl std::future::Future + Send + 'static, - balancer: Option, -) -> io::Result<()> { - let admits = SocketAdmits { - listener, - backoff: AcceptBackoff::new(), - scheme: "http", - }; - accept_loop(admits, shutdown, balancer, Served::new(router, None)).await -} - -/// What one listener serves every connection with: the hardened builder (see -/// `hardened_conn_builder`), the router, and the connection-security wrap (`None` = plain). -#[derive(Clone)] -struct Served { - builder: Arc>, - router: Router, - security: Option>, - /// The scheme a log line names. - scheme: &'static str, -} - -impl Served { - fn new(router: Router, security: Option>) -> Self { - let builder = Arc::new(hardened_conn_builder()); - let scheme = if security.is_some() { "tls" } else { "http" }; - Self { - builder, - router, - security, - scheme, - } - } - - /// Serve a single accepted TCP connection: as it arrived on a plain listener (the stream itself, - /// no compat layer), through the wrap on a TLS one. Any failure is contained to this connection. - async fn tcp(self, stream: tokio::net::TcpStream, peer: SocketAddr, watcher: Watcher) { - // TCP_NODELAY parity with axum::serve (which sets it by default on accepted streams). - if let Err(e) = stream.set_nodelay(true) { - tracing::debug!(error = %e, %peer, "{}: set_nodelay failed; continuing", self.scheme); - } - if self.security.is_none() { - return self.io(stream, peer, watcher).await; - } - let raw: Box = Box::new(TokioAsyncReadCompatExt::compat(stream)); - self.raw(raw, peer, watcher).await; - } - - /// Handshake + serve a single accepted connection's byte stream. Any failure is contained to - /// this connection. - /// - /// `security` is the opaque connection-security wrap `serve`'s caller was handed by - /// `busbar-core-connector` (DECISIONS #40): this calls `wrap` on the raw accepted stream and - /// nothing else — it names no rustls type, no cert, no key byte. The `RawIo`/tokio-io compat - /// bridge on either side of `wrap` is the same seam the core connection-security `Tls` wrap - /// crosses inside it. No wrap serves the stream as it arrived. - async fn raw(self, raw: Box, peer: impl std::fmt::Display + Send, watcher: Watcher) { - // Bound the handshake (see `handshake_timeout()`): on elapse the `wrap` future is dropped, - // which closes the half-open connection and frees the task + FDs. Cancel-safe. - let wrapped = match &self.security { - None => raw, - Some(security) => { - match tokio::time::timeout(handshake_timeout(), security.wrap(raw)).await { - Ok(Ok(s)) => s, - // Handshake failure (bad/missing client cert under mTLS, protocol mismatch, - // client gone). Debug-level and dropped — never escalated. NEVER logs key/cert - // bytes. - Ok(Err(e)) => { - tracing::debug!(error = %e, %peer, "tls: handshake failed; dropping connection"); - return; - } - Err(_) => { - tracing::debug!(%peer, "tls: handshake timed out; dropping connection"); - return; - } - } - } - }; - self.io(FuturesAsyncReadCompatExt::compat(wrapped), peer, watcher) - .await; - } - - /// Serve one connection's (already secured) bytes. Any failure is contained to it. - async fn io(self, io: I, peer: impl std::fmt::Display, watcher: Watcher) - where - I: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static, - { - let service = BodyTimeoutService::new(self.router, body_read_timeout()); - let conn = self - .builder - .serve_connection_with_upgrades(TokioIo::new(io), service); - if let Err(e) = watcher.watch(conn).await { - // Per-connection serving error (client reset, malformed request framing). Contained. - tracing::debug!(error = %e, %peer, "{}: connection error", self.scheme); - } - } -} - -#[cfg(test)] -#[path = "tests/tls_tests.rs"] -mod tests; +//! The inbound listener moved to the composition root, `busbar::root::listener`; `read_pem` stays +//! here for its outbound callers (the connector's `tls:` block resolution and `busbar-a2a`'s +//! outbound client identity). // ==== merged from busbar-substrate (W4.b P2 engine drain) ==== /// Resolve a TLS secret reference to its PEM bytes, mapping any resolve error into a clear, diff --git a/crates/busbar-kernel/tests/no_socket.rs b/crates/busbar-kernel/tests/no_socket.rs new file mode 100644 index 0000000000..c9759d8166 --- /dev/null +++ b/crates/busbar-kernel/tests/no_socket.rs @@ -0,0 +1,301 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! THE KERNEL OPENS NO SOCKET (status row 130; BUSBAR-1.6.0.md THE DESIGN §1 and §5). The kernel is a +//! byte pump that knows nothing about transport: the chain is kernel -> `busbar-core-connector` -> +//! transport plugins, the connector binds every listener and dials every connection, and the +//! composition root serves what the connector hands up. So no production source of this crate may +//! name an operating-system socket, a name resolver, or an HTTP client or server stack, and its +//! manifest may ask for none. +//! +//! The tree does not meet that yet (Law 9: a ratchet armed at today's number). [`PINNED`] holds +//! every `(file, needle)` this scan measured when it was armed, and the scan holds the pin to the +//! tree BOTH ways: +//! +//! * a measured pair the pin does not hold is RED: a new socket, resolver or HTTP stack in the +//! kernel, never admitted by adding it to the pin in the change that introduced it; +//! * a pinned pair the tree no longer has is RED too: the drain happened, so the pair is struck in +//! the SAME commit (the pin only goes down). +//! +//! Row 130 is done when [`PINNED`] and [`PINNED_MANIFEST`] are empty. +//! +//! SCOPE. Every `.rs` under `src/` except test scaffolding: a file under a `tests/` directory, a +//! `*_tests.rs` or `tests.rs` file, `src/test_support/**`, and `src/egress/fixtures.rs` (compiled +//! only under `cfg(test)` / `test-support`). Comment lines are blanked first, so prose that names +//! a socket is not a finding. The manifest scan reads `[dependencies]` only (dev-dependencies are +//! test scaffolding). + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +/// What a kernel source may not name: the OS sockets, the resolver, the HTTP client and server +/// stacks, the WebSocket client and the raw socket builder. +const NEEDLES: &[&str] = &[ + "TcpStream", + "TcpListener", + "UdpSocket", + "UnixStream", + "UnixListener", + "lookup_host", + "to_socket_addrs", + "hyper_util::server", + "hyper_util::client", + "serve_connection", + "hyper::client", + "hyper::server", + "tokio_tungstenite", + "reqwest::", + "socket2", + "axum::serve", +]; + +/// What the kernel's `[dependencies]` may not ask for: the crates that are a socket or an HTTP +/// stack by name, and tokio's `net` feature. +const MANIFEST_NEEDLES: &[&str] = &["hyper-util", "tokio-tungstenite", "reqwest", "socket2"]; + +/// The tokio feature that is the socket layer. +const TOKIO_NET: &str = "tokio:net"; + +/// TODAY'S OFFENDERS, measured on predev after row 130 E1 (the hyper serve loop moved to the root). +/// Each line is struck in the commit that drains it. +const PINNED: &[(&str, &str)] = &[ + // E3: the duplex_ws dial (`TcpStream::connect` + the tungstenite client handshake). + ("src/egress/duplex_ws.rs", "TcpStream"), + ("src/egress/duplex_ws.rs", "tokio_tungstenite"), + // E5: the engine pool (the owned hyper-util client, its https connector and resolver). + ("src/egress/engine/client.rs", "hyper::client"), + ("src/egress/engine/https.rs", "TcpStream"), + ("src/egress/engine/https.rs", "hyper_util::client"), + ("src/egress/engine/mod.rs", "TcpStream"), + ("src/egress/engine/mod.rs", "hyper_util::client"), + ("src/egress/engine/observe.rs", "hyper_util::client"), + ("src/egress/engine/pool.rs", "hyper::client"), + ("src/egress/engine/resolve.rs", "hyper_util::client"), + ("src/egress/mod.rs", "reqwest::"), + ("src/secure.rs", "TcpStream"), + // E2: net_guard's system resolver. + ("src/net_guard.rs", "lookup_host"), +]; + +/// TODAY'S MANIFEST OFFENDERS, struck as the code that needs each one leaves. +const PINNED_MANIFEST: &[&str] = &["hyper-util", "tokio-tungstenite", "reqwest", TOKIO_NET]; + +fn crate_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +/// Whether `rel` (crate-relative, `/`-separated) is test scaffolding the scan leaves out. +fn is_scaffolding(rel: &str) -> bool { + let name = rel.rsplit('/').next().unwrap_or(rel); + rel.contains("/tests/") + || name.ends_with("_tests.rs") + || name == "tests.rs" + || rel.starts_with("src/test_support/") + || rel == "src/egress/fixtures.rs" +} + +/// The production `.rs` files under `root/src`, crate-relative and sorted. +fn production_sources(root: &Path) -> Vec { + fn walk(root: &Path, dir: &Path, out: &mut Vec) { + let mut entries: Vec = std::fs::read_dir(dir) + .unwrap_or_else(|e| panic!("read {}: {e}", dir.display())) + .map(|e| e.expect("a directory entry").path()) + .collect(); + entries.sort(); + for path in entries { + if path.is_dir() { + walk(root, &path, out); + } else if path.extension().is_some_and(|x| x == "rs") { + let rel = path + .strip_prefix(root) + .expect("under the crate root") + .to_string_lossy() + .replace('\\', "/"); + if !is_scaffolding(&rel) { + out.push(rel); + } + } + } + } + let mut out = Vec::new(); + walk(root, &root.join("src"), &mut out); + out +} + +/// Every `(file, needle)` a production source under `root` names, comment lines blanked. +fn measure_sources(root: &Path) -> BTreeSet<(String, String)> { + let mut found = BTreeSet::new(); + for rel in production_sources(root) { + let text = + std::fs::read_to_string(root.join(&rel)).unwrap_or_else(|e| panic!("read {rel}: {e}")); + for line in text.lines() { + if line.trim_start().starts_with("//") { + continue; + } + for needle in NEEDLES { + if line.contains(needle) { + found.insert((rel.clone(), (*needle).to_owned())); + } + } + } + } + found +} + +/// Every manifest needle `root/Cargo.toml`'s `[dependencies]` asks for (`tokio:net` for tokio's +/// `net` feature). +fn measure_manifest(root: &Path) -> BTreeSet { + let text = std::fs::read_to_string(root.join("Cargo.toml")).expect("read Cargo.toml"); + let mut found = BTreeSet::new(); + let mut in_deps = false; + for line in text.lines() { + let t = line.trim(); + if t.starts_with('[') { + in_deps = t == "[dependencies]"; + continue; + } + if !in_deps || t.starts_with('#') { + continue; + } + let Some((name, spec)) = t.split_once('=') else { + continue; + }; + let name = name.trim(); + if MANIFEST_NEEDLES.contains(&name) { + found.insert(name.to_owned()); + } + if name == "tokio" && spec.contains("\"net\"") { + found.insert(TOKIO_NET.to_owned()); + } + } + found +} + +/// The two-way verdict of `measured` against `pinned`: the new offenders and the drained pins. +fn verdict( + measured: &BTreeSet, + pinned: &BTreeSet, +) -> (Vec, Vec) { + ( + measured.difference(pinned).cloned().collect(), + pinned.difference(measured).cloned().collect(), + ) +} + +fn pinned_sources() -> BTreeSet<(String, String)> { + PINNED + .iter() + .map(|(f, n)| ((*f).to_owned(), (*n).to_owned())) + .collect() +} + +#[test] +fn the_kernel_names_no_socket_beyond_the_pin() { + let root = crate_root(); + let files = production_sources(&root); + assert!( + files.len() > 100 && files.iter().any(|f| f == "src/lib.rs"), + "the scan reaches the kernel's sources ({} files)", + files.len() + ); + let (new, drained) = verdict(&measure_sources(&root), &pinned_sources()); + assert!( + new.is_empty(), + "busbar-kernel names a socket, resolver or HTTP stack the pin does not hold (THE DESIGN \ + §5: every dial and listener is the connector's): {new:?}" + ); + assert!( + drained.is_empty(), + "pinned offenders the tree no longer has: strike them from PINNED in this commit: \ + {drained:?}" + ); +} + +#[test] +fn the_kernel_manifest_asks_for_no_socket_beyond_the_pin() { + let pinned: BTreeSet = PINNED_MANIFEST.iter().map(|s| (*s).to_owned()).collect(); + let (new, drained) = verdict(&measure_manifest(&crate_root()), &pinned); + assert!( + new.is_empty(), + "busbar-kernel's [dependencies] ask for a socket or HTTP stack the pin does not hold: \ + {new:?}" + ); + assert!( + drained.is_empty(), + "pinned manifest offenders the manifest no longer asks for: strike them from \ + PINNED_MANIFEST in this commit: {drained:?}" + ); +} + +/// RED arms: a planted dial, listener and resolver are each seen; a planted test file and a comment +/// are not; a pin the tree no longer has is reported drained; a planted manifest asks are seen. +#[test] +fn a_planted_socket_is_seen_and_a_drained_pin_is_reported() { + let dir = std::env::temp_dir().join(format!( + "busbar-kernel-no-socket-{}-{}", + std::process::id(), + line!() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("src/tests")).expect("mkdir"); + std::fs::write( + dir.join("src/lib.rs"), + "pub async fn dial() { let _ = tokio::net::TcpStream::connect(\"127.0.0.1:1\").await; }\n\ + // a comment naming TcpListener is prose\n\ + pub async fn names() { let _ = tokio::net::lookup_host(\"x:0\").await; }\n", + ) + .expect("write lib"); + std::fs::write( + dir.join("src/serve.rs"), + "use hyper_util::server::conn::auto::Builder;\n", + ) + .expect("write serve"); + std::fs::write( + dir.join("src/tests/far_end_tests.rs"), + "fn f() { let _ = std::net::TcpListener::bind(\"127.0.0.1:0\"); }\n", + ) + .expect("write test file"); + std::fs::write( + dir.join("Cargo.toml"), + "[dependencies]\ntokio = { workspace = true, features = [\"rt\", \"net\"] }\n\ + hyper-util = { workspace = true }\n[dev-dependencies]\nreqwest = { workspace = true }\n", + ) + .expect("write manifest"); + + let measured = measure_sources(&dir); + let want: BTreeSet<(String, String)> = [ + ("src/lib.rs", "TcpStream"), + ("src/lib.rs", "lookup_host"), + ("src/serve.rs", "hyper_util::server"), + ] + .iter() + .map(|(f, n)| ((*f).to_owned(), (*n).to_owned())) + .collect(); + assert_eq!( + measured, want, + "the dial, the resolver and the server stack are seen; the comment and the test file are not" + ); + + let pinned: BTreeSet<(String, String)> = [("src/gone.rs", "TcpStream")] + .iter() + .map(|(f, n)| ((*f).to_owned(), (*n).to_owned())) + .collect(); + let (new, drained) = verdict(&measured, &pinned); + assert_eq!(new.len(), 3, "every unpinned plant is a new offender"); + assert_eq!( + drained, + vec![("src/gone.rs".to_owned(), "TcpStream".to_owned())], + "a pin the tree no longer has is reported drained" + ); + + let manifest = measure_manifest(&dir); + let want: BTreeSet = ["hyper-util", TOKIO_NET] + .iter() + .map(|s| (*s).to_owned()) + .collect(); + assert_eq!( + manifest, want, + "tokio's net feature and hyper-util are seen; a dev-dependency is not" + ); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/busbar/Cargo.toml b/crates/busbar/Cargo.toml index d9ed4e298c..47fd852999 100644 --- a/crates/busbar/Cargo.toml +++ b/crates/busbar/Cargo.toml @@ -36,7 +36,7 @@ busbar-core-oauth2 = { path = "../busbar-core-oauth2" } busbar-core-admin = { path = "../busbar-core-admin", default-features = false } # The core-side connection-security seam (DECISIONS #40): builds the opaque `ConnectionSecurity` # wrap (identity for a plaintext binding, TLS for a TLS one) the composition root hands -# to `busbar_kernel::tls::serve`. A mandatory sibling, like `busbar-admin`/`busbar-core-oauth2` above — +# to `busbar::root::listener`. A mandatory sibling, like `busbar-admin`/`busbar-core-oauth2` above — # never a plugin, always linked. busbar-core-connector = { path = "../busbar-core-connector" } # `amend_rate_history` decodes the correction body and digests the operator's free-text reason @@ -190,6 +190,14 @@ axum = { workspace = true } # out wrapped so the spend its body reports on the way past has somewhere to land. A passthrough, and # the same version axum is built on — this is the trait axum's own `Body` implements, not a second one. http-body = { workspace = true } +# The inbound listener's serve loop (`root::listener`, moved out of the kernel): the connector +# accepts and hands each stream up, and the root serves it over hyper-util's auto builder, its body +# bounds reading frames through `bytes::Buf`, and the futures-io/tokio-io compat seam the +# `ConnectionSecurity::wrap` result crosses (DECISIONS #40). +hyper = { workspace = true } +hyper-util = { workspace = true } +tokio-util = { workspace = true } +bytes = { workspace = true } tracing = { workspace = true } # Selects `idna`'s unicode-rs backend for every `url` in the binary (see the workspace row). idna_adapter = { workspace = true } @@ -518,7 +526,7 @@ rcgen = { workspace = true, features = ["pem"] } rustls = { workspace = true } rustls-pki-types = { workspace = true } tokio-rustls = { workspace = true } -# tests/tls_listener_h2.rs drives the TLS listener as an HTTP/2 client (OWNER RULING Q137). +# root/tests/listener_h2_tests.rs drives the TLS listener as an HTTP/2 client (OWNER RULING Q137). hyper = { workspace = true } hyper-util = { workspace = true } http = { workspace = true } diff --git a/crates/busbar/src/main.rs b/crates/busbar/src/main.rs index 9b724954b1..cdfa3727aa 100644 --- a/crates/busbar/src/main.rs +++ b/crates/busbar/src/main.rs @@ -48,7 +48,7 @@ use busbar_kernel::{ build_app_from_config, build_split_routers_serving_doors, load_config_from_disk, LoadedConfig, ENV_CONFIG, }; -use busbar_kernel::{config, config_validate, diagnostics, export, snapshot, tls}; +use busbar_kernel::{config, config_validate, diagnostics, export, snapshot}; // The root's own binds listen through the connector's listener (the one listener source). #[cfg(unix)] use busbar_core_connector::listen::{AcceptLimits, Listening, DEFAULT_HANDSHAKE_TIMEOUT}; @@ -1331,8 +1331,8 @@ async fn run(data_workers: usize, late_services: std::sync::Arc> = tls::ConnBalancer::build(cores.len()) - .into_iter() - .map(Some) - .collect(); + // The connection-placement balancer (see `root::listener::ConnBalancer`): one handle per + // worker, fixing SO_REUSEPORT's few-connection imbalance at ACCEPT time (placement only, never + // migration). + let mut balancers: Vec> = + root::listener::ConnBalancer::build(cores.len()) + .into_iter() + .map(Some) + .collect(); let mut handles = Vec::with_capacity(cores.len()); for (i, core_id) in cores.into_iter().enumerate() { let router = data_router.clone(); @@ -1576,15 +1578,15 @@ fn serve_thread_per_core( // once-per-listener-not-once-per-worker fix (see its own doc); grouping the existing seven into a // struct is a larger refactor this fix does not need. async fn serve_listener( - listener: impl tls::Admits, + listener: impl busbar_core_connector::listen::Admits, router: Router, tls_cfg: Option, secret_resolver: Arc, label: &str, shutdown: impl std::future::Future + Send + 'static, // The data workers' connection-placement balancer (`None` for the admin listener and - // non-unix builds — those accept exactly as before). See `tls::ConnBalancer`. - balancer: Option, + // non-unix builds — those accept exactly as before). See `root::listener::ConnBalancer`. + balancer: Option, // Whether THIS call logs "busbar listening" at INFO. `true` for the admin listener and for the // single data listener on non-unix builds (both call this exactly once); on unix's // thread-per-core data plane, `serve_thread_per_core` calls this once PER WORKER on the SAME @@ -1599,7 +1601,9 @@ async fn serve_listener( } else { tracing::debug!(listen = %label, "busbar listening"); } - if let Err(e) = tls::serve_admitted(listener, router, None, shutdown, balancer).await { + if let Err(e) = + root::listener::serve_admitted(listener, router, None, shutdown, balancer).await + { die(format!("server error on '{label}': {e}")); } } @@ -1608,8 +1612,8 @@ async fn serve_listener( // `serve_listener` is never spawned as a task: the admin call runs directly under // `run()` on the control thread, and each per-worker data call is the FIRST thing its // freshly-built runtime `block_on`s — in both shapes this resolve parks a thread that - // is not yet serving anything. It also completes before `tls::serve` below is reached, - // so no connection on this listener can be waiting on it. + // is not yet serving anything. It also completes before `listener::serve_admitted` + // below is reached, so no connection on this listener can be waiting on it. // // The built-in axum/hyper listener below always declares itself TLS-capable // (`transport_capable = true`); `prepare` still fails closed rather than silently @@ -1625,7 +1629,8 @@ async fn serve_listener( tracing::debug!(listen = %label, mtls, "busbar listening (TLS)"); } if let Err(e) = - tls::serve_admitted(listener, router, Some(security), shutdown, balancer).await + root::listener::serve_admitted(listener, router, Some(security), shutdown, balancer) + .await { die(format!("server error on '{label}': {e}")); } diff --git a/crates/busbar/src/root/listener.rs b/crates/busbar/src/root/listener.rs new file mode 100644 index 0000000000..9a1b5d975f --- /dev/null +++ b/crates/busbar/src/root/listener.rs @@ -0,0 +1,804 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! Native inbound TLS termination (+ optional mutual-TLS) for the client↔Busbar hop. +//! +//! This module is a thin transport wrapper around the *ingress* listener. It does NOT touch routing, +//! request translation, the breaker, or failover — it only decides, once at startup, whether the +//! accepted TCP stream is handed to axum as-is (plain HTTP, the historical default) or first put +//! through a rustls server handshake. +//! +//! ## Why we drive hyper directly here instead of `axum::serve` +//! +//! `axum::serve` in axum 0.7 is hardwired to a concrete `tokio::net::TcpListener` and constructs its +//! per-connection `IncomingStream` from private fields — there is no public `Listener` trait to +//! implement (that arrived in axum 0.8). Rather than bump axum (which would churn the Router/Service +//! types on the routing hot path this feature is contractually forbidden from touching), the TLS +//! branch reproduces axum::serve's accept loop over hyper-util directly: +//! * accept on the `TcpListener`, +//! * run the rustls handshake, +//! * serve the connection with `hyper_util::server::conn::auto::Builder` (http/1.1) and +//! `TowerToHyperService` bridging the cloned axum `Router`, +//! * drain in-flight connections on shutdown via `hyper_util`'s `GracefulShutdown`. +//! +//! The plain-HTTP path in `main.rs` is left exactly as it was; only `cfg.tls == Some(_)` reaches +//! this module. +//! +//! ## Connection security is opaque here (DECISIONS #40) +//! +//! [`serve`] does not build a `rustls::ServerConfig` and does not decide TLS vs. plaintext for +//! itself: it is handed an already-built `busbar_contract::transport::wire::ConnectionSecurity` +//! and calls nothing on it but `wrap`. Reading the operator's `tls:` config, resolving the key +//! material through the secret kind, and building the rustls config live in +//! `busbar-core-connector` — this module is the LISTENER half of the seam (the accept loop, +//! hyper serving, graceful shutdown), not the connection-security-prep half. +//! +//! ## Crypto provider +//! +//! The process-wide crypto provider TLS needs is installed by the connector +//! (`busbar_core_connector::tls::install_crypto_provider`), with the `ServerConfig` it builds — TLS +//! stays in the connector, and the composition root names no TLS library. +//! +//! ## Failure model +//! +//! Any cert/key/CA load or parse error is fatal at startup (`die`) with a message naming the file; +//! key bytes are never logged. A handshake failure on a single connection is logged at debug and +//! drops only that connection — it never crashes the server or affects other clients. +//! +//! ## Where the stream comes from +//! +//! The connector accepts: its listening set is the one listener source, and each admitted stream +//! is handed UP to the composition root (BUSBAR-1.6.0.md, inbound listeners). The root serves it +//! here. + +use std::io; +use std::net::SocketAddr; +use std::sync::Arc; + +use busbar_kernel::diagnostics::{diag_warn, TLS_ACCEPT_PERSISTENT_FAILURE}; +use std::time::{Duration, Instant}; + +/// Hard wall-clock bound on the TLS handshake for a single accepted connection. A client that +/// connects then stalls (sends nothing / dribbles handshake bytes) must not park a task + FDs +/// indefinitely — this caps the pre-auth slowloris / handshake-flood surface. The cost is incurred +/// BEFORE mTLS client-cert verification, so this guards the unauthenticated edge. +/// Operator-tunable via `limits.tls_handshake_timeout_secs` (default 10s), read through the +/// process-wide `crate::limits` install. A function (not a `const`) so the configured value is read +/// per accepted connection; falls back to the historical 10s when limits aren't installed. +fn handshake_timeout() -> Duration { + Duration::from_secs(busbar_kernel::limits::tls_handshake_timeout_secs()) +} + +/// Max wall-clock time allowed BETWEEN inbound request-body frames before the connection is dropped. +/// The header-read timeout (`hardened_conn_builder`) covers ONLY the header phase - once headers are +/// complete an unauthenticated slow-loris can dribble the request BODY one byte at a time, holding a +/// connection task, an FD, AND (critically) one of the finite `max_inbound_concurrent` (default 8192) +/// permits indefinitely, starving real traffic. `DefaultBodyLimit` caps total SIZE, not TIME between +/// frames, so it does not help. This wraps every inbound body in a [`TimeoutBody`] that trips when no +/// frame arrives within this bound. Operator-tunable via `limits.request_body_read_timeout_secs` +/// (default 30s), read per connection through the process-wide `crate::limits` install; falls back to +/// the default when limits aren't installed (tests / pre-install). +fn body_read_timeout() -> Duration { + Duration::from_secs(busbar_kernel::limits::request_body_read_timeout_secs()) +} + +/// MINIMUM sustained throughput a body read must maintain once the grace period has elapsed. The +/// inter-frame timer (`body_read_timeout`) resets on ANY progress at all, so a client that dribbles +/// exactly one byte per `body_read_timeout` interval holds a connection, an FD, and an +/// inbound-concurrency permit indefinitely without ever tripping it. This floor catches that: it +/// bounds RETENTION IN TIME, not size (`DefaultBodyLimit`/the per-request buffer cap already bound +/// size) and not concurrency (`GlobalConcurrencyLimitLayer` already bounds that globally). Hardcoded +/// rather than an operator knob (per owner decision): 1 KiB/s is far below any honest client's +/// sustained rate and comfortably above "a client stalling one byte every ~30s". +const MIN_BODY_THROUGHPUT_BYTES_PER_SEC: u64 = 1024; + +/// Grace period before the throughput floor is evaluated. Before it, `bytes / elapsed` is unstable +/// (a client that pauses briefly before its first frame, or whose first frame is large relative to +/// elapsed time, would false-positive). Hardcoded alongside the floor for the same reason. +const BODY_THROUGHPUT_GRACE: Duration = Duration::from_secs(10); + +/// TOTAL wall-clock deadline for reading one inbound body — the backstop that bounds retention even +/// for a client that stays JUST above the throughput floor forever. DERIVED from the configured body +/// size cap and the throughput floor (`body_cap / floor`), NOT a bare constant: `request_body_max_bytes` +/// is an operator knob with a 1 GiB ceiling, so a fixed total would silently demand an arbitrarily +/// high sustained rate from an honest client uploading near that ceiling. Deriving it means the total +/// always admits exactly "the whole cap, sustained at the floor," regardless of how the operator has +/// configured the cap. +fn total_body_deadline() -> Duration { + let cap_bytes = busbar_kernel::proxy::max_translate_body_bytes() as u64; + Duration::from_secs(cap_bytes / MIN_BODY_THROUGHPUT_BYTES_PER_SEC) +} + +use std::future::Future; +use std::pin::Pin; +use std::task::{Context, Poll}; + +use axum::Router; +use busbar_contract::transport::wire::{ConnectionSecurity, RawIo}; +use busbar_core_connector::listen::{Admits, Admitted}; +use bytes::Buf; +use http_body::{Body, Frame, SizeHint}; +use hyper_util::rt::{TokioExecutor, TokioIo}; +use hyper_util::server::conn::auto::Builder as ConnBuilder; +use hyper_util::server::graceful::{GracefulShutdown, Watcher}; +use hyper_util::service::TowerToHyperService; +use tokio::net::TcpListener; +use tokio_util::compat::{FuturesAsyncReadCompatExt, TokioAsyncReadCompatExt}; + +// The cert/key/client-CA PARSING and the `rustls::ServerConfig` BUILD that used to sit here moved +// verbatim to `busbar-core-connector` (DECISIONS #40, the core-side connection-security seam): +// this crate's own inbound listener now names no rustls/cert type of its own for that job — it is +// handed an already-built, opaque `ConnectionSecurity` wrap (`serve`'s `security` parameter, below) +// and calls nothing on it but `wrap`. `read_pem` (below) stays here: `busbar-a2a`'s OUTBOUND client +// identity resolver still reads PEM bytes through it directly, an egress concern this move does not +// touch, so the one turn-a-`SecretRef`-into-PEM function keeps its historical home. + +/// THE ONE ACCEPT-ERROR POLICY, shared by both listener loops below. +/// +/// `accept()` errors come in two kinds and the difference matters enormously: +/// +/// * PER-CONNECTION transients -- `ECONNABORTED` (the peer reset between SYN and accept), +/// `EINTR`. The next `accept()` will very likely succeed, so retrying immediately is right. +/// * RESOURCE EXHAUSTION -- `EMFILE`/`ENFILE` (fd table full), `ENOBUFS`/`ENOMEM`. These do NOT +/// clear on their own: `accept()` fails instantly, every time, until something else releases an +/// fd. An immediate `continue` therefore spins the loop at 100% CPU on a full core, which is +/// exactly when the process can least afford it -- it starves the very tasks whose completion +/// would free the fds, turning a transient fd shortage into a wedged server. +/// +/// So the second kind backs off, exponentially and capped. The cap is deliberately well under a +/// second: it bounds both the CPU burn and how long a shutdown request can sit behind a sleep. +struct AcceptBackoff { + delay: Option, +} + +impl AcceptBackoff { + const FIRST: std::time::Duration = std::time::Duration::from_millis(5); + const CAP: std::time::Duration = std::time::Duration::from_millis(250); + + fn new() -> Self { + Self { delay: None } + } + + /// Clear the backoff after a successful accept. + fn reset(&mut self) { + self.delay = None; + } + + /// How long to wait before the next `accept()`, and advance the schedule. `None` = retry now + /// (a per-connection transient). PURE, so the policy is unit-testable without a listener. + fn next_delay(&mut self, e: &io::Error) -> Option { + if matches!( + e.kind(), + io::ErrorKind::ConnectionAborted | io::ErrorKind::Interrupted + ) { + self.delay = None; + return None; + } + let d = match self.delay { + None => Self::FIRST, + Some(prev) => (prev * 2).min(Self::CAP), + }; + self.delay = Some(d); + Some(d) + } + + /// Apply the policy: log at the right level and sleep if this error class calls for it. + async fn absorb(&mut self, scheme: &'static str, e: &io::Error) { + match self.next_delay(e) { + None => tracing::debug!(error = %e, "{scheme}: accept error; continuing"), + Some(d) => { + diag_warn!( + TLS_ACCEPT_PERSISTENT_FAILURE, + error = %e, + backoff_ms = d.as_millis() as u64, + "{scheme}: accept is failing persistently (fd exhaustion?); backing off", + ); + tokio::time::sleep(d).await; + } + } + } +} + +/// A tokio socket listener as an accept source, its accept errors absorbed by [`AcceptBackoff`]: +/// what [`serve`] and [`serve_plain`] are handed, and what a build without the connector's +/// listener serves on. +pub struct SocketAdmits { + listener: TcpListener, + backoff: AcceptBackoff, + scheme: &'static str, +} + +impl SocketAdmits { + /// `listener` as an accept source. + #[must_use] + pub fn new(listener: TcpListener) -> Self { + Self { + listener, + backoff: AcceptBackoff::new(), + scheme: "http", + } + } +} + +impl Admits for SocketAdmits { + async fn admit(&mut self) -> Admitted { + loop { + match self.listener.accept().await { + Ok((stream, peer)) => { + self.backoff.reset(); + if let Ok(stream) = stream.into_std() { + return Admitted { + stream, + peer, + hold: None, + }; + } + } + Err(e) => self.backoff.absorb(self.scheme, &e).await, + } + } + } +} + +// ── CONNECTION PLACEMENT BALANCER (thread-per-core data plane) ────────────────────────────────── +// +// The kernel assigns an SO_REUSEPORT connection to a listener by a deterministic 4-tuple hash at +// SYN time, and with FEW long-lived keep-alive connections that assignment is measurably uneven +// (8 connections over 4 workers land 2/2/2/2 only ~4% of the time) and PERSISTS — an overloaded +// worker saturates while an underloaded one idles. This balancer fixes PLACEMENT at ACCEPT time +// and only there: when a worker accepts while carrying at least [`REBALANCE_MARGIN`] more live +// connections than the least-loaded worker, it hands the just-accepted bare `TcpStream` (no TLS, +// no HTTP state exists yet) to that worker, where the connection then lives for its WHOLE life — +// no migration, no cross-worker request state, the per-worker striping invariants untouched. +// Once per-worker counts exceed a few dozen the margin check is statistically never true, so the +// high-concurrency cost is a handful of relaxed loads per ACCEPT (never per request). +// +// No knob, no mode: the composition root wires one balancer across the data workers; the admin +// listener and non-unix builds simply pass `None` and behave exactly as before. + +/// Margin before a handoff: my live connections must exceed the minimum by at least this much. +/// 2 bounds steady-state imbalance at ±1 while making handoff ping-pong impossible (a handoff +/// changes the difference by 2, so it can never immediately reverse). +const REBALANCE_MARGIN: u32 = 2; + +/// Per-worker handoff channel depth. Accepts are rare relative to service time and the fallback +/// (serve locally) is always correct, so this only needs to absorb a small burst. +const HANDOFF_CHANNEL_DEPTH: usize = 16; + +/// One cache-line-padded live-connection count per worker (padded so workers' counter updates +/// never false-share). +#[repr(align(64))] +struct PaddedCount(std::sync::atomic::AtomicU32); + +/// One data worker's handle to the shared placement state: the live-connection counts of every +/// worker, its own index, its own handoff receiver, and every worker's sender. +pub struct ConnBalancer { + counts: Arc<[PaddedCount]>, + txs: Arc<[tokio::sync::mpsc::Sender]>, + me: usize, + rx: tokio::sync::mpsc::Receiver, +} + +impl ConnBalancer { + /// Build the shared placement state for `n` data workers: one handle per worker. + pub fn build(n: usize) -> Vec { + let counts: Arc<[PaddedCount]> = (0..n) + .map(|_| PaddedCount(std::sync::atomic::AtomicU32::new(0))) + .collect(); + let mut txs = Vec::with_capacity(n); + let mut rxs = Vec::with_capacity(n); + for _ in 0..n { + let (tx, rx) = tokio::sync::mpsc::channel(HANDOFF_CHANNEL_DEPTH); + txs.push(tx); + rxs.push(rx); + } + let txs: Arc<[_]> = txs.into(); + rxs.into_iter() + .enumerate() + .map(|(me, rx)| ConnBalancer { + counts: counts.clone(), + txs: txs.clone(), + me, + rx, + }) + .collect() + } + + /// Decide placement for a locally-accepted connection: `None` = serve it here (the count is + /// already incremented and `guard` returned by the caller path), or hand it off to the + /// least-loaded worker. Increment-before-send so the target's count is never transiently low; + /// a full/closed channel rolls the increment back and serves locally — a connection is never + /// dropped by balancing. + fn try_hand_off(&self, admitted: Admitted) -> Option { + use std::sync::atomic::Ordering; + let mine = self.counts[self.me].0.load(Ordering::Relaxed); + let (min_idx, min_val) = self + .counts + .iter() + .enumerate() + .map(|(i, c)| (i, c.0.load(Ordering::Relaxed))) + .min_by_key(|&(_, v)| v) + .expect("at least one worker"); + if min_idx == self.me || mine < min_val.saturating_add(REBALANCE_MARGIN) { + return Some(admitted); + } + // The admitted socket is on no worker's reactor yet, so it crosses the hand-off channel as + // it is (each worker runs its own runtime). + self.counts[min_idx].0.fetch_add(1, Ordering::Relaxed); + match self.txs[min_idx].try_send(admitted) { + Ok(()) => None, + Err(e) => { + // Backpressure (channel full) or a torn-down peer worker (closed): roll back the + // increment we just made and serve the connection HERE — balancing must never drop a + // live connection on hand-off failure. + self.counts[min_idx].0.fetch_sub(1, Ordering::Relaxed); + match e { + tokio::sync::mpsc::error::TrySendError::Full(a) + | tokio::sync::mpsc::error::TrySendError::Closed(a) => Some(a), + } + } + } + } + + /// Count this worker's newly-placed local connection; the returned guard decrements on drop + /// (every exit path, including panics). + fn place_local(&self) -> ConnCountGuard { + use std::sync::atomic::Ordering; + self.counts[self.me].0.fetch_add(1, Ordering::Relaxed); + ConnCountGuard { + counts: self.counts.clone(), + idx: self.me, + } + } + + /// Adopt a handed-off connection: the SENDER already incremented this worker's count, so the + /// guard only owns the decrement. + fn adopt(&self) -> ConnCountGuard { + ConnCountGuard { + counts: self.counts.clone(), + idx: self.me, + } + } +} + +/// RAII live-connection count: decrements its worker's slot when the served connection ends. +struct ConnCountGuard { + counts: Arc<[PaddedCount]>, + idx: usize, +} + +impl Drop for ConnCountGuard { + fn drop(&mut self) { + self.counts[self.idx] + .0 + .fetch_sub(1, std::sync::atomic::Ordering::Relaxed); + } +} + +/// Serve `router` over TLS on `listener` until `shutdown` resolves, then drain in-flight connections. +/// +/// Mirrors `axum::serve(listener, router).with_graceful_shutdown(shutdown)` for the TLS case: +/// each accepted connection is handshook with rustls and served with hyper's auto builder (http/1.1). +/// A handshake or accept error affects only that one connection — the accept loop continues, so a +/// rejected mTLS client (wrong/missing cert) never takes the server down or blocks other clients. +pub async fn serve( + listener: TcpListener, + router: Router, + security: Arc, + shutdown: impl std::future::Future + Send + 'static, + balancer: Option, +) -> io::Result<()> { + let served = Served::new(router, Some(security)); + let admits = SocketAdmits { + listener, + backoff: AcceptBackoff::new(), + scheme: served.scheme, + }; + accept_loop(admits, shutdown, balancer, served).await +} + +/// Serve `router` on the connections an inbound listener `admits` until `shutdown` resolves, then +/// drain: each secured by `security` where set (its handshake bounded as [`serve`]'s is), served by +/// the same hardened builder and body bounds, placed by `balancer` as [`serve`]'s are. +/// +/// # Errors +/// +/// None today; the signature matches [`serve`]'s. +pub async fn serve_admitted( + admits: impl Admits, + router: Router, + security: Option>, + shutdown: impl std::future::Future + Send + 'static, + balancer: Option, +) -> io::Result<()> { + accept_loop(admits, shutdown, balancer, Served::new(router, security)).await +} + +/// THE ONE ACCEPT LOOP both socket listeners run ([`serve`] and [`serve_plain`]): accept until +/// `shutdown`, place each connection (see [`ConnBalancer`]) and serve it under the graceful watcher, +/// then serve the late hand-offs and drain in-flight connections. +async fn accept_loop( + mut admits: impl Admits, + shutdown: impl std::future::Future + Send + 'static, + mut balancer: Option, + served: Served, +) -> io::Result<()> { + let graceful = GracefulShutdown::new(); + let mut shutdown = std::pin::pin!(shutdown); + // Each served connection releases its placement count (if it holds one) and the listener's + // hold when it ends. + let spawn = |a: Admitted, guard: Option| { + let Ok(stream) = tokio::net::TcpStream::from_std(a.stream) else { + return; + }; + let conn = served.clone().tcp(stream, a.peer, graceful.watcher()); + let hold = a.hold; + tokio::spawn(async move { + conn.await; + drop(guard); + drop(hold); + }); + }; + + loop { + // Placement (see `ConnBalancer`): a locally-ADMITTED connection may be handed to the + // least-loaded worker (pre-TLS, bare socket — placement, never migration); a RECEIVED + // hand-off is served here, its count already owned by the sender's increment. + let (admitted, guard) = tokio::select! { + biased; + () = &mut shutdown => break, + handed = async { balancer.as_mut().expect("guarded by if").rx.recv().await }, + if balancer.is_some() => + { + let Some(a) = handed else { continue }; + (a, balancer.as_ref().map(ConnBalancer::adopt)) + } + a = admits.admit() => match balancer.as_ref() { + Some(b) => match b.try_hand_off(a) { + // Handed to the least-loaded worker — nothing to serve here. + None => continue, + Some(a) => (a, Some(b.place_local())), + }, + None => (a, None), + }, + }; + spawn(admitted, guard); + } + + // Drain any hand-offs already in the channel (sent before every worker saw the shutdown): + // serve them under the graceful watcher like any late connection rather than dropping them. + if let Some(mut b) = balancer.take() { + while let Ok(a) = b.rx.try_recv() { + let guard = b.adopt(); + spawn(a, Some(guard)); + } + } + + // Stop accepting; drain in-flight connections (the watched futures complete on their own once + // their requests finish or their clients hang up). + graceful.shutdown().await; + Ok(()) +} + +/// An inbound-body wrapper that bounds the wall-clock time a request body may occupy a connection, +/// on THREE axes. Wraps the hyper `Incoming` body; each `poll_frame` races the inner poll against a +/// `body_read_timeout()` inter-frame timer that is RESET on every delivered frame, AND checks a +/// TOTAL deadline and a MINIMUM-THROUGHPUT floor that are evaluated on every `poll_frame` entry (so +/// no separate timer is needed for either - a dribbling client necessarily polls at least once per +/// byte). The inter-frame timer alone cannot catch a client that dribbles fast enough to keep +/// resetting it forever; the floor closes that gap, and the total deadline backstops a client that +/// stays just above the floor forever. Any of the three failing yields an error, which hyper surfaces +/// as a connection error - dropping the stalled connection and freeing its task, FD, and +/// inbound-concurrency permit. A body that keeps delivering frames promptly and above the floor is +/// passed through unchanged, so a slow-but-progressing large upload is never falsely killed. +/// `SizeHint`/`is_end_stream` delegate to the inner body so framing/content-length behavior is +/// identical to the unwrapped body. +struct TimeoutBody { + inner: B, + timeout: Duration, + // Lazily-armed inter-frame timer. Re-armed after every delivered frame; `None` until the first + // poll so the timer is driven from the runtime clock inside the connection task. + sleep: Option>>, + /// Set on the first `poll_frame`, from the runtime clock - the origin for both the total + /// deadline and the throughput-floor grace period. + started: Option, + /// Total DATA bytes delivered so far (frame trailers/metadata excluded), for the throughput + /// floor's `bytes / elapsed` comparison. + bytes: u64, +} + +impl TimeoutBody { + fn new(inner: B, timeout: Duration) -> Self { + Self { + inner, + timeout, + sleep: None, + started: None, + bytes: 0, + } + } +} + +/// The error a [`TimeoutBody`] yields when the inter-frame bound elapses. Boxed into the router's +/// body-error type; the message is generic (no client bytes) so it is safe to surface. +#[derive(Debug)] +struct BodyReadTimeout; + +impl std::fmt::Display for BodyReadTimeout { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "inbound request body read timed out (slow-loris body bound)" + ) + } +} +impl std::error::Error for BodyReadTimeout {} + +/// The error a [`TimeoutBody`] yields when the TOTAL body deadline or the MINIMUM-THROUGHPUT floor +/// is exceeded - the retention-in-time bound `BodyReadTimeout`'s inter-frame check cannot catch, +/// because a dribbling client that polls at least once per byte keeps resetting that timer forever. +/// Same discipline as `BodyReadTimeout`: generic message, no client bytes. +#[derive(Debug)] +enum BodyBoundExceeded { + Total, + Throughput, +} + +impl std::fmt::Display for BodyBoundExceeded { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Total => write!(f, "inbound request body exceeded its total read deadline"), + Self::Throughput => write!( + f, + "inbound request body fell below the minimum throughput floor" + ), + } + } +} +impl std::error::Error for BodyBoundExceeded {} + +/// Shared by both the `Ready` and `Pending` arms of `poll_frame`: evaluate the total deadline, then +/// (after the grace period) the throughput floor. `None` means neither tripped. +fn check_body_bounds(started: Instant, bytes: u64) -> Option { + let elapsed = started.elapsed(); + if elapsed > total_body_deadline() { + return Some(BodyBoundExceeded::Total); + } + if elapsed > BODY_THROUGHPUT_GRACE + && bytes < MIN_BODY_THROUGHPUT_BYTES_PER_SEC * elapsed.as_secs() + { + return Some(BodyBoundExceeded::Throughput); + } + None +} + +impl Body for TimeoutBody +where + B: Body + Unpin, + B::Data: bytes::Buf, + B::Error: Into>, +{ + type Data = B::Data; + type Error = Box; + + fn poll_frame( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll, Self::Error>>> { + let this = &mut *self; + let started = *this.started.get_or_insert_with(Instant::now); + // Poll the underlying body first: a frame ready right now short-circuits the timer entirely. + match Pin::new(&mut this.inner).poll_frame(cx) { + Poll::Ready(Some(Ok(frame))) => { + // Progress: reset the inter-frame timer for the NEXT frame. + this.sleep = None; + if let Some(data) = frame.data_ref() { + this.bytes += data.remaining() as u64; + } + // Evaluated on EVERY delivered frame too, not just on `Pending`: a dribbling client + // necessarily polls (and delivers) at least once per byte, so checking only on + // `Pending` would never see it. + if let Some(e) = check_body_bounds(started, this.bytes) { + return Poll::Ready(Some(Err(Box::new(e)))); + } + Poll::Ready(Some(Ok(frame))) + } + Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e.into()))), + Poll::Ready(None) => Poll::Ready(None), + Poll::Pending => { + if let Some(e) = check_body_bounds(started, this.bytes) { + return Poll::Ready(Some(Err(Box::new(e)))); + } + // No frame yet: arm (or poll) the inter-frame timer. On elapse, fail the body so the + // connection is dropped rather than parked indefinitely on a dribbling client. + let timeout = this.timeout; + let sleep = this + .sleep + .get_or_insert_with(|| Box::pin(tokio::time::sleep(timeout))); + match sleep.as_mut().poll(cx) { + Poll::Ready(()) => Poll::Ready(Some(Err(Box::new(BodyReadTimeout)))), + Poll::Pending => Poll::Pending, + } + } + } + } + + fn is_end_stream(&self) -> bool { + self.inner.is_end_stream() + } + + fn size_hint(&self) -> SizeHint { + self.inner.size_hint() + } +} + +/// A hyper `Service` that wraps every inbound request's body in a [`TimeoutBody`] before delegating +/// to the axum router (bridged by `TowerToHyperService`). This is the seam that installs the +/// body-read slow-loris bound on BOTH the TLS and plain serve loops, without touching the router or +/// the routing hot path - the router sees an ordinary `http_body::Body`, just one that fails on a +/// stalled inbound stream. +#[derive(Clone)] +struct BodyTimeoutService { + inner: TowerToHyperService, + timeout: Duration, +} + +impl BodyTimeoutService { + fn new(router: Router, timeout: Duration) -> Self { + Self { + inner: TowerToHyperService::new(router), + timeout, + } + } +} + +impl hyper::service::Service> for BodyTimeoutService { + type Response = as hyper::service::Service< + hyper::Request>, + >>::Response; + type Error = as hyper::service::Service< + hyper::Request>, + >>::Error; + type Future = as hyper::service::Service< + hyper::Request>, + >>::Future; + + fn call(&self, req: hyper::Request) -> Self::Future { + let timeout = self.timeout; + let req = req.map(|body| TimeoutBody::new(body, timeout)); + self.inner.call(req) + } +} + +/// Build the hyper auto connection builder shared by BOTH the plain-HTTP and TLS serve loops. +/// +/// Bounds the HTTP/1 HEADER-read phase (slow-loris defense): a client that opens a connection and +/// then trickles request headers one byte at a time would otherwise hold the connection task + FD +/// indefinitely — `DefaultBodyLimit` only applies AFTER headers are fully received, so it does not +/// help here. `header_read_timeout` bounds ONLY the header phase, so it never truncates a +/// legitimately long response stream (a streamed response can run for minutes). 30s is far longer +/// than any real client needs to send its request line + headers, so it cannot false-positive on a +/// healthy connection. `header_read_timeout` requires a `Timer` (hyper panics otherwise), so the +/// Tokio timer is wired to drive it from the runtime clock. +fn hardened_conn_builder() -> ConnBuilder { + let mut builder = ConnBuilder::new(TokioExecutor::new()); + builder + .http1() + .timer(hyper_util::rt::TokioTimer::new()) + .header_read_timeout(std::time::Duration::from_secs(30)); + builder +} + +/// Plain-HTTP serve loop — the no-`tls`-block default path. Mirrors `serve` (and the historical +/// `axum::serve(listener, router).with_graceful_shutdown(shutdown)`) but over the bare TCP stream +/// (no TLS handshake). Routed through the SAME `hardened_conn_builder` so the plain listener gets the +/// identical slow-loris header-read bound the TLS listener has — the previous `axum::serve` path +/// exposed no such timeout, leaving a plain-HTTP edge deployment open to header-trickle clients. +pub async fn serve_plain( + listener: TcpListener, + router: Router, + shutdown: impl std::future::Future + Send + 'static, + balancer: Option, +) -> io::Result<()> { + let admits = SocketAdmits { + listener, + backoff: AcceptBackoff::new(), + scheme: "http", + }; + accept_loop(admits, shutdown, balancer, Served::new(router, None)).await +} + +/// What one listener serves every connection with: the hardened builder (see +/// `hardened_conn_builder`), the router, and the connection-security wrap (`None` = plain). +#[derive(Clone)] +struct Served { + builder: Arc>, + router: Router, + security: Option>, + /// The scheme a log line names. + scheme: &'static str, +} + +impl Served { + fn new(router: Router, security: Option>) -> Self { + let builder = Arc::new(hardened_conn_builder()); + let scheme = if security.is_some() { "tls" } else { "http" }; + Self { + builder, + router, + security, + scheme, + } + } + + /// Serve a single accepted TCP connection: as it arrived on a plain listener (the stream itself, + /// no compat layer), through the wrap on a TLS one. Any failure is contained to this connection. + async fn tcp(self, stream: tokio::net::TcpStream, peer: SocketAddr, watcher: Watcher) { + // TCP_NODELAY parity with axum::serve (which sets it by default on accepted streams). + if let Err(e) = stream.set_nodelay(true) { + tracing::debug!(error = %e, %peer, "{}: set_nodelay failed; continuing", self.scheme); + } + if self.security.is_none() { + return self.io(stream, peer, watcher).await; + } + let raw: Box = Box::new(TokioAsyncReadCompatExt::compat(stream)); + self.raw(raw, peer, watcher).await; + } + + /// Handshake + serve a single accepted connection's byte stream. Any failure is contained to + /// this connection. + /// + /// `security` is the opaque connection-security wrap `serve`'s caller was handed by + /// `busbar-core-connector` (DECISIONS #40): this calls `wrap` on the raw accepted stream and + /// nothing else — it names no rustls type, no cert, no key byte. The `RawIo`/tokio-io compat + /// bridge on either side of `wrap` is the same seam the core connection-security `Tls` wrap + /// crosses inside it. No wrap serves the stream as it arrived. + async fn raw(self, raw: Box, peer: impl std::fmt::Display + Send, watcher: Watcher) { + // Bound the handshake (see `handshake_timeout()`): on elapse the `wrap` future is dropped, + // which closes the half-open connection and frees the task + FDs. Cancel-safe. + let wrapped = match &self.security { + None => raw, + Some(security) => { + match tokio::time::timeout(handshake_timeout(), security.wrap(raw)).await { + Ok(Ok(s)) => s, + // Handshake failure (bad/missing client cert under mTLS, protocol mismatch, + // client gone). Debug-level and dropped — never escalated. NEVER logs key/cert + // bytes. + Ok(Err(e)) => { + tracing::debug!(error = %e, %peer, "tls: handshake failed; dropping connection"); + return; + } + Err(_) => { + tracing::debug!(%peer, "tls: handshake timed out; dropping connection"); + return; + } + } + } + }; + self.io(FuturesAsyncReadCompatExt::compat(wrapped), peer, watcher) + .await; + } + + /// Serve one connection's (already secured) bytes. Any failure is contained to it. + async fn io(self, io: I, peer: impl std::fmt::Display, watcher: Watcher) + where + I: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static, + { + let service = BodyTimeoutService::new(self.router, body_read_timeout()); + let conn = self + .builder + .serve_connection_with_upgrades(TokioIo::new(io), service); + if let Err(e) = watcher.watch(conn).await { + // Per-connection serving error (client reset, malformed request framing). Contained. + tracing::debug!(error = %e, %peer, "{}: connection error", self.scheme); + } + } +} + +#[cfg(test)] +#[path = "tests/listener_tests.rs"] +mod tests; + +#[cfg(test)] +#[path = "tests/listener_h2_tests.rs"] +mod h2_tests; diff --git a/crates/busbar/src/root/mod.rs b/crates/busbar/src/root/mod.rs index 5f3156a5c5..5dc004524b 100644 --- a/crates/busbar/src/root/mod.rs +++ b/crates/busbar/src/root/mod.rs @@ -85,6 +85,7 @@ pub mod keyset; pub mod ledger_identity; pub mod legacy; pub mod linked; +pub mod listener; pub(crate) mod loader; pub mod migration; pub mod observe; diff --git a/crates/busbar/tests/tls_listener_h2.rs b/crates/busbar/src/root/tests/listener_h2_tests.rs similarity index 96% rename from crates/busbar/tests/tls_listener_h2.rs rename to crates/busbar/src/root/tests/listener_h2_tests.rs index bc6931cf60..cf55f3fe2b 100644 --- a/crates/busbar/tests/tls_listener_h2.rs +++ b/crates/busbar/src/root/tests/listener_h2_tests.rs @@ -3,8 +3,8 @@ //! THE TLS LISTENER SERVES HTTP/2 TO A CLIENT THAT OFFERS IT (OWNER RULING Q137, 2026-10-04: "offers //! h2 on the TLS listener. yes why not?"), end to end over the production pieces: the connector's -//! `tls::prepare` builds the listener's wrap from a `tls:` block, the kernel's `tls::serve` runs the -//! accept loop and the hardened auto builder, and a real rustls client speaks to it. +//! `tls::prepare` builds the listener's wrap from a `tls:` block, the root's `listener::serve` runs +//! the accept loop and the hardened auto builder, and a real rustls client speaks to it. //! //! * a client offering `h2` negotiates `h2` and is SERVED HTTP/2, not merely advertised it; //! * a client offering only `http/1.1` negotiates `http/1.1` and is served HTTP/1.1, as in 1.5.5. @@ -62,7 +62,7 @@ async fn tls_listener(tls: &TlsCfg) -> (SocketAddr, tokio::sync::oneshot::Sender let shutdown = async { let _ = stopped.await; }; - busbar_kernel::tls::serve(listener, router, security, shutdown, None) + super::serve(listener, router, security, shutdown, None) .await .expect("serve"); }); diff --git a/crates/busbar-kernel/src/tests/tls_tests.rs b/crates/busbar/src/root/tests/listener_tests.rs similarity index 76% rename from crates/busbar-kernel/src/tests/tls_tests.rs rename to crates/busbar/src/root/tests/listener_tests.rs index 709201c277..c58af0448e 100644 --- a/crates/busbar-kernel/src/tests/tls_tests.rs +++ b/crates/busbar/src/root/tests/listener_tests.rs @@ -1,16 +1,16 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright (C) 2026 Busbar Inc and contributors -//! Tests for `crates/busbar-core/src/tls.rs`. +//! Tests for `crates/busbar/src/root/listener.rs`. //! The TLS listener's serving loop (`serve`) over its opaque connection-security wrap. The wrap -//! itself is the connector's — TLS stays in the connector and this crate names no TLS library — so -//! the loop is driven here over a connection-security TEST DOUBLE (a one-line hello, no -//! cryptography): every accepted connection goes through the wrap, a connection the wrap refuses is -//! dropped alone and the listener keeps serving. The same loop over the connector's REAL wrap — the -//! trusted client's 200, the mutual handshake accepting a client certificate chaining to -//! `client_ca` and refusing none or a foreign one, the `http/1.1`-only ALPN — is the connector's -//! `tls/engine_tests.rs`. +//! itself is the connector's — TLS stays in the connector and the composition root names no TLS +//! library — so the loop is driven here over a connection-security TEST DOUBLE (a one-line hello, +//! no cryptography): every accepted connection goes through the wrap, a connection the wrap refuses +//! is dropped alone and the listener keeps serving. The same loop over the connector's REAL wrap — +//! the trusted client's 200, the mutual handshake accepting a client certificate chaining to +//! `client_ca` and refusing none or a foreign one — is the rows at the end of this file, moved +//! here with the listener from the connector's `tls/engine_tests.rs`. use std::net::SocketAddr; use std::time::{Duration, Instant}; @@ -62,7 +62,7 @@ impl busbar_contract::transport::wire::ConnectionSecurity for HelloWrap { /// MOVED to `crate::limits` (same lock, same rules) so that the `InstallGuard` tests living /// beside the static they mutate are serialized against these too — a lock only this file held /// protected these tests from each other but not from those, or those from these. -use crate::limits::LIMITS_TEST_LOCK; +use busbar_kernel::config::limits::LIMITS_TEST_LOCK; /// THE ACCEPT-ERROR POLICY, asserted directly. Both listener loops route every `accept()` error /// through `AcceptBackoff`, so this covers the class rather than one loop. @@ -241,11 +241,11 @@ async fn body_read_timeout_trips_on_stalled_body() { // — LIMITS_TEST_LOCK only serializes the four installers in THIS file against each other, // not against every reader elsewhere. The guard restores whatever was installed before it // (never committed, so it always rolls back) when it drops at the end of this test. - let limits = crate::config::LimitsResolved { + let limits = busbar_kernel::config::LimitsResolved { request_body_read_timeout_secs: 1, - ..crate::config::LimitsResolved::default() + ..busbar_kernel::config::LimitsResolved::default() }; - let _limits_guard = crate::limits::InstallGuard::install(&limits); + let _limits_guard = busbar_kernel::config::limits::InstallGuard::install(&limits); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); @@ -313,15 +313,19 @@ async fn a_rejected_configs_limits_do_not_govern_later_connections() { let _guard = LIMITS_TEST_LOCK.lock().await; // The "accepted config that is already serving": the historical defaults (30s inter-frame). // Itself guarded so this test leaks nothing to the rest of the binary. - let _baseline = crate::limits::InstallGuard::install(&crate::config::LimitsResolved::default()); + let _baseline = busbar_kernel::config::limits::InstallGuard::install( + &busbar_kernel::config::LimitsResolved::default(), + ); { // A candidate config whose build then FAILS. Its limits are live while the build runs… - let _rejected = crate::limits::InstallGuard::install(&crate::config::LimitsResolved { - request_body_read_timeout_secs: 1, - ..crate::config::LimitsResolved::default() - }); + let _rejected = busbar_kernel::config::limits::InstallGuard::install( + &busbar_kernel::config::LimitsResolved { + request_body_read_timeout_secs: 1, + ..busbar_kernel::config::LimitsResolved::default() + }, + ); assert_eq!( - crate::limits::request_body_read_timeout_secs(), + busbar_kernel::limits::request_body_read_timeout_secs(), 1, "sanity: the candidate's bound must really be installed, or the rollback below \ proves nothing" @@ -392,8 +396,9 @@ async fn throughput_floor_trips_on_a_dribble_the_inter_frame_timer_cannot_catch( // `uninstalled_accessors_return_historical_defaults` asserts against the UNINSTALLED state. // That it currently passes is an accident of the values happening to equal the defaults; the // guard makes it a property instead of a coincidence. - let _limits_guard = - crate::limits::InstallGuard::install(&crate::config::LimitsResolved::default()); + let _limits_guard = busbar_kernel::config::limits::InstallGuard::install( + &busbar_kernel::config::LimitsResolved::default(), + ); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); @@ -472,8 +477,9 @@ async fn a_fast_large_upload_is_not_killed_by_the_throughput_floor() { let _guard = LIMITS_TEST_LOCK.lock().await; // Through the RAII guard, not the bare setter — see the note in // `throughput_floor_trips_on_a_dribble_the_inter_frame_timer_cannot_catch`. - let _limits_guard = - crate::limits::InstallGuard::install(&crate::config::LimitsResolved::default()); + let _limits_guard = busbar_kernel::config::limits::InstallGuard::install( + &busbar_kernel::config::LimitsResolved::default(), + ); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); @@ -550,9 +556,9 @@ async fn total_deadline_trips_on_a_body_that_stays_above_the_floor_forever() { use tokio::io::{AsyncReadExt, AsyncWriteExt}; let _guard = LIMITS_TEST_LOCK.lock().await; - let limits = crate::config::LimitsResolved { + let limits = busbar_kernel::config::LimitsResolved { request_body_max_bytes: 2048, // total_body_deadline() = 2048 / 1024 B/s = 2s - ..crate::config::LimitsResolved::default() + ..busbar_kernel::config::LimitsResolved::default() }; // Through the RAII guard, not the bare setter: a bare `install` of this 2 KiB cap LEAKS it // to every test in the binary that reads limits afterward (`install` replaces the whole @@ -561,7 +567,7 @@ async fn total_deadline_trips_on_a_body_that_stays_above_the_floor_forever() { // `busbar_kernel::proxy::max_translate_body_bytes() == DEFAULT_REQUEST_BODY_MAX_BYTES` — so whether the suite // passed depended on that test happening to run BEFORE this one. Never committed, so it // always rolls back at the end of this test. - let _limits_guard = crate::limits::InstallGuard::install(&limits); + let _limits_guard = busbar_kernel::config::limits::InstallGuard::install(&limits); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); @@ -643,8 +649,11 @@ async fn tcp_pair( } /// A server-accepted stream as the accept source admits it. -fn admitted(s: tokio::net::TcpStream, peer: std::net::SocketAddr) -> super::Admitted { - super::Admitted { +fn admitted( + s: tokio::net::TcpStream, + peer: std::net::SocketAddr, +) -> busbar_core_connector::listen::Admitted { + busbar_core_connector::listen::Admitted { stream: s.into_std().unwrap(), peer, hold: None, @@ -797,12 +806,12 @@ async fn server_posture_matches_the_1_5_5_defaults() { // fall back to, exactly like `uninstalled_accessors_return_historical_defaults` pins for the // sibling probe-interval/timeout accessors. assert_eq!( - crate::limits::tls_handshake_timeout_secs(), + busbar_kernel::limits::tls_handshake_timeout_secs(), 10, "tls_handshake_timeout_secs default" ); assert_eq!( - crate::limits::request_body_read_timeout_secs(), + busbar_kernel::limits::request_body_read_timeout_secs(), 30, "request_body_read_timeout_secs default" ); @@ -813,3 +822,221 @@ async fn server_posture_matches_the_1_5_5_defaults() { // doc comment) actually take effect rather than being silently ignored. let _ = super::hardened_conn_builder(); } + +/// THE KERNEL SERVES NO HTTP (BUSBAR-1.6.0.md: the kernel is a byte pump; the connector accepts and +/// hands each stream up; the root serves it, here). The accept loop, the hyper connection builder +/// and the graceful drain moved out of `busbar-kernel` into this module; a kernel source naming +/// any of them again is a second serve loop in the wrong home. +#[test] +fn the_kernel_serves_no_http() { + fn walk(dir: &std::path::Path, out: &mut Vec) { + for entry in std::fs::read_dir(dir).expect("read kernel source dir") { + let path = entry.expect("dir entry").path(); + if path.is_dir() { + walk(&path, out); + } else if path.extension().is_some_and(|e| e == "rs") { + out.push(path); + } + } + } + let root = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../busbar-kernel/src")); + let mut files = Vec::new(); + walk(root, &mut files); + assert!( + !files.is_empty(), + "no kernel sources under {}", + root.display() + ); + let mut hits = Vec::new(); + for file in &files { + let text = std::fs::read_to_string(file).expect("read kernel source"); + for needle in [ + "hyper_util::server", + "TowerToHyperService", + "GracefulShutdown", + ] { + if text.contains(needle) { + hits.push(format!("{}: {needle}", file.display())); + } + } + } + assert!( + hits.is_empty(), + "the kernel serves HTTP again; the serve loop lives in `busbar::root::listener`:\n{}", + hits.join("\n") + ); +} + +// ── THE TLS LISTENER over the connector's production wrap ─────────────────────────────────────── + +fn temp_pem(tag: &str, contents: &str) -> std::path::PathBuf { + let mut p = std::env::temp_dir(); + p.push(format!( + "busbar-engine-tls-{tag}-{}-{:?}.pem", + std::process::id(), + { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + format!( + "{}-{}-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_nanos()), + line!(), + NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + ) + } + )); + std::fs::write(&p, contents).expect("write pem"); + p +} + +fn file(path: &std::path::Path) -> busbar_kernel::config::SecretRef { + busbar_kernel::config::SecretRef::file(path.to_string_lossy().into_owned()) +} + +/// A self-signed server cert for `localhost`/`127.0.0.1`: (cert_pem, key_pem). +fn gen_self_signed() -> (String, String) { + let rcgen::CertifiedKey { cert, signing_key } = + rcgen::generate_simple_self_signed(vec!["localhost".into(), "127.0.0.1".into()]) + .expect("self-signed"); + (cert.pem(), signing_key.serialize_pem()) +} + +/// Boot the busbar TLS listener (`super::serve`) from a `TlsCfg` on an ephemeral port, +/// secured by the connector's PRODUCTION wrap (`busbar_core_connector::tls::prepare`), exactly as +/// `main`'s TLS branch does. Returns the bound address and a shutdown sender. +async fn spawn_tls_server( + tls: &busbar_kernel::config::sections::TlsCfg, +) -> (SocketAddr, tokio::sync::oneshot::Sender<()>) { + let security = busbar_core_connector::tls::prepare( + "engine-tls test", + Some(tls), + &busbar_kernel::config::secret::SecretResolver::builtins_only(), + true, + ) + .expect("valid test TLS config"); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let addr = listener.local_addr().expect("addr"); + let (tx, rx) = tokio::sync::oneshot::channel::<()>(); + let router = axum::Router::new().route("/healthz", axum::routing::get(|| async { "ok" })); + tokio::spawn(async move { + let shutdown = async { + let _ = rx.await; + }; + super::serve(listener, router, security, shutdown, None) + .await + .expect("serve"); + }); + (addr, tx) +} + +/// TLS happy path: a client trusting the server's self-signed cert completes an https request and +/// gets 200. +#[tokio::test] +async fn tls_happy_path_trusted_client_gets_200() { + let (cert_pem, key_pem) = gen_self_signed(); + let tls = busbar_kernel::config::sections::TlsCfg { + cert: file(&temp_pem("srv-cert", &cert_pem)), + key: file(&temp_pem("srv-key", &key_pem)), + client_ca: None, + }; + let (addr, _stop) = spawn_tls_server(&tls).await; + let client = reqwest::Client::builder() + .add_root_certificate(reqwest::Certificate::from_pem(cert_pem.as_bytes()).expect("cert")) + .build() + .expect("client"); + let resp = client + .get(format!("https://localhost:{}/healthz", addr.port())) + .send() + .await + .expect("https request should succeed over TLS"); + assert_eq!(resp.status(), 200); + assert_eq!(resp.text().await.expect("body"), "ok"); +} + +/// A CA and a leaf it signed for `sans`: (ca_pem, leaf_pem, leaf_key_pem). +fn gen_ca_and_leaf(sans: &[&str]) -> (String, String, String) { + let m = busbar_kernel::egress::fixtures::ca_and_leaf(sans); + (m.ca_pem, m.leaf_pem, m.leaf_key_pem) +} + +/// The mutual listener: client certificates must chain to the CA it names. +fn mutual( + srv_cert_pem: &str, + srv_key_pem: &str, + ca_pem: &str, +) -> busbar_kernel::config::sections::TlsCfg { + busbar_kernel::config::sections::TlsCfg { + cert: file(&temp_pem("m-srv-cert", srv_cert_pem)), + key: file(&temp_pem("m-srv-key", srv_key_pem)), + client_ca: Some(file(&temp_pem("m-ca", ca_pem))), + } +} + +fn client_with(srv_cert_pem: &str, identity: Option<(&str, &str)>) -> reqwest::Client { + let mut builder = reqwest::Client::builder() + .add_root_certificate( + reqwest::Certificate::from_pem(srv_cert_pem.as_bytes()).expect("cert"), + ) + .use_rustls_tls(); + if let Some((leaf, key)) = identity { + builder = builder.identity( + reqwest::Identity::from_pem(format!("{leaf}{key}").as_bytes()).expect("identity"), + ); + } + builder.build().expect("client") +} + +/// mTLS required + valid client cert: a client presenting a leaf signed by the configured CA gets +/// 200. +#[tokio::test] +async fn mtls_valid_client_cert_gets_200() { + let (srv_cert_pem, srv_key_pem) = gen_self_signed(); + let (ca_pem, leaf_pem, leaf_key_pem) = gen_ca_and_leaf(&["busbar-client"]); + let (addr, _stop) = spawn_tls_server(&mutual(&srv_cert_pem, &srv_key_pem, &ca_pem)).await; + let resp = client_with(&srv_cert_pem, Some((&leaf_pem, &leaf_key_pem))) + .get(format!("https://localhost:{}/healthz", addr.port())) + .send() + .await + .expect("mTLS request with valid client cert should succeed"); + assert_eq!(resp.status(), 200); +} + +/// mTLS required + no/wrong client cert: the handshake is rejected, the server stays up, and a +/// subsequent valid client still succeeds. +#[tokio::test] +async fn mtls_rejects_bad_client_then_serves_valid() { + let (srv_cert_pem, srv_key_pem) = gen_self_signed(); + let (ca_pem, leaf_pem, leaf_key_pem) = gen_ca_and_leaf(&["busbar-client"]); + let (addr, _stop) = spawn_tls_server(&mutual(&srv_cert_pem, &srv_key_pem, &ca_pem)).await; + let url = format!("https://localhost:{}/healthz", addr.port()); + + // (a) Client presenting NO client cert ⇒ rejected (server requires one). + assert!( + client_with(&srv_cert_pem, None) + .get(&url) + .send() + .await + .is_err(), + "mTLS server must reject a client with no certificate" + ); + // (b) Client presenting a cert from a DIFFERENT CA ⇒ also rejected. + let (_other_ca, wrong_leaf, wrong_key) = gen_ca_and_leaf(&["impostor"]); + assert!( + client_with(&srv_cert_pem, Some((&wrong_leaf, &wrong_key))) + .get(&url) + .send() + .await + .is_err(), + "mTLS server must reject a client cert from an untrusted CA" + ); + // (c) Server survived both rejections and still serves a valid client. + let resp = client_with(&srv_cert_pem, Some((&leaf_pem, &leaf_key_pem))) + .get(&url) + .send() + .await + .expect("server must remain up and serve a valid client after rejecting bad ones"); + assert_eq!(resp.status(), 200); +} diff --git a/qa/kind-isolation.toml b/qa/kind-isolation.toml index d890f7bd34..700c70b9bd 100644 --- a/qa/kind-isolation.toml +++ b/qa/kind-isolation.toml @@ -1623,8 +1623,8 @@ cite = "measured on predev f830faa691 (ARCHITECT 2026-10-07, Law 9); the drain [[law0]] crate = "busbar" axis = "transport" -count = "208" -cite = "measured on predev f830faa691 (ARCHITECT 2026-10-07, Law 9); the drain is THE DESIGN §1 / DoD item 2: core names no instance; LOWERED 215 -> 208, predev 9c0a394771's measure: the drain landed without giving its row back (Law 9: the ceiling ratchets down)" +count = "225" +cite = "measured on predev f830faa691 (ARCHITECT 2026-10-07, Law 9); the drain is THE DESIGN §1 / DoD item 2: core names no instance; LOWERED 215 -> 208, predev 9c0a394771's measure: the drain landed without giving its row back (Law 9: the ceiling ratchets down); REBALANCED 208 -> 225 (+17), the verbatim listener serve loop moved out of busbar-kernel into the root (#710), paired with busbar-kernel x transport 286 -> 269 (-17) so the law0 total does not rise (ARCHITECT RULING 2026-10-10, https://github.com/GetBusbar/busbar/pull/710#issuecomment-6096420101; BUSBAR-1.6.0.md l.475-483 INBOUND-LISTEN: the root's stream hand-up)" [[law0]] crate = "busbar" @@ -1827,8 +1827,8 @@ cite = "measured on predev f830faa691 (ARCHITECT 2026-10-07, Law 9); the drain [[law0]] crate = "busbar-kernel" axis = "transport" -count = "285" -cite = "measured on predev f830faa691 (ARCHITECT 2026-10-07, Law 9); the drain is THE DESIGN §1 / DoD item 2: core names no instance; LOWERED 288 -> 286, predev 9c0a394771's measure: the drain landed without giving its row back (Law 9: the ceiling ratchets down); LOWERED 286 -> 285 by GetBusbar/busbar#570: the kernel's auth proof no longer cites the root's stdio-served proof" +count = "268" +cite = "measured on predev f830faa691 (ARCHITECT 2026-10-07, Law 9); the drain is THE DESIGN §1 / DoD item 2: core names no instance; LOWERED 288 -> 286, predev 9c0a394771's measure: the drain landed without giving its row back (Law 9: the ceiling ratchets down); LOWERED 286 -> 285 by GetBusbar/busbar#570: the kernel's auth proof no longer cites the root's stdio-served proof; REBALANCED 285 -> 268 (-17), the verbatim listener serve loop moved out of busbar-kernel into the root (row 130 E1, #710), paired with busbar x transport 208 -> 225 (+17) so the law0 total does not rise (ARCHITECT RULING 2026-10-10, https://github.com/GetBusbar/busbar/pull/710#issuecomment-6096420101; BUSBAR-1.6.0.md l.475-483 INBOUND-LISTEN: the root's stream hand-up)" [[law0]] crate = "busbar-kernel-breaker" diff --git a/xtask/src/gates/population.rs b/xtask/src/gates/population.rs index 1b7b25a92d..3591dcea02 100644 --- a/xtask/src/gates/population.rs +++ b/xtask/src/gates/population.rs @@ -17,7 +17,7 @@ //! //! * [`Population::below_floor`] — the aggregate ratchet. It is not `> 0`: a walk that collapses to //! a handful of files reports no findings and reads exactly like a clean tree. The floor is a -//! RATCHET pinned AT the measured count ([`FLOOR`], 947 after #703 merged with predev +//! RATCHET pinned AT the measured count ([`FLOOR`], 948 after row 130 E1 merged with predev //! 1c586876f8), not a margin below it: one file fewer is refused until a reviewed diff //! re-measures. It is raised as the tree grows, never lowered to accommodate a scan that stopped //! finding things. @@ -43,12 +43,15 @@ use crate::ctx::{Ctx, Overlay, SourceFile, WalkSpec}; /// dispatch/inline.rs, dispatch/io_slots.rs) and remove three (`busbar-core-connector` stream.rs, /// `busbar-transport-stdio` carrier.rs and conn.rs, whose work moved to the host's I/O). Lowered /// from 948 to 947 when #703 (the MCP-FIX stack) deleted `busbar-plane-mcp/src/tool_plane.rs` and -/// `outputschema.rs` and added `door_sessions.rs`, a reviewed net removal of one file. A drop -/// below 947 is refused until a reviewed diff re-measures. The selftest plant ([`one_file_short`]) +/// `outputschema.rs` and added `door_sessions.rs`, a reviewed net removal of one file. Raised from +/// 947 to 948 when row 130 E1 (#710's ruling) moved the inbound listener's serve loop out of +/// `busbar-kernel/src/tls.rs` into the new `busbar/src/root/listener.rs` (the kernel file stays for +/// `read_pem`, so the tree grew by that one file). A drop below 948 is refused until a reviewed diff +/// re-measures. The selftest plant ([`one_file_short`]) /// cuts the live population to `FLOOR - 1` whatever its size, so predev growing a file no longer /// forces a re-pin for the plant's sake. Lowering the floor is how a gate stops reading the /// repository without saying so, so a diff that lowers it is the diff to refuse. -pub const FLOOR: usize = 947; +pub const FLOOR: usize = 948; /// The `.rs` under `crates/` that are not test scaffolding, plus the accounting to refuse a /// population that cannot support a verdict. From bc8fb936530ca273fbc8f1f17af372ef000fa47e Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:30:10 -0700 Subject: [PATCH 07/59] row 130 E2: net_guard leaves busbar-kernel; the kernel resolves no name itself busbar-kernel/src/net_guard.rs was a glob re-export of the egress unit's guard (busbar_kernel_egress::trust::net) plus SystemResolver, a getaddrinfo on a thread of its own: the one piece of the guard that touched the network. Deleted. - The kernel's call sites name the guard in its homes: the pure host atoms from busbar_contract::net (observability, config_validate, plane_host/guard), the pre-dial guard from busbar_kernel_egress::trust::net (plane_host/egress, egress/duplex_ws, config_validate's ssrf_blocked_host re-export). Same items, same answers. - The name lookup is the connector's. busbar_core_connector::process::SystemNames is the deleted SystemResolver, verbatim, implementing the unit's Resolver seam. The root installs it behind the egress-trust capability (GuardedEgressTrust's third field) with the guard and the TLS wrap, at the same boot step. The kernel's two resolve-then-pin dials (the plane-host egress open and the duplex_ws dial) ask plane_host::egress_trust::InstalledResolver, which reads the capability. With no guarded capability, a production kernel refuses the name (fail closed); a test build resolves through egress::fixtures::system_names, the same body, in test scaffolding. No new static. - The connector reads split_url from the unit instead of through the shim, and depends on busbar-kernel-egress (kind-isolation [[dep]] row, cleanliness -> kernel, allowed). core-oauth2 reads parse_url from busbar_contract::net. busbar-a2a and busbar-voice (legacy, deleted by #787 and #503) reach the unit through the kernel's existing busbar_kernel::proxy::egress_unit re-export, so no new legacy edge. - Tests: the shim suites' four rows the unit's suite did not already hold move to busbar-kernel-egress trust/tests/net_tests.rs; the other 32 were duplicates of it. egress_trust_tests gains the_guarded_seam_resolves_by_the_resolver_behind_it. The root's net_guard_one_judge compares config_validate's re-exports with the unit. no_socket strikes ("src/net_guard.rs", "lookup_host"). - Population floor 948 -> 947 (one non-test file deleted, reviewed). --- Cargo.lock | 1 + crates/busbar-a2a/src/a2a/config.rs | 2 +- crates/busbar-a2a/src/a2a/fetch.rs | 28 +- crates/busbar-a2a/src/a2a/mod.rs | 2 +- crates/busbar-a2a/src/a2a/pushnotify.rs | 8 +- crates/busbar-a2a/src/a2a/relay.rs | 9 +- .../busbar-a2a/src/a2a/tests/fetch_tests.rs | 2 +- .../src/a2a/tests/pushnotify_tests.rs | 2 +- crates/busbar-a2a/src/a2a/words.rs | 2 +- .../src/transport/transport.rs | 4 +- crates/busbar-core-connector/Cargo.toml | 5 + crates/busbar-core-connector/src/guard.rs | 2 +- crates/busbar-core-connector/src/process.rs | 57 +- crates/busbar-core-oauth2/Cargo.toml | 2 +- crates/busbar-core-oauth2/src/routes.rs | 4 +- .../src/trust/tests/net_tests.rs | 164 ++++++ crates/busbar-kernel/Cargo.toml | 6 +- crates/busbar-kernel/src/appbuild.rs | 4 +- .../busbar-kernel/src/config_validate/mod.rs | 16 +- crates/busbar-kernel/src/egress/duplex_ws.rs | 17 +- crates/busbar-kernel/src/egress/fixtures.rs | 36 ++ crates/busbar-kernel/src/egress/mod.rs | 2 +- .../src/egress/tests/duplex_ws_tests.rs | 2 +- crates/busbar-kernel/src/host_services.rs | 2 +- crates/busbar-kernel/src/lib.rs | 3 +- crates/busbar-kernel/src/net_guard.rs | 116 ---- crates/busbar-kernel/src/oauth_as/mod.rs | 7 +- crates/busbar-kernel/src/observability.rs | 23 +- crates/busbar-kernel/src/plane_host/egress.rs | 34 +- .../src/plane_host/egress_trust.rs | 58 +- crates/busbar-kernel/src/plane_host/guard.rs | 4 +- .../plane_host/tests/egress_trust_tests.rs | 36 ++ crates/busbar-kernel/src/plane_host/vtable.rs | 2 +- crates/busbar-kernel/src/preflight.rs | 4 +- crates/busbar-kernel/src/router.rs | 4 +- .../src/tests/net_guard_fetch_tests.rs | 556 ------------------ .../src/tests/net_guard_tests.rs | 452 -------------- .../src/tests/observability_tests.rs | 10 +- crates/busbar-kernel/tests/no_socket.rs | 2 - crates/busbar-voice/src/bin/voice-conform.rs | 6 +- crates/busbar-voice/src/mount.rs | 3 +- .../busbar-voice/src/tests/breaker_tests.rs | 2 +- .../busbar-voice/src/tests/hook_gate_tests.rs | 4 +- crates/busbar-voice/src/topology/mod.rs | 2 +- crates/busbar-voice/src/topology/tests/mod.rs | 2 +- crates/busbar/src/root/connector.rs | 4 +- crates/busbar/tests/net_guard_one_judge.rs | 43 +- qa/kind-isolation.toml | 10 + xtask/src/gates/population.rs | 10 +- 49 files changed, 516 insertions(+), 1260 deletions(-) delete mode 100644 crates/busbar-kernel/src/net_guard.rs delete mode 100644 crates/busbar-kernel/src/tests/net_guard_fetch_tests.rs delete mode 100644 crates/busbar-kernel/src/tests/net_guard_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 1b412d0a16..bf399395c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -566,6 +566,7 @@ dependencies = [ "axum", "busbar-contract", "busbar-kernel", + "busbar-kernel-egress", "busbar-plugin-loader", "busbar-transport-tcp-plugin", "bytes", diff --git a/crates/busbar-a2a/src/a2a/config.rs b/crates/busbar-a2a/src/a2a/config.rs index 0076bcdaa5..5fb69036f7 100644 --- a/crates/busbar-a2a/src/a2a/config.rs +++ b/crates/busbar-a2a/src/a2a/config.rs @@ -299,7 +299,7 @@ pub struct AgentDefCfg { /// /// WHAT IT NEVER PERMITS, and this is the half that makes the knob safe to have: a /// CLOUD-METADATA endpoint. `169.254.169.254` and its family are refused whether this is set or - /// not — see [`busbar_kernel::net_guard::ip_is_cloud_metadata`], whose own doc gives the reason: an + /// not — see [`busbar_contract::net::ip_is_cloud_metadata`], whose own doc gives the reason: an /// `allow_private` that reached IMDS would be a configuration flag that hands out cloud /// credentials. /// diff --git a/crates/busbar-a2a/src/a2a/fetch.rs b/crates/busbar-a2a/src/a2a/fetch.rs index f48ceaa689..b1984bed9f 100644 --- a/crates/busbar-a2a/src/a2a/fetch.rs +++ b/crates/busbar-a2a/src/a2a/fetch.rs @@ -2,7 +2,7 @@ // Copyright (C) 2026 Busbar Inc and contributors //! THE AGENT CARD FETCH: the card-shaped part of a guarded fetch, over the guard in -//! [`busbar_kernel::net_guard`]. +//! [`busbar_kernel::proxy::egress_unit::trust::net`]. //! //! Everything else on this plane decides. This is the one module that REACHES OUT, at a URL an //! operator wrote and following redirects a stranger controls, and it therefore carries the whole @@ -11,7 +11,7 @@ //! ## THE GUARD IS NOT HERE, and that is the point //! //! Resolve-then-pin, the address judgement, the metadata arm that sits ahead of `allow_private`, the -//! hop bound and the body cap all live in [`busbar_kernel::net_guard`]. They were written twice — once here +//! hop bound and the body cap all live in [`busbar_kernel::proxy::egress_unit::trust::net`]. They were written twice — once here //! and once for the MCP dispatch path — and the doc comments in each copy had already started //! citing the other's function names as the reason an ordering was correct. A security control that //! has to cite its twin to explain itself has two implementations and one of them will be the stale @@ -52,7 +52,9 @@ use std::net::IpAddr; -use busbar_kernel::net_guard::{self, AddressRefusal, GuardPolicy, PinnedTarget}; +use busbar_kernel::proxy::egress_unit::trust::net::{ + self, AddressRefusal, GuardPolicy, PinnedTarget, +}; use super::card::{WELL_KNOWN_CARD_PATH, WELL_KNOWN_CARD_PATH_LEGACY}; @@ -61,7 +63,7 @@ use super::card::{WELL_KNOWN_CARD_PATH, WELL_KNOWN_CARD_PATH_LEGACY}; /// Re-exported rather than redeclared: a second trait with the same shape would let a transport be /// written against one and a guard against the other, which is how two implementations of one /// control start. -pub(crate) use busbar_kernel::net_guard::Resolver; +pub(crate) use busbar_kernel::proxy::egress_unit::trust::net::Resolver; /// The operator's fetch policy. Config, therefore intent. #[derive(Clone, Debug, PartialEq, Eq)] @@ -88,7 +90,7 @@ pub(crate) struct FetchPolicy { /// concept and two spellings of it would be two things to learn and two things to get wrong. /// It relaxes the loopback/private ARMS of the guard and NOTHING else: a cloud-metadata name /// and a cloud-metadata address are refused with this set, because - /// [`busbar_kernel::net_guard::judge_address`] tests the metadata arm BEFORE it reads this flag, and + /// [`busbar_kernel::proxy::egress_unit::trust::net::judge_address`] tests the metadata arm BEFORE it reads this flag, and /// the alternate-IPv4-encoding arm is likewise unconditional. An `allow_private` that reached /// IMDS would be a config flag that hands out cloud credentials. pub(crate) allow_private: bool, @@ -320,7 +322,7 @@ impl Pin { } } -/// GUARD ONE HOP AND PIN IT: the card fetch's door onto [`busbar_kernel::net_guard::resolve_and_pin`]. +/// GUARD ONE HOP AND PIN IT: the card fetch's door onto [`busbar_kernel::proxy::egress_unit::trust::net::resolve_and_pin`]. /// /// Returns the parsed URL BESIDE the pin, because the two are needed together and for different /// things: the socket goes to [`PinnedTarget::addr`], and the request carries the URL — its host in @@ -332,10 +334,10 @@ impl Pin { /// /// The one difference between the two callers of the guard that could NOT be parameterised away. /// The MCP dispatch path wants a strict recogniser on an attacker-influenced string, and gets -/// [`busbar_kernel::net_guard::split_url`]. This path must FOLLOW redirects, and following one means +/// [`busbar_kernel::proxy::egress_unit::trust::net::split_url`]. This path must FOLLOW redirects, and following one means /// joining a relative `Location` against the hop that sent it exactly as a client would — which /// needs a real URL type and its resolution rules, not a splitter. Both then bring the host they -/// parsed through the SAME [`busbar_kernel::net_guard::judge_host_name`] and the same resolve-then-pin, so +/// parsed through the SAME [`busbar_kernel::proxy::egress_unit::trust::net::judge_host_name`] and the same resolve-then-pin, so /// what differs is the recognition and never the judgement. pub(crate) fn guard_hop( url: &str, @@ -364,7 +366,7 @@ pub(crate) fn guard_hop( // `http://127.0.0.1` has made ONE decision; making them write two flags would teach that the // second one is harmless. `allow_plaintext` stays its own field because a plaintext fetch of a // PUBLIC host is a different, worse thing than a plaintext fetch of loopback. - if net_guard::judge_scheme(parsed.as_str(), https, guard).is_err() { + if net::judge_scheme(parsed.as_str(), https, guard).is_err() { return Err(FetchRefusal::NotHttps { url: parsed.to_string(), scheme, @@ -382,12 +384,12 @@ pub(crate) fn guard_hop( let host = host.strip_suffix('.').unwrap_or(host); let port = parsed .port_or_known_default() - .unwrap_or_else(|| net_guard::default_port(https)); + .unwrap_or_else(|| net::default_port(https)); // ── THE GUARD. Structural name refusals, then EXACTLY ONE resolution, then EVERY answered // address, then the pin. All of it core's, including the ordering that keeps the // cloud-metadata arm ahead of `allow_private`. - let target = net_guard::resolve_and_pin(host, port, https, resolver, guard)?; + let target = net::resolve_and_pin(host, port, https, resolver, guard)?; Ok((parsed, target)) } @@ -466,7 +468,7 @@ pub(crate) fn fetch_card( if (300..400).contains(&resp.status) { // THE HOP BOUND IS CORE'S. A guard applied correctly to an unbounded chain is a way to // spend the process, and the bound is the same one every guarded fetch gets. - net_guard::refuse_hop_overflow(hops, url.as_str(), policy.guard())?; + net::refuse_hop_overflow(hops, url.as_str(), policy.guard())?; let location = resp .location .as_deref() @@ -499,7 +501,7 @@ pub(crate) fn fetch_card( } // THE BODY CAP IS CORE'S, and it is applied BEFORE the bytes are parsed. - net_guard::refuse_oversized_body(url.as_str(), resp.body.len(), policy.guard())?; + net::refuse_oversized_body(url.as_str(), resp.body.len(), policy.guard())?; let document: serde_json::Value = serde_json::from_slice(&resp.body).map_err(|e| FetchRefusal::NotACard { url: url.to_string(), diff --git a/crates/busbar-a2a/src/a2a/mod.rs b/crates/busbar-a2a/src/a2a/mod.rs index 757c445312..8f31aac98f 100644 --- a/crates/busbar-a2a/src/a2a/mod.rs +++ b/crates/busbar-a2a/src/a2a/mod.rs @@ -33,7 +33,7 @@ // // AND THE ROUTER NOW RELAYS. [`relay`] is the hop `ingress::invoke` makes to the registered backend // agent: it guards and pins the target through the SAME `fetch::guard_hop` — and therefore the -// same `busbar_kernel::net_guard` resolve-then-pin — the card fetch +// same the egress unit's guard (`busbar_kernel::proxy::egress_unit::trust::net`) resolve-then-pin — the card fetch // uses, RE-ASKS the trust question against the live registry immediately before the socket so a // mid-flight demotion is not something an in-flight request escapes, presents BUSBAR'S OWN leased // credential or none, and turns every way the hop can fail into a busbar-attributed error rather diff --git a/crates/busbar-a2a/src/a2a/pushnotify.rs b/crates/busbar-a2a/src/a2a/pushnotify.rs index d85826ad7f..8be429f3bf 100644 --- a/crates/busbar-a2a/src/a2a/pushnotify.rs +++ b/crates/busbar-a2a/src/a2a/pushnotify.rs @@ -31,7 +31,7 @@ //! //! ## The address predicates are NOT re-implemented here //! -//! The CGNAT, unique-local, link-local and alternate-encoding atoms live in [`busbar_kernel::net_guard`] and +//! The CGNAT, unique-local, link-local and alternate-encoding atoms live in [`busbar_kernel::proxy::egress_unit::trust::net`] and //! are used from there. Duplicated security logic is the one kind of duplication that cannot be made //! safe by documenting it: somebody hardens one copy against a new obfuscation and never learns the //! other exists. @@ -131,10 +131,10 @@ pub(crate) struct PinnedCallback { /// /// The tear-out went the other way too — the three ranges this copy had and the shared predicate /// did not (`0.0.0.0/8`, `192.0.0.0/24`, `198.18.0.0/15`) moved INTO -/// [`busbar_kernel::net_guard::ipv4_is_internal`] first, so no guard lost coverage in the unification. That +/// [`busbar_contract::net::ipv4_is_internal`] first, so no guard lost coverage in the unification. That /// ordering is the whole discipline: widen the shared predicate to the union, then delete the copy. pub(crate) fn is_internal_addr(ip: &IpAddr) -> bool { - busbar_kernel::net_guard::ip_is_internal(ip) + busbar_contract::net::ip_is_internal(ip) } /// Split a URL into `(scheme, host)`, the host read by the one shared URL reader. @@ -203,7 +203,7 @@ fn structural_check(url: &str) -> Result { if scheme != "https" { return Err(PushNotifyError::Scheme(scheme)); } - if busbar_kernel::net_guard::is_alternate_ipv4_encoding(&host) { + if busbar_contract::net::is_alternate_ipv4_encoding(&host) { return Err(PushNotifyError::ObfuscatedHost(host)); } // A canonical IP LITERAL is checked directly and is not subject to the resolver's answer at all diff --git a/crates/busbar-a2a/src/a2a/relay.rs b/crates/busbar-a2a/src/a2a/relay.rs index a5c04d7c8f..1c35de6af3 100644 --- a/crates/busbar-a2a/src/a2a/relay.rs +++ b/crates/busbar-a2a/src/a2a/relay.rs @@ -34,7 +34,7 @@ //! ## 2. THE NAME IS RESOLVED ONCE, BY THE GUARD, AND THE JUDGED ADDRESS IS WHAT CONNECTS //! //! This module does not open its own client. It reuses [`super::fetch::guard_hop`], and through it -//! [`busbar_kernel::net_guard::resolve_and_pin`] — the same +//! [`busbar_kernel::proxy::egress_unit::trust::net::resolve_and_pin`] — the same //! guard the card fetch goes through — and hands the surviving address to the transport, which pins //! it. A relay that handed the URL to `reqwest` and let the client resolve the host would reinstate //! the second lookup, which is the whole of DNS rebinding, and would pass every test that does not @@ -90,9 +90,8 @@ use super::creds::{Lease, LeaseError}; use super::fetch::{FetchPolicy, FetchRefusal, HttpResponse, Resolver}; use super::task::TaskState; -use busbar_kernel::{ - breaker::normalize_raw_error, net_guard::PinnedTarget, plane_host::breaker as host_breaker, -}; +use busbar_kernel::proxy::egress_unit::trust::net::PinnedTarget; +use busbar_kernel::{breaker::normalize_raw_error, plane_host::breaker as host_breaker}; /// The HTTP round trip the relay makes, as a seam. /// @@ -1610,7 +1609,7 @@ fn prepare<'a>( admit_id: &mut busbar_contract::abi::hot::AdmissionId, ) -> Result<(url::Url, PinnedTarget, OutboundRelayRequest), RelayRefusal> { // ── THE GUARD. One resolution, every answered address judged, one pinned address out. It is - // `busbar_kernel::net_guard`'s, reached through the card fetch's hop door, so a relayed submission + // the egress unit's guard (`busbar_kernel::proxy::egress_unit::trust::net`)'s, reached through the card fetch's hop door, so a relayed submission // and a card fetch cannot be guarded to two different standards. // `call.policy`, NOT `seam.policy()`. The seam answers with the plane's fail-closed default and // knows nothing about any registration; the call carries the one the operator's `allow_private:` diff --git a/crates/busbar-a2a/src/a2a/tests/fetch_tests.rs b/crates/busbar-a2a/src/a2a/tests/fetch_tests.rs index b5be265c55..7deaf7015c 100644 --- a/crates/busbar-a2a/src/a2a/tests/fetch_tests.rs +++ b/crates/busbar-a2a/src/a2a/tests/fetch_tests.rs @@ -830,7 +830,7 @@ fn allow_private_never_opens_a_cloud_metadata_target() { } /// `allow_private` ADMITS PLAINTEXT, because that is what the word means on the `tools:` plane -/// (both planes ask `busbar_kernel::net_guard::judge_scheme`, which refuses `http://` with exactly +/// (both planes ask `busbar_kernel::proxy::egress_unit::trust::net::judge_scheme`, which refuses `http://` with exactly /// `!https && !plaintext_admissible()`), and a /// loopback agent that could only be reached over TLS is a knob that does not do its job. #[test] diff --git a/crates/busbar-a2a/src/a2a/tests/pushnotify_tests.rs b/crates/busbar-a2a/src/a2a/tests/pushnotify_tests.rs index 281da258ab..0054a54fed 100644 --- a/crates/busbar-a2a/src/a2a/tests/pushnotify_tests.rs +++ b/crates/busbar-a2a/src/a2a/tests/pushnotify_tests.rs @@ -49,7 +49,7 @@ fn every_internal_range_is_refused() { ("IETF protocol 192.0.0/24", v4(192, 0, 0, 8)), ("benchmarking 198.18/15", v4(198, 19, 0, 1)), // AZURE WIRESERVER AND THE DOCUMENTATION BLOCK were missing from this plane's private copy - // of the predicate while the shared one in `net_guard` had them. `168.63.129.16` is the + // of the predicate while the shared one in `busbar_contract::net` had them. `168.63.129.16` is the // whole point: it is a PUBLIC address, so every range check in the copy missed it, and a // caller could register it as a push callback and have busbar fetch Azure's platform // metadata on its behalf. The copy's own doc claimed to cover "a cloud metadata address". diff --git a/crates/busbar-a2a/src/a2a/words.rs b/crates/busbar-a2a/src/a2a/words.rs index 54641ddb4c..14dd6836d5 100644 --- a/crates/busbar-a2a/src/a2a/words.rs +++ b/crates/busbar-a2a/src/a2a/words.rs @@ -10,7 +10,7 @@ //! shape is a body the official TCK rejects by schema. //! //! That split — **a caller keeps its refusal VOCABULARY, not its DECISION** — is -//! `busbar_kernel::net_guard`'s rule, stated for a second concern. It is why `mcp/envelope.rs` and this +//! the egress unit's guard (`busbar_kernel::proxy::egress_unit::trust::net`)'s rule, stated for a second concern. It is why `mcp/envelope.rs` and this //! file can say completely different things about the same refusal without either of them deciding //! when it happens. diff --git a/crates/busbar-contract/src/transport/transport.rs b/crates/busbar-contract/src/transport/transport.rs index 5d53895310..0b6b944f91 100644 --- a/crates/busbar-contract/src/transport/transport.rs +++ b/crates/busbar-contract/src/transport/transport.rs @@ -188,8 +188,8 @@ pub enum Transport { /// property the transport can assume. ARMED under the `runtime` capability: the neutral WS /// transport is real on both legs — the ingress WS-upgrade acceptor (`crate::ingress::duplex_ws`) /// presents an upgraded socket as the `serve_messages` channel, and the egress WS dialer - /// (`crate::egress::duplex_ws`) dials an upstream `wss://` THROUGH `net_guard` and hands back the - /// same channel. A duplex caller selects this variant and lets the transport open the socket. + /// (`crate::egress::duplex_ws`) dials an upstream `wss://` THROUGH the egress unit's guard and + /// hands back the same channel. A duplex caller selects this variant and lets the transport open the socket. WebSocket, } diff --git a/crates/busbar-core-connector/Cargo.toml b/crates/busbar-core-connector/Cargo.toml index 079fe96c80..1efbf67c17 100644 --- a/crates/busbar-core-connector/Cargo.toml +++ b/crates/busbar-core-connector/Cargo.toml @@ -33,6 +33,11 @@ busbar-contract = { path = "../busbar-contract" } # config and implements its `DestJudge` trait with the one destination guard (`guard`, `process`); # `busbar-kernel` names nothing here. busbar-kernel = { path = "../busbar-kernel", default-features = false } +# The egress unit's one http(s) URL reader (`trust::net::split_url`, the destination judge's URL arm +# and the metadata carve-outs' host), and its `Resolver` seam, which the system name lookup the +# kernel's remaining resolve-then-pin dials ask (`process::SystemNames`) implements: the kernel +# resolves no name itself (row 130 E2). One-way, like the edge above. +busbar-kernel-egress = { path = "../busbar-kernel-egress" } rustls = { workspace = true } rustls-pki-types = { workspace = true } tokio-rustls = { workspace = true } diff --git a/crates/busbar-core-connector/src/guard.rs b/crates/busbar-core-connector/src/guard.rs index 46c319abba..6fc25a358f 100644 --- a/crates/busbar-core-connector/src/guard.rs +++ b/crates/busbar-core-connector/src/guard.rs @@ -330,7 +330,7 @@ impl Metadata { let mut by_host: HashMap> = HashMap::new(); for (url, own) in &d.url_allow { // The one http(s) URL reader; a URL it cannot read names no host, so carves nothing. - if let Ok((_, host, _, _)) = busbar_kernel::net_guard::split_url(url) { + if let Ok((_, host, _, _)) = busbar_kernel_egress::trust::net::split_url(url) { by_host .entry(norm(&host)) .or_default() diff --git a/crates/busbar-core-connector/src/process.rs b/crates/busbar-core-connector/src/process.rs index 1bb205b6d0..335f70cdbf 100644 --- a/crates/busbar-core-connector/src/process.rs +++ b/crates/busbar-core-connector/src/process.rs @@ -133,9 +133,11 @@ fn split(dest: &str) -> Result<(String, u16, bool), u64> { if let Some((scheme, rest)) = dest.split_once("://") { let lowered = format!("{}://{rest}", scheme.to_ascii_lowercase()); // The one http(s) URL reader (scheme allowlist, userinfo refused, the scheme's port). - return match busbar_kernel::net_guard::split_url(&lowered) { + return match busbar_kernel_egress::trust::net::split_url(&lowered) { Ok((https, host, port, _)) => Ok((host, port, https)), - Err(busbar_kernel::net_guard::AddressRefusal::Scheme { .. }) => Err(DEST_SCHEME), + Err(busbar_kernel_egress::trust::net::AddressRefusal::Scheme { .. }) => { + Err(DEST_SCHEME) + } Err(_) => Err(DEST_NO_HOST), }; } @@ -351,6 +353,57 @@ pub fn own_ports(listens: &[&str]) -> Vec { .collect() } +/// THE SYSTEM NAME LOOKUP the kernel's remaining resolve-then-pin dials ask through its egress-trust +/// capability (row 130 E2: the kernel opens no socket, a resolver's included, so its lookup is the +/// connector's; the root installs this behind the capability at boot). Returns EVERY address the +/// name answered with, de-duplicated but otherwise untouched and unsorted: filtering or re-ordering +/// would quietly decide which address the guard gets to judge, and the guard judges all of them; a +/// name answering one address under both a v4 and a v6 query is ONE fact. +/// +/// ## Why a thread of its own +/// +/// The seam is synchronous, so this runs `getaddrinfo` and waits. Not `Handle::current().block_on` +/// and not `block_in_place`: both make assumptions about the caller (that there IS a runtime, that +/// it is multi-threaded) that a synchronous seam cannot make, and its callers reach it from +/// different postures — a foreign thread already inside `Runtime::block_on`, a boxed future on a +/// shared worker, a plain `async fn`. A thread with its own current-thread runtime has no such +/// precondition and cannot panic on the caller's behalf. (Moved verbatim from the kernel's deleted +/// `net_guard::SystemResolver`.) +#[derive(Clone, Copy, Debug, Default)] +pub struct SystemNames; + +impl busbar_kernel_egress::trust::net::Resolver for SystemNames { + fn resolve(&self, host: &str) -> Result, String> { + // Port zero: this seam answers about ADDRESSES. The port belongs to the URL and is applied + // by the caller that built the pin, so asking the resolver about one would be asking a + // second question. + let target = format!("{host}:0"); + std::thread::scope(|s| { + s.spawn(|| { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| format!("name lookup: could not start a runtime: {e}"))?; + rt.block_on(async move { + let answered = tokio::net::lookup_host(target) + .await + .map_err(|e| e.to_string())?; + let mut out: Vec = Vec::new(); + for sa in answered { + let ip = sa.ip(); + if !out.contains(&ip) { + out.push(ip); + } + } + Ok(out) + }) + }) + .join() + .map_err(|_| "name lookup: the worker thread panicked".to_string())? + }) + } +} + #[cfg(test)] #[path = "tests/process_tests.rs"] mod tests; diff --git a/crates/busbar-core-oauth2/Cargo.toml b/crates/busbar-core-oauth2/Cargo.toml index af7b94e21d..1a3eff1091 100644 --- a/crates/busbar-core-oauth2/Cargo.toml +++ b/crates/busbar-core-oauth2/Cargo.toml @@ -2,7 +2,7 @@ # busbar-core into its own sibling crate. # # ONE-WAY DEPENDENCY (Cargo-enforced): this crate depends on busbar-core (to reach `CoreRouter`, -# `App`/`AppHandle`, `net_guard`, the diagnostics macros, and the `oauth_as::config` types the +# `App`/`AppHandle`, the diagnostics macros, and the `oauth_as::config` types the # single-document config pipeline still owns). busbar-core depends on NOTHING here — naming this # crate from core would be a dependency cycle, which Cargo refuses outright. Core reaches this # plane's runtime object only through the small seam in `busbar_core::oauth_as::seam` diff --git a/crates/busbar-core-oauth2/src/routes.rs b/crates/busbar-core-oauth2/src/routes.rs index d34370d017..83a39f9791 100644 --- a/crates/busbar-core-oauth2/src/routes.rs +++ b/crates/busbar-core-oauth2/src/routes.rs @@ -531,14 +531,14 @@ fn client_and_scope_of(target: &str) -> Option<(String, String, String)> { /// the credential, and a full URI puts an attacker-chosen path and query on the screen next to it, /// which is room to write text that argues with the page around it. /// -/// Read by the one shared URL reader ([`busbar_kernel::net_guard::parse_url`], WHATWG rules for +/// Read by the one shared URL reader ([`busbar_contract::net::parse_url`], WHATWG rules for /// `https`), so the host named here is the host the browser contacts: a userinfo before the last /// `@` is dropped (`https://client.example@evil.example/cb` names `evil.example`), and a `\` ends the /// authority (`https://evil.example\@trusted.example/cb` names `evil.example`, not the /// `trusted.example` a reader splitting only at `/` showed). A URI with no readable host names /// nothing, and the screen says so. fn host_of(redirect_uri: &str) -> String { - let Ok(parts) = busbar_kernel::net_guard::parse_url(redirect_uri) else { + let Ok(parts) = busbar_contract::net::parse_url(redirect_uri) else { return String::new(); }; let host = if parts.host.contains(':') { diff --git a/crates/busbar-kernel-egress/src/trust/tests/net_tests.rs b/crates/busbar-kernel-egress/src/trust/tests/net_tests.rs index f7aac247df..0eb81ea526 100644 --- a/crates/busbar-kernel-egress/src/trust/tests/net_tests.rs +++ b/crates/busbar-kernel-egress/src/trust/tests/net_tests.rs @@ -1077,3 +1077,167 @@ fn the_structural_check_refuses_every_loopback_spelling() { ); } } + +// ── Moved from busbar-kernel's deleted `net_guard` shim suites (row 130 E2): the rows those suites +// held that this one did not. ─────────────────────────────────────────────────────────────────── + +/// The NAME arm is the ONLY defence for a metadata host that answers on a PUBLIC address. +/// +/// `ip_is_cloud_metadata` covers link-local /16 plus three literals plus IMDSv6. A metadata +/// endpoint reachable at a globally-routable address matches NONE of them, so if the name is not +/// in `METADATA_HOSTS` the resolve-then-pin path dials it. +#[test] +fn a_metadata_name_on_a_public_address_is_refused_by_its_name_alone() { + struct Stub(IpAddr); + impl Resolver for Stub { + fn resolve(&self, _host: &str) -> Result, String> { + Ok(vec![self.0]) + } + } + // Equinix Metal's metadata service answers on a public address, which is exactly why it is on + // the denylist by NAME: no address predicate can catch it. + let public = IpAddr::V4(Ipv4Addr::new(147, 75, 1, 1)); + let got = resolve_and_pin( + "metadata.platformequinix.com", + 443, + true, + &Stub(public), + GuardPolicy::default(), + ); + assert!( + got.is_err(), + "metadata.platformequinix.com resolved to a PUBLIC address was PINNED, not refused: {got:?}" + ); +} + +/// THE HOST-STRING GUARDS MUST JUDGE THE NAT64 EMBEDDING TOO, not just the address predicates. +/// +/// [`ip_is_cloud_metadata`] and [`ip_is_internal`] take an already-parsed `IpAddr` and were fixed +/// first. The three guards below take a HOST STRING and parse it themselves, and each kept its own +/// `to_ipv4()` unwrap — so `64:ff9b::a9fe:a9fe` parsed clean, matched no v6 range, and read as an +/// ordinary public address. That gap was live after the address-level fix landed, which is exactly +/// why this test exists separately from +/// `nat64_embedded_ipv4_is_judged_by_the_shared_predicates`: fixing the predicate does NOT fix the +/// callers that never call it. +/// +/// [`ssrf_blocked_host`] is the pre-flight guard for OAuth token-endpoint URLs and for MCP +/// tool-call ARGUMENT hosts — attacker-influenced input on the classic SSRF injection path — so a +/// bypass here is reachable without any DNS answer at all. +#[test] +fn nat64_embedded_ipv4_is_judged_by_the_host_string_guards() { + // Every spelling of the IMDS target 169.254.169.254 that a caller can hand us. + for meta in [ + "169.254.169.254", // bare v4 + "::ffff:169.254.169.254", // v4-mapped + "64:ff9b::a9fe:a9fe", // NAT64 well-known (RFC 6052) + "64:ff9b:1::a9fe:a9fe", // NAT64 local-use (RFC 8215), zero middle + "64:ff9b:1:fffe::a9fe:a9fe", // NAT64 local-use, non-zero middle + ] { + let url = format!("http://[{meta}]/latest/meta-data/"); + let url = if meta.contains(':') { + url + } else { + format!("http://{meta}/latest/meta-data/") + }; + assert!( + ssrf_blocked_host(&url, &[], false, &[]).is_some(), + "{meta} reaches the cloud-metadata service and must be refused pre-flight; \ + a NAT64 spelling is the same target as the bare form" + ); + } + + // The private/loopback host predicate must see through the embedding as well. + for internal in [ + "64:ff9b::7f00:1", // 127.0.0.1 + "64:ff9b::a01:203", // 10.1.2.3 + "64:ff9b:1:fffe::c0a8:1", // 192.168.0.1 via local-use NAT64 + ] { + assert!( + host_is_private_or_loopback(internal), + "{internal} is a NAT64 embedding of an internal target and must read as internal" + ); + } + + // An operator denylist entry written in the bare v4 form must also catch the NAT64 spelling — + // otherwise the denylist is trivially evaded by re-encoding the same address. Asked through the + // operator's OWN door rather than through the canonicalizer, and asked on an address the + // hardcoded denylist does NOT hold (`10.99.99.99` is a legitimate upstream by default), so the + // refusal can only be coming from the entry: the control below shows the same host passing when + // the list is empty. + const NAT64_OF_10_99_99_99: &str = "https://[64:ff9b::a63:6363]/"; + assert!( + ssrf_blocked_host( + NAT64_OF_10_99_99_99, + &[], + false, + &["10.99.99.99".to_string()] + ) + .is_some(), + "a denylist entry naming the v4 address must match its NAT64 embedding" + ); + assert!( + ssrf_blocked_host(NAT64_OF_10_99_99_99, &[], false, &[]).is_none(), + "the control: without the operator entry this host is an ordinary private upstream, so the \ + assertion above is about the entry and not about the hardcoded denylist" + ); + + // Public addresses are unaffected: 8.8.8.8 embedded in NAT64 is still public. + assert!( + !host_is_private_or_loopback("64:ff9b::808:808"), + "64:ff9b::808:808 embeds public 8.8.8.8 and must NOT read as internal" + ); +} + +/// THE DIALING GUARD AND THE CONFIG GUARD MUST KNOW THE SAME METADATA NAMES. +/// +/// They did not. `ssrf_blocked_host` kept a SIX-name list private to its own body while +/// `judge_host_name` — the arm the resolve-then-pin dialing path consults — read a TWO-name +/// module const. Config validation refused `metadata.tencentyun.com`; the socket did not. +#[test] +fn the_dialing_guard_and_the_config_guard_know_the_same_metadata_names() { + // Every name `ssrf_blocked_host` (config validation) refuses must ALSO be refused by + // `judge_host_name`, which is the arm the resolve-then-pin dialing path actually consults. + for name in [ + "metadata.google.internal", + "metadata.internal", + "metadata.tencentyun.com", + "metadata.platformequinix.com", + "instance-data", + "instance-data.ec2.internal", + ] { + // Config-validation view. + let cfg = ssrf_blocked_host(&format!("https://{name}/"), &[], false, &[]); + // Dialing view — the one a socket is opened behind. + let dial = judge_host_name(name, GuardPolicy::default()); + assert!( + cfg.is_some(), + "{name}: config guard should refuse (this is the 6-name list)" + ); + assert!( + dial.is_err(), + "{name}: DIALING guard did NOT refuse — config refuses it but the dial path allows it" + ); + } +} + +/// NAT64 / RFC 6052 END TO END: a DNS64-answered destination must be refused as metadata, not +/// pinned as an ordinary public v6 address, running through the SAME resolve-then-pin guard every +/// other rebinding case in this file is driven through. +/// +/// A DNS64 resolver on an IPv6-only network answers a AAAA query with the NAT64 synthesis of the +/// queried name's IPv4 address. `64:ff9b:1:fffe::a9fe:a9fe` is the RFC 8215 local-use synthesis +/// (with a non-zero middle, the shape RFC 8215 Section 6's own worked example uses) of the IMDS +/// target `169.254.169.254`; it matches no v6 range and no `to_ipv4()` form, so a guard unwrapping +/// only `to_ipv4()` would pin it as an ordinary public address. +#[test] +fn nat64_dns64_synthesis_of_imds_is_refused_even_under_allow_private() { + let r = ScriptedResolver::new(vec![Ok(vec![ip("64:ff9b:1:fffe::a9fe:a9fe")])]); + let err = resolve_and_pin("dns64.example", 443, true, &r, private_ok()).expect_err( + "the RFC 8215 local-use NAT64 synthesis of the IMDS target must be refused even under \ + allow_private", + ); + assert!( + matches!(err, AddressRefusal::CloudMetadataAddress { .. }), + "must be refused AS METADATA, not merely pinned as an ordinary public address: {err:?}" + ); +} diff --git a/crates/busbar-kernel/Cargo.toml b/crates/busbar-kernel/Cargo.toml index 9ecb35e2f4..4f530959d9 100644 --- a/crates/busbar-kernel/Cargo.toml +++ b/crates/busbar-kernel/Cargo.toml @@ -52,9 +52,9 @@ busbar-contract = { path = "../busbar-contract" } # THE ONE NETWORK JUDGE (#36: "the upstream leg — walk + swrr weighting + pre-dial guard; trust # folded in"). `busbar_kernel_egress::trust::net` is the single definition of the SSRF/metadata # guard — the metadata name list, the address predicates, the WHATWG host extraction, the -# resolve-then-pin. `src/net_guard.rs` is a re-export shim over it, so every in-core call site and -# all three plane crates keep naming `busbar_kernel::net_guard::…` unchanged and no plane→unit edge -# is created. There WAS a second copy of that guard in this crate; it drifted, and the drift was a +# resolve-then-pin. Every in-core call site names it in its unit home (the pure host atoms in +# `busbar_contract::net`); the kernel resolves no name itself (row 130 E2: the resolver is the +# connector's, behind the egress-trust capability). There WAS a second copy of that guard in this crate; it drifted, and the drift was a # live metadata bypass (`metadata.platformequinix.com` resolves and pins to a routable address, so # nothing but the NAME arm can refuse it, and the name arm read a two-entry list). One judge is the # fix; this edge is what makes one judge reachable from here. diff --git a/crates/busbar-kernel/src/appbuild.rs b/crates/busbar-kernel/src/appbuild.rs index c046cd5d62..c41cd38ed4 100644 --- a/crates/busbar-kernel/src/appbuild.rs +++ b/crates/busbar-kernel/src/appbuild.rs @@ -26,8 +26,8 @@ use crate::store::{HealthState, LaneData}; use crate::{ admin, audit, auth, billing, breaker, catalogue, config, config_validate, core_routes, cost, durable, endpoints, export, failover, governance, handlers, hooks, ingress, ir, json, limits, - net_guard, oauth_as, observability, operation, plane, plugin_routes, profile, proto, proxy, - ratelimit, snapshot, state, store, telemetry, tls, transport, trust, + oauth_as, observability, operation, plane, plugin_routes, profile, proto, proxy, ratelimit, + snapshot, state, store, telemetry, tls, transport, trust, }; use busbar_kernel::plane_host::{ AffinityInput, AuthStyleInput, ClientSettingsInput, FailoverInput, HealthInput, diff --git a/crates/busbar-kernel/src/config_validate/mod.rs b/crates/busbar-kernel/src/config_validate/mod.rs index 786640d7e3..17bd64c61f 100644 --- a/crates/busbar-kernel/src/config_validate/mod.rs +++ b/crates/busbar-kernel/src/config_validate/mod.rs @@ -51,15 +51,15 @@ const MAX_SEMAPHORE_PERMITS: usize = tokio::sync::Semaphore::MAX_PERMITS; const MAX_DURATION_SECS: u64 = 30 * 365 * 86_400; /// [`MAX_DURATION_SECS`] for the millisecond-denominated knobs. const MAX_DURATION_MS: u64 = MAX_DURATION_SECS * 1_000; -// SSRF host guards relocated DOWN into the neutral `busbar-substrate` net_guard leaf (Batch A), -// re-exported here so every in-core caller keeps naming `config_validate::{…}` unchanged and the -// two SSRF guards still single-source their byte-identical atoms. -pub use crate::net_guard::{ - extract_normalized_host, host_is_private_or_loopback, scheme_is, ssrf_blocked_host, -}; +// SSRF host guards: the pure host atoms are `busbar_contract::net`'s and the pre-flight guard is the +// egress unit's (`busbar_kernel_egress::trust::net`), re-exported here so every in-core caller keeps +// naming `config_validate::{…}` unchanged and the two SSRF guards still single-source their +// byte-identical atoms. +pub use busbar_contract::net::{extract_normalized_host, host_is_private_or_loopback, scheme_is}; +pub use busbar_kernel_egress::trust::net::ssrf_blocked_host; // Test-only: the alternate-IPv4 expander moved with the guards; its unit tests still name it here. #[cfg(test)] -use crate::net_guard::expand_alternate_ipv4; +use busbar_contract::net::expand_alternate_ipv4; /// Validate the loaded configuration and collect all errors at once. /// Returns Ok(()) if valid; Err(Vec) with all validation failures otherwise. @@ -2588,7 +2588,7 @@ pub fn service_account_token_uri(credential: &str) -> Result { /// /// The endpoint breaks either rule; the text is 1.5.5's. pub fn vet_token_uri(token_uri: &str, posture: &MetadataPosture) -> Result<(), String> { - use crate::net_guard::{extract_normalized_host, host_is_private_or_loopback, scheme_is}; + use busbar_contract::net::{extract_normalized_host, host_is_private_or_loopback, scheme_is}; let host_private = extract_normalized_host(token_uri) .as_deref() .map(host_is_private_or_loopback) diff --git a/crates/busbar-kernel/src/egress/duplex_ws.rs b/crates/busbar-kernel/src/egress/duplex_ws.rs index 8604f06bd8..e86659aa8c 100644 --- a/crates/busbar-kernel/src/egress/duplex_ws.rs +++ b/crates/busbar-kernel/src/egress/duplex_ws.rs @@ -14,7 +14,7 @@ //! //! A `wss://` upstream is an operator/runtime target, so it is resolved-then-pinned-then-guarded on //! EXACTLY the discipline the HTTP egress seam applies — never a raw `connect_async` that resolves DNS -//! itself. The order is [`crate::net_guard::resolve_and_pin`] FIRST (structural refusals, one +//! itself. The order is [`net::resolve_and_pin`] FIRST (structural refusals, one //! resolution, every answered address judged, the survivor pinned), then a TCP connect to THAT pinned //! address, then a TLS handshake presenting the URL host for SNI / certificate validation, then the //! client WS handshake OVER that already-guarded stream. The socket is never opened to anything the @@ -27,7 +27,7 @@ use futures::{Sink, SinkExt, Stream, StreamExt}; use tokio::net::TcpStream; use tokio_tungstenite::tungstenite::Message; -use crate::net_guard::{self, AddressRefusal, GuardPolicy}; +use busbar_kernel_egress::trust::net::{self, AddressRefusal, GuardPolicy}; /// How many frames an upstream may have in flight ahead of the leg reading them. The same reasoning /// the inbound acceptor's queue is bounded by, pointed the other way: an upstream that emits faster @@ -161,7 +161,7 @@ impl From for DialError { /// `wss://host[:port]/path` (or `ws://…`) split into `(secure, host, port, request-url)`. A strict /// recogniser over the scheme, with the authority read by the one shared URL reader -/// ([`net_guard::parse_url`], WHATWG rules) — so it ends at `/`, `?`, `#` or `\` exactly where the +/// ([`net::parse_url`], WHATWG rules) — so it ends at `/`, `?`, `#` or `\` exactly where the /// handshake's own parser ends it, and the host the guard pins is the host the handshake names. The /// request-url handed to the handshake keeps the original `ws(s)` scheme so the `Host` / /// `Sec-WebSocket-*` headers are exactly what the upstream expects. @@ -173,7 +173,7 @@ fn split_ws_url(url: &str) -> Result<(bool, String, u16, String), DialError> { } else { return Err(DialError::Url(DialTarget::new(url))); }; - let parts = net_guard::parse_url(url).map_err(|_| DialError::Url(DialTarget::new(url)))?; + let parts = net::parse_url(url).map_err(|_| DialError::Url(DialTarget::new(url)))?; // No userinfo in an egress target: an `@` is an unusable authority, exactly as the HTTP guard // treats it. if parts.userinfo { @@ -239,8 +239,13 @@ pub async fn dial_with_credential( // THE GUARD, FIRST — resolve then pin then judge. `https = secure`: a `ws://` target is judged as // plaintext (admitted only under the policy's plaintext stance), a `wss://` as TLS. No socket is // opened to anything this did not pin. - let pinned = - net_guard::resolve_and_pin(&host, port, secure, &net_guard::SystemResolver, policy)?; + let pinned = net::resolve_and_pin( + &host, + port, + secure, + &crate::plane_host::egress_trust::InstalledResolver, + policy, + )?; // The upgrade request — the only place the secret is written, built after the guard has judged // the keyless target and before any socket is opened. diff --git a/crates/busbar-kernel/src/egress/fixtures.rs b/crates/busbar-kernel/src/egress/fixtures.rs index ba04476e9b..1d9273a582 100644 --- a/crates/busbar-kernel/src/egress/fixtures.rs +++ b/crates/busbar-kernel/src/egress/fixtures.rs @@ -949,3 +949,39 @@ pub fn loopback_literal_listed(host: &str) -> Result<(), crate::host_services::D pub fn install_test_tls(layer: Arc) { crate::plane_host::egress_trust::install_egress_trust_host(Arc::new(TlsEgressTrust(layer))); } + +/// THE SYSTEM LOOKUP a test build's kernel resolve-then-pin answers with when no guarded egress-trust +/// capability is installed (`plane_host::egress_trust::EgressTrustHost::resolve_names`): every +/// address `host` answers with, de-duplicated but otherwise untouched and unsorted. Test machinery +/// only: a production kernel resolves nothing itself, the connector's resolver +/// (`busbar_core_connector::process::SystemNames`, the same body) is installed behind the capability. +/// +/// # Errors +/// +/// The resolution failure. +pub fn system_names(host: &str) -> Result, String> { + let target = format!("{host}:0"); + std::thread::scope(|s| { + s.spawn(|| { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| format!("name lookup: could not start a runtime: {e}"))?; + rt.block_on(async move { + let answered = tokio::net::lookup_host(target) + .await + .map_err(|e| e.to_string())?; + let mut out: Vec = Vec::new(); + for sa in answered { + let ip = sa.ip(); + if !out.contains(&ip) { + out.push(ip); + } + } + Ok(out) + }) + }) + .join() + .map_err(|_| "name lookup: the worker thread panicked".to_string())? + }) +} diff --git a/crates/busbar-kernel/src/egress/mod.rs b/crates/busbar-kernel/src/egress/mod.rs index f7c127ca30..7c66e1ec8a 100644 --- a/crates/busbar-kernel/src/egress/mod.rs +++ b/crates/busbar-kernel/src/egress/mod.rs @@ -30,7 +30,7 @@ pub mod seam; // `crate::proxy::` paths. pub mod engine; -// THE NEUTRAL FULL-DUPLEX WS EGRESS DIALER — dial an upstream `wss://` THROUGH `net_guard` +// THE NEUTRAL FULL-DUPLEX WS EGRESS DIALER — dial an upstream `wss://` THROUGH the egress unit's guard // (resolve→pin→guard, then TCP to the pinned address, then TLS with the URL host for SNI, then the WS // handshake) and hand back the `Stream`/`Sink>` pair `byte_duplex::serve_messages` consumes. // The outbound half of the WS transport `Transport::WebSocket` selects; armed under `runtime`. This is diff --git a/crates/busbar-kernel/src/egress/tests/duplex_ws_tests.rs b/crates/busbar-kernel/src/egress/tests/duplex_ws_tests.rs index 3d79d2048a..bcb3b901d9 100644 --- a/crates/busbar-kernel/src/egress/tests/duplex_ws_tests.rs +++ b/crates/busbar-kernel/src/egress/tests/duplex_ws_tests.rs @@ -15,8 +15,8 @@ use futures::{SinkExt, StreamExt}; use crate::egress::duplex_ws::{self, DialError}; use crate::ingress::byte_duplex::{CallRef, DuplexHandle, DuplexPlane}; use crate::ingress::duplex_ws as ws_ingress; -use crate::net_guard::{AddressRefusal, GuardPolicy}; use crate::transport::{Transport, UpstreamWireKind}; +use busbar_kernel_egress::trust::net::{AddressRefusal, GuardPolicy}; /// A trivial ECHO plane bound to the acceptor: no protocol, no wire vocabulary — it echoes each frame /// back verbatim. Exactly the shape the `serve_messages` header describes an upgraded WS session taking. diff --git a/crates/busbar-kernel/src/host_services.rs b/crates/busbar-kernel/src/host_services.rs index 36d659c278..177e5bb747 100644 --- a/crates/busbar-kernel/src/host_services.rs +++ b/crates/busbar-kernel/src/host_services.rs @@ -9,7 +9,7 @@ //! //! * `clock.now` — the kernel's clock: wall time, and monotonic time from an origin fixed when the //! services are built. -//! * `dest.judge` — the ONE destination judge, the egress unit's (through `net_guard`): the +//! * `dest.judge` — the ONE destination judge, the egress unit's (`busbar_kernel_egress::trust::net`): the //! metadata denylist, then the refusals the name decides, //! answered at once and before any resolution, as 1.5.5 answered them. Asked to resolve //! (`DEST_RESOLVE`), a name is resolved off the caller's thread, the call pends, and the address diff --git a/crates/busbar-kernel/src/lib.rs b/crates/busbar-kernel/src/lib.rs index e94a47873a..fd6e0460a5 100644 --- a/crates/busbar-kernel/src/lib.rs +++ b/crates/busbar-kernel/src/lib.rs @@ -220,7 +220,7 @@ pub use busbar_plugin_loader::durable; // The host-owned outbound surface: the neutral SSRF-pinned client (re-exported wholesale from // busbar-substrate) plus the host-mediated `seam` adapter that drives egress through the // `plane_host` FFI vtable, gated behind the neutral `egress-seam` capability feature rather than -// any one plane. Always compiled, like `net_guard`, because the host owns every outbound byte +// any one plane. Always compiled, because the host owns every outbound byte // whether or not a plane needing the seam is built. See the module header. /// A lane's credential, bound by the auth plugin that serves its style (P2 D1, the auth split). pub mod bound_credential; @@ -250,7 +250,6 @@ pub mod host_services; pub mod json; pub mod limits; pub mod lineage; -pub mod net_guard; pub mod oauth_as; pub mod observability; pub mod snapshot; diff --git a/crates/busbar-kernel/src/net_guard.rs b/crates/busbar-kernel/src/net_guard.rs deleted file mode 100644 index a066e9c7a6..0000000000 --- a/crates/busbar-kernel/src/net_guard.rs +++ /dev/null @@ -1,116 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (C) 2026 Busbar Inc and contributors - -//! Re-export shim over THE ONE NETWORK JUDGE, plus the one part of it a pure crate cannot hold. -//! -//! ## There was a second copy of this file, and the copy was a live bypass -//! -//! Until this commit `net_guard.rs` was 1,345 lines of guard — its own `METADATA_HOSTS`, its own -//! address predicates, its own WHATWG host extraction, its own resolve-then-pin — sitting beside a -//! near-identical 1,701-line definition in [`busbar_kernel_egress::trust::net`]. Two implementations -//! of one security control is not untidiness. It is the mechanism by which somebody hardens one and -//! the other keeps the hole, and it had already happened here twice: once inside this file (a -//! six-name metadata list shadowed privately inside `ssrf_blocked_host` while the module const the -//! DIALING path read held two), and once across the two files (the extraction was carried over -//! without the WHATWG leading/trailing trim, so `"https://10.99.99.99 "` read as a host that -//! matched no denylist entry while the connecting stack trimmed the space and dialled it). -//! -//! The first of those was the serious one, and it is worth naming exactly why: the address arm -//! cannot save a metadata endpoint that answers on a GLOBALLY ROUTABLE address. -//! `metadata.platformequinix.com` resolves and pins to a public literal, so -//! [`ip_is_cloud_metadata`] says nothing about it and [`judge_host_name`] — reading the short list — -//! said nothing either. NOTHING but the name arm can refuse that host, and the name arm was reading -//! the wrong list. -//! -//! So there is one definition now. It lives in the egress unit because #36 gives that unit "the -//! upstream leg — walk + swrr weighting + pre-dial guard; trust folded in", and because a guard -//! beside the walk is a guard every carrier reaches rather than one each transport must remember to -//! call. What is left here is a glob, so every in-core call site and all three plane crates keep -//! naming `busbar_kernel::net_guard::…` unchanged: this shim is what keeps a plane's reach pointed -//! at the kernel it already depends on instead of growing a new plane→unit edge. -//! -//! ## Why [`SystemResolver`] is here and the guard is not -//! -//! The guard is PURE — no I/O, no globals, one resolution taken through the [`Resolver`] seam — and -//! that purity is what lets it live in a crate whose entire dependency list is `busbar-contract` -//! plus `futures` (the spec holds the kernel-8 units to `⊆ {contract, plugin-sdk}`). A -//! `resolve_and_pin_async` that called `tokio::net::lookup_host` could not have moved there, and -//! for a while that one function was the stated reason the fork could not be collapsed. -//! -//! It was the wrong thing to move. A resolver is not a judgement; it is the thing the judgement is -//! applied to. So the async entry point is GONE rather than relocated, every caller funnels through -//! the one pure [`resolve_and_pin`], and the runtime-bound half — the actual `getaddrinfo` — is the -//! small binding below, in the crate that already owns tokio. The guard got smaller and the -//! constraint dissolved instead of being worked around. - -pub use busbar_kernel_egress::trust::net::*; - -use std::net::IpAddr; - -/// THE REAL RESOLVER: the system resolver, reached through tokio, bound to the guard's seam. -/// -/// This is what [`resolve_and_pin`] asks when the destination is a NAME rather than a literal, on -/// every path that used to call the deleted `resolve_and_pin_async`: the plane-host egress open, -/// the neutral full-duplex WS dial, the MCP dispatch pin and the OAuth2 client-ID-metadata fetch. -/// The ORDERING those paths depend on — structural refusals first so a hostile name never reaches a -/// resolver, then EXACTLY ONE lookup, then every answered address judged, then the pin — is stated -/// once, in [`resolve_and_pin`], and is not restated by any of them. -/// -/// Returns EVERY address the name answered with, de-duplicated but otherwise untouched and -/// unsorted, which is the same rule the A2A card fetch's own resolver states. Filtering or -/// re-ordering would quietly decide which address the guard gets to judge, and the guard's rule is -/// that it judges all of them; de-duplication is not filtering, because a name answering the same -/// address under both a v4 and a v6 query is ONE fact and nothing distinct is dropped. -/// -/// ## Why a thread of its own -/// -/// The seam is synchronous, so this runs `getaddrinfo` and waits. Not -/// `Handle::current().block_on(..)` and not `block_in_place`: both make assumptions about the -/// caller (that there IS a runtime, that it is multi-threaded) that a synchronous seam cannot make, -/// and every one of the four callers reaches it from a different posture — a foreign thread already -/// inside `Runtime::block_on`, a boxed future on a shared worker, a plain `async fn`. A thread with -/// its own current-thread runtime has no such precondition and cannot panic on the caller's behalf. -/// This is the shape `busbar-a2a`'s card-fetch resolver already uses in production, stated once -/// more here rather than reached across a plane boundary for it. -#[derive(Clone, Copy, Debug, Default)] -pub struct SystemResolver; - -impl Resolver for SystemResolver { - fn resolve(&self, host: &str) -> Result, String> { - // Port zero: this seam answers about ADDRESSES. The port belongs to the URL and is applied - // by the caller that built the pin, so asking the resolver about one would be asking a - // second question. - let target = format!("{host}:0"); - std::thread::scope(|s| { - s.spawn(|| { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .map_err(|e| format!("name lookup: could not start a runtime: {e}"))?; - rt.block_on(async move { - let answered = tokio::net::lookup_host(target) - .await - .map_err(|e| e.to_string())?; - let mut out: Vec = Vec::new(); - for sa in answered { - let ip = sa.ip(); - if !out.contains(&ip) { - out.push(ip); - } - } - Ok(out) - }) - }) - .join() - .map_err(|_| "name lookup: the worker thread panicked".to_string())? - }) - } -} - -#[cfg(test)] -#[path = "tests/net_guard_tests.rs"] -mod tests; - -#[cfg(test)] -#[path = "tests/net_guard_fetch_tests.rs"] -mod fetch_tests; diff --git a/crates/busbar-kernel/src/oauth_as/mod.rs b/crates/busbar-kernel/src/oauth_as/mod.rs index 87e7edf423..adf42df160 100644 --- a/crates/busbar-kernel/src/oauth_as/mod.rs +++ b/crates/busbar-kernel/src/oauth_as/mod.rs @@ -67,13 +67,14 @@ //! feature this tree does not enable — the handover is a marked TODO in [`cimd`] tied to //! `chore/1.6.0-oauth-as-0.9.3`, and it replaces one call, not the seam. That //! fetch is an SSRF surface by construction — the URL is attacker-supplied — and it goes through -//! [`crate::net_guard`]'s resolve-then-pin guard rather than a second copy of it; a drifted copy -//! of exactly that guard was a live cloud-metadata bypass on the MCP plane. +//! the egress unit's (`busbar_kernel_egress::trust::net`) resolve-then-pin guard rather than a +//! second copy of it; a drifted copy of exactly that guard was a live cloud-metadata bypass on +//! the MCP plane. //! //! **It reaches for CORE, not for a plane.** An earlier draft of this note pointed at //! `a2a::fetch::fetch_card`, which would have made an authorization server's security control a //! dependency on the A2A plane's internals — and would have meant that deleting that plane broke -//! the AS. The guard now lives in `net_guard` with the knobs as parameters, so this path supplies +//! the AS. The guard now lives in the egress unit with the knobs as parameters, so this path supplies //! its OWN bounds — a client metadata document is a few kilobytes and the fetch is on an //! interactive authorization request, so 5 KB and 10 s, not the card fetch's 512 KB — and gets //! the same resolve-then-pin, the same unconditional cloud-metadata refusal and the same diff --git a/crates/busbar-kernel/src/observability.rs b/crates/busbar-kernel/src/observability.rs index 81ef0bda13..58f4aa0a25 100644 --- a/crates/busbar-kernel/src/observability.rs +++ b/crates/busbar-kernel/src/observability.rs @@ -14,8 +14,8 @@ // Here they are a defense-in-depth parity mirror (the webhook/OTLP URL is already // `url::Url::parse`-normalized, so the canonical `parse::()` path does the real // blocking); keeping the byte-identical atoms in one tested leaf stops the two guards drifting. -use crate::net_guard::{ - ipv4_is_internal as net_guard_ipv4_is_internal, ipv6_is_internal, is_alternate_ipv4_encoding, +use busbar_contract::net::{ + ipv4_is_internal as shared_ipv4_is_internal, ipv6_is_internal, is_alternate_ipv4_encoding, }; // 1.5.3 LIFT-OUT: the request-log webhook DELIVERY (the `WEBHOOK_URL`/`CLIENT`/ @@ -163,22 +163,22 @@ pub fn validate_webhook_url(url: Option) -> Result, Strin /// block DIFFERENT sets on the LOCALHOST family. /// /// The METADATA family is NOT a place the two may differ, and this is no longer a second copy: it -/// is [`crate::net_guard::METADATA_HOSTS`] itself — which is now the egress unit's list, reached -/// through the re-export shim, so there is exactly ONE metadata list in the tree and this guard +/// is [`busbar_contract::net::METADATA_HOSTS`] itself — the list the egress unit's guard and the +/// connector's guard read too, so there is exactly ONE metadata list in the tree and this guard /// reads it rather than a sibling of it. It used to be a private two-name list beside a six-name /// one, which is a telemetry guard that had never heard of four names the config guard refused. -use crate::net_guard::METADATA_HOSTS; +use busbar_contract::net::METADATA_HOSTS; /// True for an IPv4 literal busbar must not POST telemetry to. Shared by the V4 arm and the /// IPv4-mapped-IPv6 arm so the two stay identical. Covers loopback, link-local (incl. the /// `169.254.169.254` IMDS endpoint), RFC1918 private, RFC6598 CGNAT, unspecified, and broadcast. fn is_internal_v4(v4: &std::net::Ipv4Addr) -> bool { - // THE PREDICATE ITSELF LIVES IN `net_guard` and is called, not copied. It used to be spelled - // out here, and another SSRF guard elsewhere in the codebase would have been the THIRD copy of the + // THE PREDICATE ITSELF LIVES IN `busbar_contract::net` and is called, not copied. It used to + // be spelled out here, and another SSRF guard elsewhere in the codebase would have been the THIRD copy of the // same range list (Azure WireServer and OCI IMDS in particular sit on public addresses that every range // predicate misses, so a copy that forgets them looks correct). Hoisted rather than duplicated: // a contributor hardening one guard against a new range must not be able to miss the others. - net_guard_ipv4_is_internal(v4) + shared_ipv4_is_internal(v4) } /// True if the URL's host is an address busbar must not POST telemetry to: a literal loopback, @@ -223,8 +223,9 @@ fn host_is_internal(url: &url::Url) -> bool { return true; } - // Defense-in-depth parity mirror via the shared `net_guard::is_alternate_ipv4_encoding`, NOT the - // primary guard. For an http(s) URL the PRIMARY protection is `url::Url::parse`: http(s) + // Defense-in-depth parity mirror via the shared + // `busbar_contract::net::is_alternate_ipv4_encoding`, NOT the primary guard. For an + // http(s) URL the PRIMARY protection is `url::Url::parse`: http(s) // is a WHATWG "special scheme", so its host parser already canonicalizes every alternate // IPv4 encoding to a dotted-quad BEFORE we ever read `host_str()` — `2130706433` / // `0x7f000001` / `017700000001` / `127.1` / `0177.0.0.1` all arrive here as `127.0.0.1`, @@ -242,7 +243,7 @@ fn host_is_internal(url: &url::Url) -> bool { // THE V6 ARM IS THE SHARED PREDICATE, not a local transcription of it. It used to be // spelled out here — `is_loopback()`, then `to_ipv4()`, then unspecified / // unique-local / link-local — and the transcription had DROPPED `is_multicast()`, - // which `net_guard::ipv6_is_internal` has. `[ff02::1]` is the all-nodes group: it + // which `busbar_contract::net::ipv6_is_internal` has. `[ff02::1]` is the all-nodes group: it // reaches every host on the segment, and this guard admitted it while the predicate // it was written as a sibling of refused it. The order that arm depends on (loopback // first, embedded-v4 before the v6 masks) is documented once, on `ipv6_is_internal`. diff --git a/crates/busbar-kernel/src/plane_host/egress.rs b/crates/busbar-kernel/src/plane_host/egress.rs index 0e77274515..84fa2398a3 100644 --- a/crates/busbar-kernel/src/plane_host/egress.rs +++ b/crates/busbar-kernel/src/plane_host/egress.rs @@ -6,7 +6,7 @@ //! The host OWNS every outbound byte. A plane never holds a client, a socket, a resolver, or a //! credential in plaintext; it holds an opaque [`EgressId`] and asks the host to open, pump, write //! and close. That is the whole point of routing egress through this seam: the SSRF resolve-then-pin -//! ([`crate::net_guard`]), the SPKI observation, the mTLS client-identity, the breaker and the meter +//! ([`busbar_kernel_egress::trust::net`]), the SPKI observation, the mTLS client-identity, the breaker and the meter //! are all ONE chokepoint the host controls, applied identically whatever the plane is dispatching. //! //! ## What is wired here, and what is a faithful Phase-2 note @@ -15,7 +15,7 @@ //! built from the [`EgressDesc`] outbound tail (the `verb`, the packed header set, and the one-shot //! request `body`), the credential is INJECTED host-side (the resolved credential the plane named by //! ref, never plaintext the plane held — see [`inject_credential`]), then resolve-then-pin over -//! [`crate::net_guard::resolve_and_pin`], a per-hop PINNED client (the a2a lesson — a pooled +//! [`busbar_kernel_egress::trust::net::resolve_and_pin`], a per-hop PINNED client (the a2a lesson — a pooled //! client re-resolves and reopens the DNS-rebind window, so a governed hop pins the address and //! refuses a second lookup), the post-connect observed peer identity handed back in the //! [`EgressHead`], a background streaming task that pumps `resp.chunk().await` into a bounded @@ -82,7 +82,7 @@ const CHUNK_CHANNEL_DEPTH: usize = 16; // wired. The guard stays FAIL-CLOSED by default: a scope of 0 reaches neither. /// This allowlist scope permits private / loopback / CGNAT addresses (cloud-metadata stays refused — -/// that is the guard, not a policy the scope can speak for; see [`crate::net_guard`]). +/// that is the guard, not a policy the scope can speak for; see [`busbar_kernel_egress::trust::net`]). const SCOPE_ALLOW_PRIVATE: u32 = 1 << 0; /// This allowlist scope permits a plaintext `http://` endpoint. const SCOPE_ALLOW_PLAINTEXT: u32 = 1 << 1; @@ -274,11 +274,11 @@ fn observed_identity(resp: &http::Response) -> Vec { .unwrap_or_default() } -/// Build the [`crate::net_guard::GuardPolicy`] for a hop from the host's allowlist-scope stance. Only +/// Build the [`busbar_kernel_egress::trust::net::GuardPolicy`] for a hop from the host's allowlist-scope stance. Only /// the private/plaintext arms are the scope's to relax; the cloud-metadata and obfuscated-encoding /// refusals are the guard and no scope can speak for them. -fn guard_policy(scope: u32) -> crate::net_guard::GuardPolicy { - crate::net_guard::GuardPolicy { +fn guard_policy(scope: u32) -> busbar_kernel_egress::trust::net::GuardPolicy { + busbar_kernel_egress::trust::net::GuardPolicy { allow_private: scope & SCOPE_ALLOW_PRIVATE != 0, allow_plaintext: scope & SCOPE_ALLOW_PLAINTEXT != 0, // Unused by the resolve-then-pin path (it judges names and addresses); the streaming read is @@ -579,7 +579,9 @@ impl OperatorDestinations { fn walk(v: &serde_yaml::Value, out: &mut Vec<(bool, String, u16)>) { match v { serde_yaml::Value::String(s) => { - if let Ok((https, host, port, _)) = crate::net_guard::split_url(s.trim()) { + if let Ok((https, host, port, _)) = + busbar_kernel_egress::trust::net::split_url(s.trim()) + { out.push((https, host.to_ascii_lowercase(), port)); } } @@ -609,7 +611,7 @@ impl OperatorDestinations { { return 0; } - let local = crate::net_guard::host_is_private_or_loopback(&host); + let local = busbar_kernel_egress::trust::net::host_is_private_or_loopback(&host); match (https, local) { (_, true) => SCOPE_ALLOW_PRIVATE | SCOPE_ALLOW_PLAINTEXT, (true, false) if !pinned => SCOPE_ALLOW_PRIVATE, @@ -630,7 +632,7 @@ fn host_scope(state: &HostState, d: &EgressDesc) -> u32 { // SAFETY: `(target_ptr, target_len)` is a live borrowed range for the call (ABI discipline). let bytes = unsafe { std::slice::from_raw_parts(d.target_ptr, d.target_len) }; let Ok(Ok((https, host, port, _))) = - std::str::from_utf8(bytes).map(crate::net_guard::split_url) + std::str::from_utf8(bytes).map(busbar_kernel_egress::trust::net::split_url) else { return 0; }; @@ -683,7 +685,7 @@ fn open_http( // Strict-recognise the URL (structural: scheme/host/port — no resolution), so a malformed / // non-http(s) target fails fast without spawning a thread. The host uses the URL HOST for SNI / // certificate-name / mTLS whether or not it also resolves it. - let (https, host_name, port, _path) = match crate::net_guard::split_url(&url) { + let (https, host_name, port, _path) = match busbar_kernel_egress::trust::net::split_url(&url) { Ok(parts) => parts, Err(refusal) => { stash_fault( @@ -699,7 +701,7 @@ fn open_http( // THE SCHEME JUDGEMENT runs UNCONDITIONALLY — a plane-supplied pinned address does NOT let a hop // skip it (FFI-F1). A `http://` downgrade is refused unless the HOST scope admits plaintext. - if let Err(refusal) = crate::net_guard::judge_scheme(&url, https, policy) { + if let Err(refusal) = busbar_kernel_egress::trust::net::judge_scheme(&url, https, policy) { stash_fault( scope, EgressFailClass::Refused, @@ -723,7 +725,9 @@ fn open_http( ip_from_resolved(kind, bytes).map(|ip| std::net::SocketAddr::new(ip, port)) }; if let Some(addr) = pinned { - if let Err(refusal) = crate::net_guard::judge_address(&host_name, addr.ip(), policy) { + if let Err(refusal) = + busbar_kernel_egress::trust::net::judge_address(&host_name, addr.ip(), policy) + { stash_fault( scope, EgressFailClass::Refused, @@ -955,7 +959,7 @@ fn run_http_stream( host_name: &str, port: u16, https: bool, - policy: crate::net_guard::GuardPolicy, + policy: busbar_kernel_egress::trust::net::GuardPolicy, spec: &ReqSpec, identity: (u64, Option), extra_roots: (u64, Vec>), @@ -985,11 +989,11 @@ fn run_http_stream( // address judged, the survivor pinned. let socket_addr = match pinned { Some(addr) => addr, - None => match crate::net_guard::resolve_and_pin( + None => match busbar_kernel_egress::trust::net::resolve_and_pin( host_name, port, https, - &crate::net_guard::SystemResolver, + &crate::plane_host::egress_trust::InstalledResolver, policy, ) { Ok(pin) => pin.socket_addr(), diff --git a/crates/busbar-kernel/src/plane_host/egress_trust.rs b/crates/busbar-kernel/src/plane_host/egress_trust.rs index 7138958f91..ef357b7616 100644 --- a/crates/busbar-kernel/src/plane_host/egress_trust.rs +++ b/crates/busbar-kernel/src/plane_host/egress_trust.rs @@ -38,6 +38,7 @@ use crate::egress::engine::ClientIdentity; use crate::host_services::{DestJudge, DestRefusal}; use crate::plane_host::spki::SpkiError; use crate::secure::SecureLayer; +use busbar_kernel_egress::trust::net::Resolver; /// THE OUTBOUND EGRESS-TRUST HOST CAPABILITY, as a neutral trait a plane reaches through instead of /// calling [`identity`](super::identity) / [`trust_anchor`](super::trust_anchor) / [`spki`](super::spki) @@ -116,6 +117,50 @@ pub trait EgressTrustHost: Send + Sync { fn secure_layer(&self) -> Option> { None } + + /// THE NAME RESOLUTION a kernel dial's resolve-then-pin asks for (row 130 E2): every address + /// `host` answers with, de-duplicated, unsorted, unfiltered (the guard judges all of them). The + /// lookup is the connector's (`busbar_core_connector::process::SystemNames`): the kernel opens no + /// socket, a resolver's included. The pass-through has none behind it, so a production build + /// that installed no guarded capability refuses every name (FAIL CLOSED); a test build resolves + /// through the test fixtures' system lookup. + /// + /// # Errors + /// + /// The resolution failure (not an empty answer: the two are different facts). + fn resolve_names(&self, host: &str) -> Result, String> { + unresolved(host) + } +} + +/// [`EgressTrustHost::resolve_names`] with no resolver behind it: a test build resolves through the +/// fixtures' system lookup; a production build refuses the name. +fn unresolved(host: &str) -> Result, String> { + #[cfg(any(test, feature = "test-support"))] + { + crate::egress::fixtures::system_names(host) + } + #[cfg(not(any(test, feature = "test-support")))] + { + Err(format!( + "name lookup: `{host}` was not resolved: no resolver is installed" + )) + } +} + +/// THE RESOLVER a kernel resolve-then-pin hands the guard (`resolve_and_pin`): the installed +/// capability's [`EgressTrustHost::resolve_names`], or, with none installed, the same answer the +/// pass-through gives. +#[derive(Clone, Copy, Debug, Default)] +pub struct InstalledResolver; + +impl Resolver for InstalledResolver { + fn resolve(&self, host: &str) -> Result, String> { + match egress_trust_host() { + Some(capability) => capability.resolve_names(host), + None => unresolved(host), + } + } } /// The production egress-trust capability: a BYTE-FOR-BYTE pass-through to the host-side primitives. @@ -126,14 +171,21 @@ pub struct PassThroughEgressTrust; impl EgressTrustHost for PassThroughEgressTrust {} /// The capability the composition root installs: the pass-through primitives, every answer judged by -/// the deployment's one destination guard, and every outbound connection secured by the one TLS -/// wrap (both the connector's; this decides nothing). -pub struct GuardedEgressTrust(pub Arc, pub Arc); +/// the deployment's one destination guard, every outbound connection secured by the one TLS wrap, +/// and every name resolved by the one resolver (all three the connector's; this decides nothing). +pub struct GuardedEgressTrust( + pub Arc, + pub Arc, + pub Arc, +); impl EgressTrustHost for GuardedEgressTrust { fn secure_layer(&self) -> Option> { Some(Arc::clone(&self.1)) } + fn resolve_names(&self, host: &str) -> Result, String> { + self.2.resolve(host) + } fn judge_answer(&self, host: &str, addrs: &[IpAddr], class: u32) -> Result<(), DestRefusal> { self.0.judge_answer(host, addrs, class) } diff --git a/crates/busbar-kernel/src/plane_host/guard.rs b/crates/busbar-kernel/src/plane_host/guard.rs index fb57a02bc3..d8819f1411 100644 --- a/crates/busbar-kernel/src/plane_host/guard.rs +++ b/crates/busbar-kernel/src/plane_host/guard.rs @@ -8,7 +8,7 @@ //! tool ARGUMENT, and that argument is attacker-influenced DATA travelling to an operator-chosen //! destination — the classic SSRF shape. Judging it means naming the tree's host primitives (the //! `http(s)` scheme allowlist, the WHATWG host normalizer, the cloud-metadata / obfuscated-encoding -//! / internal-address checks over [`crate::net_guard`] + [`crate::config_validate`]). Those are the +//! / internal-address checks over [`busbar_contract::net`] + [`crate::config_validate`]). Those are the //! host's internals; a plane compiled apart from the host cannot name them. So the judgement is a //! host-vtable slot: the plane passes the URL bytes and the target's private-addressing policy, and //! the host returns an allow/deny verdict + the refusal class + the offending host bytes. @@ -34,10 +34,10 @@ use super::{recover, with_borrowed_host, DispatchScope}; use crate::config_validate::{ extract_normalized_host, host_is_private_or_loopback, scheme_is, ssrf_blocked_host, }; -use crate::net_guard::is_alternate_ipv4_encoding; use crate::state::App; use busbar_contract::abi::hot::host::HostCtx; use busbar_contract::abi::hot::{GuardClass, GuardVerdict, StatusClass}; +use busbar_contract::net::is_alternate_ipv4_encoding; use core::mem::MaybeUninit; use std::panic::{catch_unwind, AssertUnwindSafe}; diff --git a/crates/busbar-kernel/src/plane_host/tests/egress_trust_tests.rs b/crates/busbar-kernel/src/plane_host/tests/egress_trust_tests.rs index 476ad30de5..8d311870e3 100644 --- a/crates/busbar-kernel/src/plane_host/tests/egress_trust_tests.rs +++ b/crates/busbar-kernel/src/plane_host/tests/egress_trust_tests.rs @@ -134,9 +134,45 @@ fn the_guarded_seam_judges_by_the_guard_behind_it() { let judged = GuardedEgressTrust( crate::egress::fixtures::private_refusing(&[]), crate::egress::fixtures::TlsDouble::default().layer(), + std::sync::Arc::new(Answers(Vec::new())), ); let private: std::net::IpAddr = "10.0.0.5".parse().unwrap(); let public: std::net::IpAddr = "93.184.216.34".parse().unwrap(); assert!(judged.judge_answer("db.test", &[private], 0).is_err()); assert_eq!(judged.judge_answer("api.test", &[public], 0), Ok(())); } + +/// A resolver that answers every name with the same addresses, and an empty list as a failure. +struct Answers(Vec); + +impl busbar_kernel_egress::trust::net::Resolver for Answers { + fn resolve(&self, host: &str) -> Result, String> { + if self.0.is_empty() { + return Err(format!("{host}: no answer")); + } + Ok(self.0.clone()) + } +} + +/// Row 130 E2: the root's capability resolves every name through the resolver behind it (the +/// connector's), answering exactly what it answered, and its failure as a failure; the kernel +/// resolves nothing itself. +#[test] +fn the_guarded_seam_resolves_by_the_resolver_behind_it() { + let public: std::net::IpAddr = "93.184.216.34".parse().unwrap(); + let guarded = |answers: Vec| { + GuardedEgressTrust( + crate::egress::fixtures::private_refusing(&[]), + crate::egress::fixtures::TlsDouble::default().layer(), + std::sync::Arc::new(Answers(answers)), + ) + }; + assert_eq!( + guarded(vec![public]).resolve_names("api.test"), + Ok(vec![public]) + ); + assert_eq!( + guarded(Vec::new()).resolve_names("api.test"), + Err("api.test: no answer".to_owned()) + ); +} diff --git a/crates/busbar-kernel/src/plane_host/vtable.rs b/crates/busbar-kernel/src/plane_host/vtable.rs index d031235c6e..c521f512ef 100644 --- a/crates/busbar-kernel/src/plane_host/vtable.rs +++ b/crates/busbar-kernel/src/plane_host/vtable.rs @@ -99,7 +99,7 @@ pub fn build_plane_host_vtable() -> PlaneHostVtable { subkey_sign: None, // ── WIRED `guard_url` (minor-12) → the host-owned structural URL guard in `super::guard`: the // SSRF/URL-guard chokepoint for a URL-shaped tool argument. Always wired (the host owns the - // net_guard internals whatever the plane); no plane feature gates it. ───────────────────────── + // URL-guard internals whatever the plane); no plane feature gates it. ───────────────────────── guard_url: Some(super::guard::guard_url), // ── WIRED `identity_admit` (minor-17) → the host-side inbound admission in // `super::identity_admit`: the configured auth chain + the one verdict resolution over the diff --git a/crates/busbar-kernel/src/preflight.rs b/crates/busbar-kernel/src/preflight.rs index 4cf154f5f6..5c9ba975b2 100644 --- a/crates/busbar-kernel/src/preflight.rs +++ b/crates/busbar-kernel/src/preflight.rs @@ -16,8 +16,8 @@ use crate::diagnostics::{ use crate::{ admin, audit, auth, billing, breaker, catalogue, config, config_validate, core_routes, cost, durable, endpoints, export, failover, governance, handlers, hooks, ingress, ir, json, limits, - net_guard, oauth_as, observability, operation, plane, plugin_routes, profile, proto, proxy, - snapshot, state, store, telemetry, tls, transport, trust, + oauth_as, observability, operation, plane, plugin_routes, profile, proto, proxy, snapshot, + state, store, telemetry, tls, transport, trust, }; /// The FLEET DATA DIR the first-party anti-downgrade floor persists under, or `None` when this diff --git a/crates/busbar-kernel/src/router.rs b/crates/busbar-kernel/src/router.rs index 053fbc1f1e..06eccd23d3 100644 --- a/crates/busbar-kernel/src/router.rs +++ b/crates/busbar-kernel/src/router.rs @@ -10,8 +10,8 @@ use axum::Router; use crate::{ admin, audit, auth, billing, breaker, catalogue, config, config_validate, core_routes, cost, durable, endpoints, export, failover, governance, handlers, hooks, ingress, ir, json, limits, - net_guard, oauth_as, observability, operation, plane, plugin_routes, profile, proto, proxy, - state, store, telemetry, tls, transport, trust, + oauth_as, observability, operation, plane, plugin_routes, profile, proto, proxy, state, store, + telemetry, tls, transport, trust, }; /// Response header name for the W3C Server-Timing field. diff --git a/crates/busbar-kernel/src/tests/net_guard_fetch_tests.rs b/crates/busbar-kernel/src/tests/net_guard_fetch_tests.rs deleted file mode 100644 index 12a3e04948..0000000000 --- a/crates/busbar-kernel/src/tests/net_guard_fetch_tests.rs +++ /dev/null @@ -1,556 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (C) 2026 Busbar Inc and contributors - -//! THE ONE GUARDED FETCH: resolve-then-pin, the address judgement, the redirect policy and the -//! caps, tested where they now live rather than once per plane — and driven through -//! `busbar_kernel::net_guard`, the shim, so this is also the statement that the shim still reaches -//! the one judge in `busbar_kernel_egress::trust::net` rather than a re-grown local copy. -//! -//! The rebinding cases are the reason this file exists. They are driven through a SCRIPTED RESOLVER -//! that answers differently on the second lookup, because that is the only way to state the claim -//! the guard actually makes: the name is resolved EXACTLY ONCE and the socket goes to the address -//! that was judged. A guard tested against a resolver that answers the same thing twice cannot tell -//! a resolve-then-pin from a check-then-re-resolve — both pass — which is precisely the mistake -//! that looks right in review. - -use super::{ - default_port, judge_address, judge_addresses, judge_host_name, judge_scheme, pin_answer, - refuse_hop_overflow, refuse_oversized_body, refuse_redirect, resolve_and_pin, split_url, - AddressRefusal, GuardPolicy, Resolver, -}; -use std::cell::RefCell; -use std::net::IpAddr; - -const PUBLIC: &str = "93.184.216.34"; -const PUBLIC_2: &str = "93.184.216.35"; - -fn ip(s: &str) -> IpAddr { - s.parse().expect("a test address must parse") -} - -fn strict() -> GuardPolicy { - GuardPolicy::default() -} - -fn private_ok() -> GuardPolicy { - GuardPolicy { - allow_private: true, - ..GuardPolicy::default() - } -} - -/// A resolver that answers a SCRIPT: the first lookup gets one answer, every later lookup gets the -/// next. It also records what it was asked, so "the guard resolved exactly once" is an assertion -/// about a number rather than about intent. -struct ScriptedResolver { - answers: RefCell, String>>>, - asked: RefCell>, -} - -impl ScriptedResolver { - fn new(answers: Vec, String>>) -> Self { - Self { - answers: RefCell::new(answers), - asked: RefCell::new(Vec::new()), - } - } - fn asked(&self) -> usize { - self.asked.borrow().len() - } -} - -impl Resolver for ScriptedResolver { - fn resolve(&self, host: &str) -> Result, String> { - self.asked.borrow_mut().push(host.to_string()); - let mut answers = self.answers.borrow_mut(); - if answers.len() > 1 { - answers.remove(0) - } else { - answers - .first() - .cloned() - .unwrap_or_else(|| Err("the script is exhausted".to_string())) - } - } -} - -/// A resolver that PANICS. Nothing refusable from the URL alone may reach it: a case that needed a -/// lookup would be a case where the guard depends on what the attacker's nameserver says. -struct NeverAsked; -impl Resolver for NeverAsked { - fn resolve(&self, host: &str) -> Result, String> { - panic!("the guard resolved `{host}`, which it must refuse structurally"); - } -} - -// ══ THE REBINDING PROOFS ═════════════════════════════════════════════════════════════════════════ - -/// **THE DNS-REBINDING CASE.** The name answers a PUBLIC address on the first lookup and a -/// LOOPBACK address on the second. A guard that checks the name and then lets the client resolve -/// again connects to the second answer; a guard that resolves once and pins connects to the first. -/// -/// The assertion is on BOTH halves, and both are needed: the pinned address must be the judged one, -/// AND the resolver must have been asked exactly once. Asserting only the address would pass -/// against a guard that resolved twice and happened to be handed the good answer first; asserting -/// only the count would pass against a guard that resolved once and pinned the wrong element. -#[test] -fn a_name_that_answers_a_private_address_on_the_second_lookup_never_gets_a_second_lookup() { - let r = ScriptedResolver::new(vec![Ok(vec![ip(PUBLIC)]), Ok(vec![ip("127.0.0.1")])]); - let target = resolve_and_pin("rebind.example", 443, true, &r, strict()) - .expect("the first answer is public and admissible"); - assert_eq!( - target.addr(), - ip(PUBLIC), - "the pin must carry the address that was JUDGED, not one a later lookup would return" - ); - assert_eq!( - r.asked(), - 1, - "the name must be resolved EXACTLY ONCE; a second lookup is the window a rebind wins in" - ); - assert_eq!(target.host(), "rebind.example", "the name is kept for SNI"); - assert_eq!(target.socket_addr(), "93.184.216.34:443".parse().unwrap()); -} - -/// The other order, which is the one an attacker actually serves: the FIRST answer is already -/// hostile. There is no "and then it rebinds" to reach, because the fetch never happens. -#[test] -fn a_name_that_answers_the_metadata_address_first_is_refused_outright() { - let r = ScriptedResolver::new(vec![Ok(vec![ip("169.254.169.254")]), Ok(vec![ip(PUBLIC)])]); - let err = resolve_and_pin("rebind.example", 443, true, &r, strict()) - .expect_err("an IMDS answer must refuse"); - assert_eq!( - err, - AddressRefusal::CloudMetadataAddress { - host: "rebind.example".to_string(), - addr: ip("169.254.169.254"), - } - ); -} - -/// A MIXED ANSWER IS A HOSTILE ANSWER. One reply carrying a public address and a loopback one is -/// refused whole rather than filtered to the address that happens to pass — otherwise the same name -/// is sometimes fine and sometimes not, decided by an ordering the upstream chooses. -#[test] -fn a_mixed_answer_is_refused_whole_in_either_order() { - let forward = [ip(PUBLIC), ip("127.0.0.1")]; - let err = judge_addresses("mixed.example", &forward, strict()) - .expect_err("a loopback address in the answer must refuse the resolution"); - assert_eq!( - err, - AddressRefusal::InternalAddress { - host: "mixed.example".to_string(), - addr: ip("127.0.0.1"), - } - ); - let reversed = [ip("127.0.0.1"), ip(PUBLIC)]; - assert!( - judge_addresses("mixed.example", &reversed, strict()).is_err(), - "order must not decide the verdict" - ); - // The CONTROL: an all-public answer passes, so the two above are not passing because everything - // is refused. - assert!(judge_addresses("mixed.example", &[ip(PUBLIC), ip(PUBLIC_2)], strict()).is_ok()); -} - -// ══ THE ADDRESS JUDGEMENT ════════════════════════════════════════════════════════════════════════ - -#[test] -fn every_internal_range_is_refused() { - let internal = [ - "127.0.0.1", - "10.0.0.1", - "172.16.0.1", - "192.168.1.1", - "169.254.1.1", - "100.64.0.1", - "0.0.0.0", - "0.1.2.3", - "255.255.255.255", - "224.0.0.1", - "198.18.0.1", - "192.0.2.1", - "::1", - "fc00::1", - "fe80::1", - "::", - // An IPv4-mapped loopback: the v4 ruleset must not be bypassable via a AAAA record. - "::ffff:127.0.0.1", - ]; - assert_eq!(internal.len(), 17, "the internal-range set must not shrink"); - for a in internal { - assert!( - judge_address("h", ip(a), strict()).is_err(), - "{a} must be refused" - ); - } - // The CONTROL. - assert!(judge_address("h", ip(PUBLIC), strict()).is_ok()); -} - -/// CLOUD METADATA IS REFUSED EVEN UNDER `allow_private`, and it is refused AS METADATA — the arm -/// runs BEFORE the flag is consulted. Merging it into the internal-range arm would make -/// `allow_private` a config flag that hands out cloud credentials. -#[test] -fn cloud_metadata_is_refused_unconditionally_and_as_metadata() { - let metadata = [ - "169.254.169.254", - "169.254.170.2", - "100.100.100.200", - "168.63.129.16", - "192.0.0.192", - "fd00:ec2::254", - // The IPv4-COMPATIBLE and IPv4-MAPPED spellings of IMDS: neither matches a v6 range, and a - // guard that unwrapped only the mapped form connected to the compatible one. - "::169.254.169.254", - "::ffff:169.254.169.254", - ]; - assert_eq!(metadata.len(), 8, "the metadata set must not shrink"); - for a in metadata { - for policy in [strict(), private_ok()] { - let err = judge_address("meta", ip(a), policy) - .expect_err("metadata is refused under every policy"); - assert!( - matches!(err, AddressRefusal::CloudMetadataAddress { .. }), - "{a} must be refused AS METADATA, not merely as internal: {err:?}" - ); - } - } - // And a private address that is NOT metadata IS permitted under `allow_private`, so the - // unconditional refusal above is specific to metadata rather than to everything. - assert!(judge_address("h", ip("10.0.0.1"), private_ok()).is_ok()); -} - -/// THE WHOLE LINK-LOCAL /16 IS METADATA, not just the five literals a list can name. -/// -/// Clouds put their instance-metadata service anywhere inside `169.254.0.0/16` (Tencent answers on -/// `169.254.0.23`, AWS's IPv6-era ECS endpoint on `169.254.170.3`, the IMDS v6 alias on -/// `169.254.169.253`), and nothing legitimate runs on link-local at all. An address-side predicate -/// that enumerates literals leaves every other link-local address to the internal-range arm, which -/// `allow_private: true` switches off — so the operator flag that says "our upstream is on the -/// internal network" would pin and dial an unlisted metadata endpoint. -#[test] -fn unlisted_link_local_metadata_is_refused_as_metadata_under_allow_private() { - let unlisted = [ - ("Tencent IMDS", "169.254.0.23"), - ("IMDS v6-alias endpoint", "169.254.169.253"), - ("ECS task metadata (v6-era)", "169.254.170.3"), - // The IPv4-COMPATIBLE spelling reaches the same target through `to_ipv4()`. - ("IPv4-COMPATIBLE Tencent", "::169.254.0.23"), - ("IPv4-MAPPED Tencent", "::ffff:169.254.0.23"), - ]; - for (what, a) in unlisted { - for policy in [strict(), private_ok()] { - let err = judge_address("meta", ip(a), policy) - .expect_err("link-local metadata is refused under every policy"); - assert!( - matches!(err, AddressRefusal::CloudMetadataAddress { .. }), - "{what} ({a}) must be refused AS METADATA, not merely as internal: {err:?}" - ); - } - } -} - -/// NAT64 / RFC 6052 END TO END: a DNS64-answered destination must be refused as metadata, not -/// pinned as an ordinary public v6 address, running through the SAME resolve-then-pin guard every -/// other rebinding case in this file is driven through. -/// -/// A DNS64 resolver on an IPv6-only network answers a AAAA query with the NAT64 synthesis of the -/// queried name's IPv4 address. `64:ff9b:1:fffe::a9fe:a9fe` is the RFC 8215 local-use synthesis -/// (with a non-zero middle, the shape RFC 8215 Section 6's own worked example uses) of the IMDS -/// target `169.254.169.254`; it matches no v6 range and no `to_ipv4()` form, so a guard unwrapping -/// only `to_ipv4()` would pin it as an ordinary public address. -#[test] -fn nat64_dns64_synthesis_of_imds_is_refused_even_under_allow_private() { - let r = ScriptedResolver::new(vec![Ok(vec![ip("64:ff9b:1:fffe::a9fe:a9fe")])]); - let err = resolve_and_pin("dns64.example", 443, true, &r, private_ok()).expect_err( - "the RFC 8215 local-use NAT64 synthesis of the IMDS target must be refused even under \ - allow_private", - ); - assert!( - matches!(err, AddressRefusal::CloudMetadataAddress { .. }), - "must be refused AS METADATA, not merely pinned as an ordinary public address: {err:?}" - ); -} - -// ══ THE STRUCTURAL REFUSALS ══════════════════════════════════════════════════════════════════════ - -/// The metadata NAMES are refused before any resolver is consulted, and `allow_private` does not -/// speak for them. The `localhost` family is the population it DOES speak for, and the split -/// between the two lists is the whole point of having two arms. -#[test] -fn the_metadata_names_are_refused_under_every_policy_and_localhost_only_by_default() { - for name in [ - "metadata.google.internal", - "metadata.google.internal.", - "METADATA.GOOGLE.INTERNAL", - "metadata.internal", - ] { - for policy in [strict(), private_ok()] { - assert_eq!( - judge_host_name(name, policy), - Err(AddressRefusal::MetadataName(name.to_string())), - "`{name}` is a cloud-metadata name and `allow_private` may not reach it" - ); - } - } - for name in ["localhost", "localhost.", "api.localhost"] { - assert!( - matches!( - judge_host_name(name, strict()), - Err(AddressRefusal::LoopbackName(_)) - ), - "`{name}` is the loopback family and is refused by default" - ); - assert!( - judge_host_name(name, private_ok()).is_ok(), - "`{name}` is what `allow_private` is for" - ); - } - assert!(judge_host_name("agent.vendor", strict()).is_ok()); -} - -#[test] -fn alternate_ipv4_encodings_are_refused_before_the_resolver_sees_them() { - for host in ["2130706433", "0x7f000001", "017700000001", "127.1"] { - for policy in [strict(), private_ok()] { - assert_eq!( - judge_host_name(host, policy), - Err(AddressRefusal::ObfuscatedHost(host.to_string())), - "`{host}` is an encoding the resolver expands and the check cannot read" - ); - } - } -} - -#[test] -fn a_literal_is_judged_and_pinned_without_a_resolver() { - let t = resolve_and_pin(PUBLIC, 8443, true, &NeverAsked, strict()) - .expect("a public literal is its own answer"); - assert_eq!(t.addr(), ip(PUBLIC)); - assert_eq!(t.port(), 8443); - assert!(t.is_https()); - - assert!(matches!( - resolve_and_pin("127.0.0.1", 9000, true, &NeverAsked, strict()), - Err(AddressRefusal::InternalAddress { .. }) - )); - let t = resolve_and_pin("127.0.0.1", 9000, false, &NeverAsked, private_ok()) - .expect("an opted-in private literal pins"); - assert_eq!(t.socket_addr(), "127.0.0.1:9000".parse().unwrap()); - assert!(!t.is_https()); -} - -// ══ RESOLUTION FAILURE IS NOT ABSENCE ════════════════════════════════════════════════════════════ - -#[test] -fn a_resolution_failure_and_an_empty_answer_are_different_facts() { - let failing = ScriptedResolver::new(vec![Err("NXDOMAIN".to_string())]); - assert_eq!( - resolve_and_pin("a.example", 443, true, &failing, strict()), - Err(AddressRefusal::Unresolvable { - host: "a.example".to_string(), - reason: "NXDOMAIN".to_string(), - }) - ); - let empty = ScriptedResolver::new(vec![Ok(vec![])]); - assert_eq!( - resolve_and_pin("a.example", 443, true, &empty, strict()), - Err(AddressRefusal::NoAddresses("a.example".to_string())), - "an empty answer has nothing to connect to and nothing to have judged" - ); -} - -// ══ THE STRICT RECOGNISER ════════════════════════════════════════════════════════════════════════ - -#[test] -fn only_http_and_https_are_recognised() { - let banned = [ - "file:///etc/passwd", - "gopher://x/", - "smb://host/share", - "ftp://host/", - "data:text/plain,hi", - "ws://host/", - "/no-scheme", - "host.example/rpc", - ]; - assert_eq!(banned.len(), 8, "the banned-scheme set must not shrink"); - for url in banned { - assert!( - matches!(split_url(url), Err(AddressRefusal::Scheme { .. })), - "`{url}` must be refused on its scheme" - ); - } - assert!(split_url("https://ok.example/rpc").is_ok()); -} - -#[test] -fn userinfo_is_refused_rather_than_stripped() { - assert!(matches!( - split_url("https://evil.test@good.example/rpc"), - Err(AddressRefusal::NoHost(_)) - )); -} - -/// A refusal names the URL, and a URL's authority is where a password goes. -/// -/// The refusal text is written into a card and a log, both read by more people than a config is and -/// kept for longer. Repeating the credential there would make the refusal the one place the secret -/// is written down twice — so the authority's userinfo is replaced by a marker, and the host, which -/// is the whole diagnosis, stays. -#[test] -fn a_refusal_never_repeats_the_credential_in_the_authority() { - let refusals = [ - split_url("https://svc:hunter2@good.example/rpc").expect_err("userinfo is refused"), - split_url("ftp://svc:hunter2@good.example/x").expect_err("the scheme is refused"), - judge_scheme("http://svc:hunter2@good.example/x", false, strict()) - .expect_err("plaintext is refused"), - refuse_oversized_body("https://svc:hunter2@good.example/x", 1 << 30, strict()) - .expect_err("the body is over the ceiling"), - ]; - for refusal in refusals { - let text = refusal.to_string(); - assert!( - !text.contains("hunter2") && !text.contains("svc"), - "the refusal repeated the credential: {text}" - ); - assert!( - text.contains("good.example"), - "the refusal must still name the host it is about: {text}" - ); - } - - // A `@` in the path is not a credential and is left as the operator wrote it. - let path_at = split_url("ftp://good.example/mail@archive").expect_err("the scheme is refused"); - assert!(path_at.to_string().contains("mail@archive")); -} - -#[test] -fn default_ports_are_derived_from_the_scheme_and_ipv6_comes_back_unbracketed() { - let (https, host, port, path) = split_url("https://a.example/rpc").unwrap(); - assert!(https && host == "a.example" && port == 443 && path == "/rpc"); - let (https, _, port, path) = split_url("http://a.internal").unwrap(); - assert!(!https && port == 80 && path == "/"); - let (_, host, port, _) = split_url("https://[::1]:9443/x").unwrap(); - assert_eq!((host.as_str(), port), ("::1", 9443)); - assert_eq!((default_port(true), default_port(false)), (443, 80)); -} - -#[test] -fn plaintext_is_refused_unless_the_policy_admits_it() { - assert!(matches!( - judge_scheme("http://public.example/x", false, strict()), - Err(AddressRefusal::Plaintext { .. }) - )); - assert!(judge_scheme("https://public.example/x", true, strict()).is_ok()); - // Either knob admits it, because opting an upstream into private addressing at all is one - // decision rather than two. - assert!(judge_scheme("http://x.internal/x", false, private_ok()).is_ok()); - assert!(judge_scheme( - "http://public.example/x", - false, - GuardPolicy { - allow_plaintext: true, - ..GuardPolicy::default() - } - ) - .is_ok()); -} - -// ══ REDIRECTS, HOPS AND CAPS ═════════════════════════════════════════════════════════════════════ - -#[test] -fn a_redirect_is_refused_and_names_its_target() { - for status in [301u16, 302, 303, 307, 308] { - assert_eq!( - refuse_redirect(status, Some("http://169.254.169.254/latest/meta-data/")), - Err(AddressRefusal::Redirect { - status, - location: "http://169.254.169.254/latest/meta-data/".to_string(), - }) - ); - } - // Non-3xx passes, so the check is about redirects rather than about everything. - for ok in [200u16, 404, 500] { - assert!(refuse_redirect(ok, None).is_ok()); - } - assert_eq!( - refuse_redirect(302, None), - Err(AddressRefusal::Redirect { - status: 302, - location: "".to_string(), - }) - ); -} - -#[test] -fn the_hop_bound_refuses_at_the_limit_rather_than_past_it() { - let three = GuardPolicy { - max_redirects: 3, - ..GuardPolicy::default() - }; - for hops in 0..3u32 { - assert!(refuse_hop_overflow(hops, "https://a.example/", three).is_ok()); - } - assert_eq!( - refuse_hop_overflow(3, "https://a.example/", three), - Err(AddressRefusal::TooManyRedirects { - limit: 3, - at: "https://a.example/".to_string(), - }) - ); - // Zero redirects is a legitimate setting and means "the document must be where I said". - assert!(refuse_hop_overflow(0, "https://a.example/", GuardPolicy::default()).is_err()); -} - -#[test] -fn the_body_cap_refuses_over_the_ceiling_and_not_at_it() { - let policy = GuardPolicy { - max_body_bytes: 5 * 1024, - ..GuardPolicy::default() - }; - assert!(refuse_oversized_body("https://a.example/", 5 * 1024, policy).is_ok()); - assert_eq!( - refuse_oversized_body("https://a.example/", 5 * 1024 + 1, policy), - Err(AddressRefusal::BodyTooLarge { - url: "https://a.example/".to_string(), - bytes: 5 * 1024 + 1, - }) - ); -} - -/// The default is FAIL-CLOSED in every direction, so a caller that forgets a knob gets the strict -/// answer. A default that permitted anything would make "forgot to set it" indistinguishable from -/// "decided to allow it". -#[test] -fn the_default_policy_is_closed_in_every_direction() { - let d = GuardPolicy::default(); - assert!(!d.allow_private); - assert!(!d.allow_plaintext); - assert!(!d.plaintext_admissible()); - assert_eq!(d.max_redirects, 0); - assert_eq!(d.max_body_bytes, 64 * 1024); - assert_eq!(d.timeout, std::time::Duration::from_secs(10)); -} - -/// `pin_answer` is the one door every resolution goes through, so its own refusals are asserted -/// here rather than only through its callers. -#[test] -fn the_pin_carries_the_first_admissible_address_and_the_scheme_it_was_judged_under() { - let t = pin_answer( - "a.example", - 8443, - true, - &[ip(PUBLIC), ip(PUBLIC_2)], - strict(), - ) - .expect("an all-public answer pins"); - assert_eq!(t.addr(), ip(PUBLIC), "the resolver's own ordering is kept"); - assert_eq!(t.host(), "a.example"); - assert!(t.is_https()); - assert_eq!( - pin_answer("a.example", 443, true, &[], strict()), - Err(AddressRefusal::NoAddresses("a.example".to_string())) - ); -} diff --git a/crates/busbar-kernel/src/tests/net_guard_tests.rs b/crates/busbar-kernel/src/tests/net_guard_tests.rs deleted file mode 100644 index 38ae359859..0000000000 --- a/crates/busbar-kernel/src/tests/net_guard_tests.rs +++ /dev/null @@ -1,452 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (C) 2026 Busbar Inc and contributors - -//! Tests for the network guard AS REACHED THROUGH `busbar_kernel::net_guard`. -//! -//! The guard itself is `busbar_kernel_egress::trust::net` and is tested there too. This suite is not -//! a second copy of that one: it is driven through the SHIM, so it is also the statement that the -//! shim still resolves to the one judge. A re-grown local definition behind `crate::net_guard` -//! would have to pass every row below to go unnoticed — including -//! `the_dialing_guard_and_the_config_guard_know_the_same_metadata_names`, which is the bug that was -//! actually live. - -use super::*; -// The address type these assertions name. It used to arrive through `use super::*` from -// the guard's own imports; the shim imports only what IT needs, so a suite that names a type -// says so itself. -use std::net::Ipv4Addr; - -#[test] -fn is_cgnat_shared_v4_covers_rfc6598_only() { - // 100.64.0.0/10 = first octet 100, second octet's top two bits == 01 (i.e. 64..=127). - assert!(is_cgnat_shared_v4(&Ipv4Addr::new(100, 64, 0, 0))); - assert!(is_cgnat_shared_v4(&Ipv4Addr::new(100, 100, 100, 200))); // Alibaba metadata - assert!(is_cgnat_shared_v4(&Ipv4Addr::new(100, 127, 255, 255))); - // Outside the /10: second octet below 64 or above 127, or different first octet. - assert!(!is_cgnat_shared_v4(&Ipv4Addr::new(100, 63, 255, 255))); - assert!(!is_cgnat_shared_v4(&Ipv4Addr::new(100, 128, 0, 0))); - assert!(!is_cgnat_shared_v4(&Ipv4Addr::new(99, 64, 0, 0))); - assert!(!is_cgnat_shared_v4(&Ipv4Addr::new(8, 8, 8, 8))); -} - -#[test] -fn is_unique_local_v6_covers_fc00_slash_7() { - // fc00::/7 — first 7 bits 1111110, so fc00.. and fd00.. are in-range. - assert!(is_unique_local_v6(&"fc00::1".parse().unwrap())); - assert!(is_unique_local_v6(&"fd00:ec2::254".parse().unwrap())); // EC2 IMDSv6 - assert!(is_unique_local_v6(&"fdff:ffff::".parse().unwrap())); - // Outside fc00::/7. - assert!(!is_unique_local_v6(&"fe80::1".parse().unwrap())); // link-local, not ULA - assert!(!is_unique_local_v6(&"2001:db8::1".parse().unwrap())); - assert!(!is_unique_local_v6(&"::1".parse().unwrap())); -} - -#[test] -fn is_link_local_v6_covers_fe80_slash_10() { - // fe80::/10 — first 10 bits 1111111010. - assert!(is_link_local_v6(&"fe80::1".parse().unwrap())); - assert!(is_link_local_v6(&"febf:ffff::".parse().unwrap())); - // Outside fe80::/10. - assert!(!is_link_local_v6(&"fec0::1".parse().unwrap())); // site-local (deprecated), not fe80::/10 - assert!(!is_link_local_v6(&"fc00::1".parse().unwrap())); // ULA, not link-local - assert!(!is_link_local_v6(&"2001:db8::1".parse().unwrap())); -} - -#[test] -fn is_alternate_ipv4_encoding_flags_obfuscated_forms() { - assert!(is_alternate_ipv4_encoding("2130706433")); // decimal 127.0.0.1 - assert!(is_alternate_ipv4_encoding("0x7f000001")); // hex - assert!(is_alternate_ipv4_encoding("0X7F000001")); // hex, uppercase prefix - assert!(is_alternate_ipv4_encoding("017700000001")); // leading-zero octal - assert!(is_alternate_ipv4_encoding("127.1")); // short dotted - assert!(is_alternate_ipv4_encoding("10.0.1")); // short dotted - assert!(is_alternate_ipv4_encoding("0x7f.0.0.1")); // per-octet hex - assert!(is_alternate_ipv4_encoding("0177.0.0.1")); // per-octet octal - - // Canonical dotted-quads are left to the `parse::()` path, not flagged here. - assert!(!is_alternate_ipv4_encoding("127.0.0.1")); - assert!(!is_alternate_ipv4_encoding("8.8.8.8")); - // DNS names and the empty string are not alternate encodings. - assert!(!is_alternate_ipv4_encoding("api.provider.example")); - assert!(!is_alternate_ipv4_encoding("example.com")); - assert!(!is_alternate_ipv4_encoding("")); -} - -// ══ THE CLASS TEST FOR THE GUARDED-FETCH CHOKE POINT ═════════════════════════════════════════════ -// -// `ip_is_internal` is the ONE address predicate every plane's outbound guard is required to route -// through (structure-lint choke point `H-net-guard`). Its table therefore has to be the UNION of -// what every plane-local copy ever checked, because the tear-out of a copy is only safe if the -// shared predicate already covers everything that copy covered. Two of the rows below arrived here -// exactly that way — from `a2a::pushnotify`'s private copy, which checked ranges this one did not. -// -// The floor at the end is what stops the table quietly shrinking: a row deleted with the range it -// guarded is the failure mode this whole exercise exists to prevent. - -/// EVERY range a busbar guard must refuse, in one table, asserted through the shared entry point. -#[test] -fn the_shared_internal_predicate_covers_every_range_any_plane_ever_checked() { - use std::net::IpAddr; - let cases: &[(&str, &str)] = &[ - ("loopback v4 127/8", "127.0.0.1"), - ("private 10/8", "10.1.2.3"), - ("private 172.16/12", "172.16.5.5"), - ("private 192.168/16", "192.168.1.1"), - ("link-local 169.254/16", "169.254.1.1"), - ("AWS IMDS", "169.254.169.254"), - ("ECS task metadata", "169.254.170.2"), - ("Alibaba metadata (inside CGNAT)", "100.100.100.200"), - ("CGNAT 100.64/10", "100.64.0.1"), - ("Azure WireServer (a PUBLIC address)", "168.63.129.16"), - ("OCI IMDS (a PUBLIC-shaped address)", "192.0.0.192"), - ("unspecified", "0.0.0.0"), - // FROM `a2a::pushnotify`'s copy: 0.0.0.0/8 is "this network", and several stacks route the - // whole block to the local host — so `is_unspecified()` alone (which is only 0.0.0.0) left - // 0.1.2.3 reachable on every plane that used this predicate. - ("this-network 0/8", "0.1.2.3"), - // FROM `a2a::pushnotify`'s copy: 192.0.0.0/24 IETF protocol assignments (the /24 OCI's - // 192.0.0.192 sits inside) and 198.18.0.0/15 benchmarking. Neither is a legitimate - // destination and both are reachable inside some fabrics. - ("IETF protocol assignments 192.0.0/24", "192.0.0.8"), - ("benchmarking 198.18/15", "198.18.0.1"), - ("benchmarking 198.19/16", "198.19.0.1"), - ("broadcast", "255.255.255.255"), - ("multicast v4", "224.0.0.1"), - // ALL THREE DOCUMENTATION BLOCKS (RFC 5737), not just TEST-NET-1. `is_documentation()` - // covers the other two as well, and leaving them unasserted is how a floor gets written - // above the table it guards: these were the rows the count was already reserving room for. - ("documentation TEST-NET-1 192.0.2/24", "192.0.2.1"), - ("documentation TEST-NET-2 198.51.100/24", "198.51.100.7"), - ("documentation TEST-NET-3 203.0.113/24", "203.0.113.9"), - ("loopback v6", "::1"), - ("unspecified v6", "::"), - ("unique-local v6 fc00::/7", "fd00::1"), - ("link-local v6 fe80::/10", "fe80::1"), - ("multicast v6", "ff02::1"), - ("EC2 IMDSv6", "fd00:ec2::254"), - // The two embedded-v4 spellings. The COMPATIBLE one is the literal that got through a copy - // unwrapping with `to_ipv4_mapped()`; it matches no v6 range at all. - ("IPv4-MAPPED metadata", "::ffff:169.254.169.254"), - ("IPv4-COMPATIBLE metadata", "::169.254.169.254"), - ("IPv4-COMPATIBLE loopback", "::127.0.0.1"), - ]; - let mut checked = 0usize; - for (what, spelling) in cases { - let ip: IpAddr = spelling.parse().expect(what); - assert!( - ip_is_internal(&ip), - "{what} ({spelling}) must be internal to the SHARED predicate — a plane that routes \ - through it inherits this row, and a plane that does not is the drift this test exists \ - to catch" - ); - checked += 1; - } - // `checked` equals `cases.len()` by construction (one increment per row, no early `continue`), - // so the anti-shrink guard is a FLOOR on that count, not an equality that could only restate it. - assert!( - checked >= 30, - "the shared hostile table shrank; a deleted row is a range every plane silently stopped \ - guarding" - ); -} - -/// WHITESPACE AROUND A URL MUST NOT BUY A METADATA HOP. The WHATWG basic URL parser begins by -/// trimming leading and trailing C0 controls AND spaces from the input, and by deleting every ASCII -/// tab / CR / LF from anywhere inside it — so a connecting stack sees `169.254.169.254` for every -/// spelling below. Any spelling this guard reads differently from the stack that will dial it is a -/// bypass: a token endpoint POSTs client credentials to the URL verbatim, so a host the guard failed -/// to recognize as IMDS is a host that receives those credentials. -#[test] -fn whitespace_padded_metadata_urls_are_still_refused() { - for spelling in [ - "http://169.254.169.254/latest/meta-data/ ", // trailing space after the path - "http://169.254.169.254 ", // trailing space directly after the host - " http://169.254.169.254/latest/meta-data/", // leading space (would hide the scheme) - "\u{1}http://169.254.169.254/", // leading C0 control - "http://169.254.169.254/\u{1f}", // trailing C0 control - "http://169.254.169\t.254/", // interior tab, deleted by the parser - "http://169.254.169.254\r\n/", // interior CR/LF - "\t http://169.254.169.254/ \r\n", // mixed padding, both ends - ] { - assert_eq!( - ssrf_blocked_host(spelling, &[], false, &[]).as_deref(), - Some("169.254.169.254"), - "{spelling:?} is dialled as the IMDS target once the parser trims and deletes the \ - whitespace the guard must trim and delete the same way" - ); - } -} - -/// The CONTROL for the trim: whitespace INSIDE a host (not at either end of the input, and not one -/// of the three deleted bytes) is left alone, so a malformed host stays malformed rather than being -/// silently repaired into something that matches. -#[test] -fn interior_spaces_are_not_trimmed_away() { - assert_eq!( - extract_normalized_host("http://169.254.169 .254/").as_deref(), - Some("169.254.169 .254") - ); - assert_eq!( - ssrf_blocked_host("http://169.254.169 .254/", &[], false, &[]), - None - ); - assert_eq!( - extract_normalized_host(" https://api.provider.example/v1 ").as_deref(), - Some("api.provider.example") - ); -} - -/// The CONTROL. Without it a predicate that returned `true` unconditionally would pass the table -/// above, and every legitimate upstream in the fleet would be refused. -#[test] -fn the_shared_internal_predicate_admits_ordinary_public_addresses() { - use std::net::IpAddr; - for ok in [ - "93.184.216.34", - "8.8.8.8", - "1.1.1.1", - // 100.128/9 is OUTSIDE the RFC 6598 /10 and is ordinary public space. - "100.128.0.1", - // 198.20/16 is outside the 198.18/15 benchmarking block. - "198.20.0.1", - // 192.0.1.0/24 sits between the IETF-assignments /24 and the documentation /24. - "192.0.1.1", - "2606:4700:4700::1111", - "::ffff:93.184.216.34", - ] { - let ip: IpAddr = ok.parse().expect(ok); - assert!( - !ip_is_internal(&ip), - "{ok} is ordinary public space and must remain reachable" - ); - } -} - -/// CLOUD METADATA IS A SEPARATE QUESTION FROM INTERNAL, because the two carry different policies: -/// an operator may opt into internal addressing with `allow_private`, and may never opt into IMDS. -#[test] -fn cloud_metadata_is_judged_separately_and_covers_every_vendor() { - use std::net::IpAddr; - for meta in [ - "169.254.169.254", // AWS / Azure / GCP / OpenStack / DigitalOcean - "169.254.170.2", // ECS task metadata - "100.100.100.200", // Alibaba - "168.63.129.16", // Azure WireServer - "192.0.0.192", // OCI - "fd00:ec2::254", // EC2 IMDSv6 - "::ffff:169.254.169.254", - "::169.254.169.254", - ] { - let ip: IpAddr = meta.parse().expect(meta); - assert!( - ip_is_cloud_metadata(&ip), - "{meta} is a cloud-metadata endpoint and no policy flag may reach it" - ); - } - assert!(!ip_is_cloud_metadata( - &"93.184.216.34".parse::().unwrap() - )); - // An internal address that is NOT metadata: `allow_private` may reach this one. - assert!(!ip_is_cloud_metadata( - &"10.0.0.1".parse::().unwrap() - )); -} - -/// NAT64 / RFC 6052 EMBEDDING MUST BE JUDGED, not left to fall through to the v6 range checks that -/// do not cover `64:ff9b::/96` at all. -/// -/// A DNS64 resolver on an IPv6-only network answers a AAAA query with the NAT64 synthesis of the -/// queried name's IPv4 address rather than the address itself. A guard that unwraps only -/// `to_ipv4()` (IPv4-MAPPED/IPv4-COMPATIBLE) does not recognise `64:ff9b::/96` at all, so -/// `64:ff9b::a9fe:a9fe` — the IMDS target `169.254.169.254` re-encoded — matches no v6 range and -/// reads as an ordinary public v6 address. This asserts the embedding is judged in both the -/// well-known (RFC 6052) and RFC 8215 local-use forms, including the local-use form with a -/// NON-ZERO middle (RFC 8215 Section 6's own `64:ff9b:1:fffe::/96` worked example). -#[test] -fn nat64_embedded_ipv4_is_judged_by_the_shared_predicates() { - use std::net::{IpAddr, Ipv6Addr}; - - for meta in [ - "64:ff9b::a9fe:a9fe", // RFC 6052 well-known - "64:ff9b:1::a9fe:a9fe", // RFC 8215 local-use, zero-padded - "64:ff9b:1:fffe::a9fe:a9fe", // RFC 8215 local-use, non-zero middle (RFC 8215 Section 6 example) - ] { - let addr: IpAddr = meta.parse().expect(meta); - assert!( - ip_is_cloud_metadata(&addr), - "{meta} is the NAT64 synthesis of the IMDS target 169.254.169.254 and must be judged \ - metadata" - ); - assert!( - ip_is_internal(&addr), - "{meta} is also internal — every cloud-metadata address is internal" - ); - } - for internal in [ - "64:ff9b::7f00:1", // 127.0.0.1 loopback - "64:ff9b::a01:203", // 10.1.2.3 private - "64:ff9b:1::7f00:1", // local-use loopback - "64:ff9b:1:fffe::a01:203", // non-zero-padded local-use private - ] { - let addr: IpAddr = internal.parse().expect(internal); - assert!( - ip_is_internal(&addr), - "{internal} is a NAT64 embedding of an internal IPv4 target and must be internal" - ); - assert!( - !ip_is_cloud_metadata(&addr), - "{internal} is internal but not metadata" - ); - } - - // `embedded_ipv4` decodes the low 32 bits regardless of the operator-chosen local-use middle. - assert_eq!( - embedded_ipv4(&"64:ff9b:1:fffe::a9fe:a9fe".parse::().unwrap()), - Some(Ipv4Addr::new(169, 254, 169, 254)), - ); - // RFC 6052 fixes the ENTIRE well-known /96 to zero: a non-zero middle there is an ordinary - // address, not an embedding, and must NOT be unwrapped. - assert_eq!( - embedded_ipv4(&"64:ff9b::1:0:a9fe:a9fe".parse::().unwrap()), - None, - ); -} - -/// THE HOST-STRING GUARDS MUST JUDGE THE NAT64 EMBEDDING TOO, not just the address predicates. -/// -/// [`ip_is_cloud_metadata`] and [`ip_is_internal`] take an already-parsed `IpAddr` and were fixed -/// first. The three guards below take a HOST STRING and parse it themselves, and each kept its own -/// `to_ipv4()` unwrap — so `64:ff9b::a9fe:a9fe` parsed clean, matched no v6 range, and read as an -/// ordinary public address. That gap was live after the address-level fix landed, which is exactly -/// why this test exists separately from -/// `nat64_embedded_ipv4_is_judged_by_the_shared_predicates`: fixing the predicate does NOT fix the -/// callers that never call it. -/// -/// [`ssrf_blocked_host`] is the pre-flight guard for OAuth token-endpoint URLs and for MCP -/// tool-call ARGUMENT hosts — attacker-influenced input on the classic SSRF injection path — so a -/// bypass here is reachable without any DNS answer at all. -#[test] -fn nat64_embedded_ipv4_is_judged_by_the_host_string_guards() { - // Every spelling of the IMDS target 169.254.169.254 that a caller can hand us. - for meta in [ - "169.254.169.254", // bare v4 - "::ffff:169.254.169.254", // v4-mapped - "64:ff9b::a9fe:a9fe", // NAT64 well-known (RFC 6052) - "64:ff9b:1::a9fe:a9fe", // NAT64 local-use (RFC 8215), zero middle - "64:ff9b:1:fffe::a9fe:a9fe", // NAT64 local-use, non-zero middle - ] { - let url = format!("http://[{meta}]/latest/meta-data/"); - let url = if meta.contains(':') { - url - } else { - format!("http://{meta}/latest/meta-data/") - }; - assert!( - ssrf_blocked_host(&url, &[], false, &[]).is_some(), - "{meta} reaches the cloud-metadata service and must be refused pre-flight; \ - a NAT64 spelling is the same target as the bare form" - ); - } - - // The private/loopback host predicate must see through the embedding as well. - for internal in [ - "64:ff9b::7f00:1", // 127.0.0.1 - "64:ff9b::a01:203", // 10.1.2.3 - "64:ff9b:1:fffe::c0a8:1", // 192.168.0.1 via local-use NAT64 - ] { - assert!( - host_is_private_or_loopback(internal), - "{internal} is a NAT64 embedding of an internal target and must read as internal" - ); - } - - // An operator denylist entry written in the bare v4 form must also catch the NAT64 spelling — - // otherwise the denylist is trivially evaded by re-encoding the same address. Asked through the - // operator's OWN door rather than through the canonicalizer, and asked on an address the - // hardcoded denylist does NOT hold (`10.99.99.99` is a legitimate upstream by default), so the - // refusal can only be coming from the entry: the control below shows the same host passing when - // the list is empty. - const NAT64_OF_10_99_99_99: &str = "https://[64:ff9b::a63:6363]/"; - assert!( - ssrf_blocked_host( - NAT64_OF_10_99_99_99, - &[], - false, - &["10.99.99.99".to_string()] - ) - .is_some(), - "a denylist entry naming the v4 address must match its NAT64 embedding" - ); - assert!( - ssrf_blocked_host(NAT64_OF_10_99_99_99, &[], false, &[]).is_none(), - "the control: without the operator entry this host is an ordinary private upstream, so the \ - assertion above is about the entry and not about the hardcoded denylist" - ); - - // Public addresses are unaffected: 8.8.8.8 embedded in NAT64 is still public. - assert!( - !host_is_private_or_loopback("64:ff9b::808:808"), - "64:ff9b::808:808 embeds public 8.8.8.8 and must NOT read as internal" - ); -} - -/// THE DIALING GUARD AND THE CONFIG GUARD MUST KNOW THE SAME METADATA NAMES. -/// -/// They did not. `ssrf_blocked_host` kept a SIX-name list private to its own body while -/// `judge_host_name` — the arm the resolve-then-pin dialing path consults — read a TWO-name -/// module const. Config validation refused `metadata.tencentyun.com`; the socket did not. -#[test] -fn the_dialing_guard_and_the_config_guard_know_the_same_metadata_names() { - // Every name `ssrf_blocked_host` (config validation) refuses must ALSO be refused by - // `judge_host_name`, which is the arm the resolve-then-pin dialing path actually consults. - for name in [ - "metadata.google.internal", - "metadata.internal", - "metadata.tencentyun.com", - "metadata.platformequinix.com", - "instance-data", - "instance-data.ec2.internal", - ] { - // Config-validation view. - let cfg = ssrf_blocked_host(&format!("https://{name}/"), &[], false, &[]); - // Dialing view — the one a socket is opened behind. - let dial = judge_host_name(name, GuardPolicy::default()); - assert!( - cfg.is_some(), - "{name}: config guard should refuse (this is the 6-name list)" - ); - assert!( - dial.is_err(), - "{name}: DIALING guard did NOT refuse — config refuses it but the dial path allows it" - ); - } -} - -/// The NAME arm is the ONLY defence for a metadata host that answers on a PUBLIC address. -/// -/// `ip_is_cloud_metadata` covers link-local /16 plus three literals plus IMDSv6. A metadata -/// endpoint reachable at a globally-routable address matches NONE of them, so if the name is not -/// in `METADATA_HOSTS` the resolve-then-pin path dials it. -#[test] -fn a_metadata_name_on_a_public_address_is_refused_by_its_name_alone() { - struct Stub(IpAddr); - impl Resolver for Stub { - fn resolve(&self, _host: &str) -> Result, String> { - Ok(vec![self.0]) - } - } - // Equinix Metal's metadata service answers on a public address, which is exactly why it is on - // the denylist by NAME: no address predicate can catch it. - let public = IpAddr::V4(Ipv4Addr::new(147, 75, 1, 1)); - let got = resolve_and_pin( - "metadata.platformequinix.com", - 443, - true, - &Stub(public), - GuardPolicy::default(), - ); - assert!( - got.is_err(), - "metadata.platformequinix.com resolved to a PUBLIC address was PINNED, not refused: {got:?}" - ); -} diff --git a/crates/busbar-kernel/src/tests/observability_tests.rs b/crates/busbar-kernel/src/tests/observability_tests.rs index 4a1a521b63..896d6fe527 100644 --- a/crates/busbar-kernel/src/tests/observability_tests.rs +++ b/crates/busbar-kernel/src/tests/observability_tests.rs @@ -166,8 +166,9 @@ fn test_validate_webhook_url_rejects_https_internal_hosts() { "https://[::1]/hook", // IPv6 loopback "https://[fe80::1]/hook", // IPv6 link-local "https://[fc00::1]/hook", // IPv6 unique-local - // THE RANGES THIS GUARD'S HAND-WRITTEN V6 ARM MISSED while `net_guard::ipv6_is_internal` - // — the predicate it was supposed to be a sibling of — had them. v6 multicast reaches every + // THE RANGES THIS GUARD'S HAND-WRITTEN V6 ARM MISSED while + // `busbar_contract::net::ipv6_is_internal` — the predicate it was supposed to be a sibling + // of — had them. v6 multicast reaches every // node on the segment; the IPv4-COMPATIBLE spelling matches no v6 range at all. "https://[ff02::1]/hook", // IPv6 multicast (all-nodes) "https://[::169.254.169.254]/hook", // IPv4-COMPATIBLE metadata @@ -502,8 +503,9 @@ fn a_registry_level_filter_gates_the_otlp_layer() { /// POSITIVE CONTROL (call site 5/5: `observability.rs`, the webhook / OTLP guard). /// /// The same three planted targets every other call site is driven with. This guard is the one that -/// used to keep its OWN two-name `METADATA_HOSTS`; it now reads `net_guard::METADATA_HOSTS`, so the -/// four names it had never heard of are refused here too. +/// used to keep its OWN two-name `METADATA_HOSTS`; it now reads +/// `busbar_contract::net::METADATA_HOSTS`, so the four names it had never heard of are refused here +/// too. #[test] fn planted_blocked_targets_are_refused_by_the_telemetry_guard() { for target in [ diff --git a/crates/busbar-kernel/tests/no_socket.rs b/crates/busbar-kernel/tests/no_socket.rs index c9759d8166..04a5177069 100644 --- a/crates/busbar-kernel/tests/no_socket.rs +++ b/crates/busbar-kernel/tests/no_socket.rs @@ -73,8 +73,6 @@ const PINNED: &[(&str, &str)] = &[ ("src/egress/engine/resolve.rs", "hyper_util::client"), ("src/egress/mod.rs", "reqwest::"), ("src/secure.rs", "TcpStream"), - // E2: net_guard's system resolver. - ("src/net_guard.rs", "lookup_host"), ]; /// TODAY'S MANIFEST OFFENDERS, struck as the code that needs each one leaves. diff --git a/crates/busbar-voice/src/bin/voice-conform.rs b/crates/busbar-voice/src/bin/voice-conform.rs index eda4d8b5cc..d33f279a09 100644 --- a/crates/busbar-voice/src/bin/voice-conform.rs +++ b/crates/busbar-voice/src/bin/voice-conform.rs @@ -1950,10 +1950,10 @@ fn probe_provider_dial() -> (&'static str, String) { let host = FreshBreakerHost::default(); let url = format!("ws://{addr}"); - let policy = busbar_kernel::net_guard::GuardPolicy { + let policy = busbar_kernel::proxy::egress_unit::trust::net::GuardPolicy { allow_private: true, allow_plaintext: true, - ..busbar_kernel::net_guard::GuardPolicy::default() + ..busbar_kernel::proxy::egress_unit::trust::net::GuardPolicy::default() }; let (mut provider_in, _provider_out) = match busbar_voice::topology::dial_provider( &host, @@ -2115,7 +2115,7 @@ fn probe_route_failover() -> (&'static str, String) { tokio_rt.block_on(async move { let host = FixtureHost::new(); let pool = "stream:conform-route-failover"; - let policy = busbar_kernel::net_guard::GuardPolicy::default(); + let policy = busbar_kernel::proxy::egress_unit::trust::net::GuardPolicy::default(); // ATTEMPT 1 — breaker CLOSED: a real dial to a target the default fail-closed guard refuses (a // plaintext `ws://` loopback address, `allow_plaintext: false` by default) genuinely fails, diff --git a/crates/busbar-voice/src/mount.rs b/crates/busbar-voice/src/mount.rs index 1a7e86addc..fefead2989 100644 --- a/crates/busbar-voice/src/mount.rs +++ b/crates/busbar-voice/src/mount.rs @@ -45,6 +45,7 @@ use crate::topology::{ StartError, }; use busbar_kernel::config::RootCfg; +use busbar_kernel::door::UnitKeyMint; use busbar_kernel::egress::{ duplex_ws::{CredentialPlacement as WsPlacement, DialCredential}, engine::{send_bounded, EngineClient}, @@ -63,7 +64,7 @@ use busbar_kernel::plane_host::{ }; use busbar_kernel::plane_host::{GauntletPlane, GauntletRequest}; use busbar_kernel::plane_routes::PlaneRouteSpec; -use busbar_kernel::{door::UnitKeyMint, net_guard::GuardPolicy}; +use busbar_kernel::proxy::egress_unit::trust::net::GuardPolicy; use bytes::Bytes; use http_body_util::{BodyExt, Full}; use std::any::Any; diff --git a/crates/busbar-voice/src/tests/breaker_tests.rs b/crates/busbar-voice/src/tests/breaker_tests.rs index f615ef934c..1157b1ca02 100644 --- a/crates/busbar-voice/src/tests/breaker_tests.rs +++ b/crates/busbar-voice/src/tests/breaker_tests.rs @@ -19,7 +19,7 @@ use crate::testkit::fixture_host::FixtureHost; use crate::topology::{dial_provider, stream_breaker_key, DialProviderError}; use busbar_contract::upstream::{CanonicalSignal, StatusClass}; -use busbar_kernel::net_guard::GuardPolicy; +use busbar_kernel::proxy::egress_unit::trust::net::GuardPolicy; use busbar_kernel::store::BreakerState; /// A canonical hard-down signal — the disposition a definitive provider failure (auth/billing, or a diff --git a/crates/busbar-voice/src/tests/hook_gate_tests.rs b/crates/busbar-voice/src/tests/hook_gate_tests.rs index da445c4c21..80086993c6 100644 --- a/crates/busbar-voice/src/tests/hook_gate_tests.rs +++ b/crates/busbar-voice/src/tests/hook_gate_tests.rs @@ -176,10 +176,10 @@ async fn ws_accept_handshake(host: Arc) -> Result<(), ()> { tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); - let policy = busbar_kernel::net_guard::GuardPolicy { + let policy = busbar_kernel::proxy::egress_unit::trust::net::GuardPolicy { allow_private: true, allow_plaintext: true, - ..busbar_kernel::net_guard::GuardPolicy::default() + ..busbar_kernel::proxy::egress_unit::trust::net::GuardPolicy::default() }; match busbar_kernel::egress::duplex_ws::dial(&format!("ws://{addr}/telephony/call-ws"), policy) .await diff --git a/crates/busbar-voice/src/topology/mod.rs b/crates/busbar-voice/src/topology/mod.rs index cba4e24620..97f960b1ac 100644 --- a/crates/busbar-voice/src/topology/mod.rs +++ b/crates/busbar-voice/src/topology/mod.rs @@ -34,12 +34,12 @@ use crate::runtime::VoiceRuntime; use busbar_contract::transport::transport::{Transport, UpstreamWireKind}; use busbar_contract::upstream::{CanonicalSignal, StatusClass}; use busbar_kernel::egress::duplex_ws::{self, DialCredential, DialError, DialTarget}; -use busbar_kernel::net_guard::GuardPolicy; use busbar_kernel::plane::handle_engine::HandleEngineError; use busbar_kernel::plane_host::{ breaker, run_gauntlet_session, BreakerHost, DispatchScope, GauntletPlane, GauntletRequest, VerifyOutcome, }; +use busbar_kernel::proxy::egress_unit::trust::net::GuardPolicy; use futures::{Sink, Stream}; use std::sync::Arc; diff --git a/crates/busbar-voice/src/topology/tests/mod.rs b/crates/busbar-voice/src/topology/tests/mod.rs index 77426095cb..9689354a4a 100644 --- a/crates/busbar-voice/src/topology/tests/mod.rs +++ b/crates/busbar-voice/src/topology/tests/mod.rs @@ -28,7 +28,7 @@ use busbar_kernel::ingress::byte_duplex::{CallRef, DuplexHandle, DuplexPlane}; #[cfg(feature = "test-support")] use busbar_kernel::ingress::duplex_ws as ws_ingress; #[cfg(feature = "test-support")] -use busbar_kernel::net_guard::GuardPolicy; +use busbar_kernel::proxy::egress_unit::trust::net::GuardPolicy; #[cfg(feature = "test-support")] use futures::SinkExt; diff --git a/crates/busbar/src/root/connector.rs b/crates/busbar/src/root/connector.rs index f7a46e25bc..f0c39d25ac 100644 --- a/crates/busbar/src/root/connector.rs +++ b/crates/busbar/src/root/connector.rs @@ -165,11 +165,13 @@ pub fn guard_for(d: &Destinations) -> Result, String> { /// build commits: every config commit is raised to it there. The same capability carries the one /// TLS wrap every kernel outbound connection is secured with — the connector's /// (`busbar_core_connector::tls::engine::Layer`; THE DESIGN: TLS stays in the connector, and the -/// kernel names no TLS library). +/// kernel names no TLS library), and the one name lookup every kernel resolve-then-pin asks +/// (`busbar_core_connector::process::SystemNames`; row 130 E2: the kernel resolves no name itself). pub fn install_egress_trust(dest: Arc) { install_egress_trust_host(Arc::new(GuardedEgressTrust( dest, Arc::new(busbar_core_connector::tls::engine::Layer), + Arc::new(process::SystemNames), ))); } diff --git a/crates/busbar/tests/net_guard_one_judge.rs b/crates/busbar/tests/net_guard_one_judge.rs index 4a8ab47eab..2a418e9255 100644 --- a/crates/busbar/tests/net_guard_one_judge.rs +++ b/crates/busbar/tests/net_guard_one_judge.rs @@ -17,13 +17,15 @@ //! //! ## What it is now //! -//! There is one definition. It lives in [`busbar_kernel_egress::trust::net`], and -//! `busbar_kernel::net_guard` is a re-export shim over it. So the two names below are two paths to -//! ONE function, and every equality in this file is trivially true — *while that stays true*. +//! There is one definition. It lives in [`busbar_kernel_egress::trust::net`]. The kernel's +//! `net_guard` re-export shim is gone (row 130 E2); the kernel reaches the judge's config arm as +//! `busbar_kernel::config_validate::{extract_normalized_host, ssrf_blocked_host}`, re-exports of +//! the unit's. So the two names below are two paths to ONE function, and every equality in this +//! file is trivially true — *while that stays true*. //! //! That is precisely what this file now gates. The failure mode it watches for is no longer "the //! two copies disagree" but "somebody re-grew a second copy behind one of these names", and the -//! shape of that regression is identical: a local definition under `busbar_kernel::net_guard` that +//! shape of that regression is identical: a local definition under `busbar_kernel::config_validate` that //! answers some hostile spelling differently from the unit. The rows below are chosen to be the //! spellings a re-fork would get wrong — in particular [`BARE_AUTHORITIES`], which the shim's //! predecessor answered `None` for while the unit judged them, and the metadata NAMES, which is @@ -97,7 +99,7 @@ const HOSTILE: &[&str] = &[ /// spell its destination either way and the operator's denylist fires for both. /// /// They stay in this file as the sharpest re-fork detector it has. A second copy grown behind -/// `busbar_kernel::net_guard` would almost certainly be the OLD shape — scheme-only extraction, +/// `busbar_kernel::config_validate` would almost certainly be the OLD shape — scheme-only extraction, /// `None` for every row here — so this is the table where "somebody re-forked the guard" shows up /// as a refusal that silently stopped happening. const BARE_AUTHORITIES: &[&str] = &[ @@ -117,11 +119,11 @@ const BARE_AUTHORITIES: &[&str] = &[ fn both_names_reach_one_extraction() { let mut compared = 0usize; for spelling in HOSTILE.iter().chain(BARE_AUTHORITIES) { - let through_kernel = busbar_kernel::net_guard::extract_normalized_host(spelling); + let through_kernel = busbar_kernel::config_validate::extract_normalized_host(spelling); let through_unit = busbar_kernel_egress::trust::net::extract_normalized_host(spelling); assert_eq!( through_kernel, through_unit, - "`busbar_kernel::net_guard` and the egress unit read {spelling:?} as different hosts — \ + "`busbar_kernel::config_validate` and the egress unit read {spelling:?} as different hosts — \ the shim has grown a second definition, and whichever one a call site routes through \ now decides whether this destination is guarded" ); @@ -149,13 +151,18 @@ fn both_names_reach_one_denylist_judgement() { for spelling in HOSTILE.iter().chain(BARE_AUTHORITIES) { // The bare guard, no operator lists at all. assert_eq!( - busbar_kernel::net_guard::ssrf_blocked_host(spelling, &[], false, &[]), + busbar_kernel::config_validate::ssrf_blocked_host(spelling, &[], false, &[]), busbar_kernel_egress::trust::net::ssrf_blocked_host(spelling, &[], false, &[]), "bare guard disagrees on {spelling:?}" ); // With the operator's denylist — the arm the trailing-space bypass defeated. assert_eq!( - busbar_kernel::net_guard::ssrf_blocked_host(spelling, &[], false, &operator_blocked), + busbar_kernel::config_validate::ssrf_blocked_host( + spelling, + &[], + false, + &operator_blocked + ), busbar_kernel_egress::trust::net::ssrf_blocked_host( spelling, &[], @@ -166,7 +173,7 @@ fn both_names_reach_one_denylist_judgement() { ); // With a surgical allow-override, which must win through both names or through neither. assert_eq!( - busbar_kernel::net_guard::ssrf_blocked_host( + busbar_kernel::config_validate::ssrf_blocked_host( spelling, &operator_allowed, false, @@ -197,7 +204,7 @@ fn the_bare_authority_arm_is_reached_through_the_kernel_name() { for spelling in BARE_AUTHORITIES { for blocked in [&[][..], &operator_blocked[..]] { let through_kernel = - busbar_kernel::net_guard::ssrf_blocked_host(spelling, &[], false, blocked); + busbar_kernel::config_validate::ssrf_blocked_host(spelling, &[], false, blocked); let through_unit = busbar_kernel_egress::trust::net::ssrf_blocked_host(spelling, &[], false, blocked); assert_eq!( @@ -229,30 +236,30 @@ fn the_bare_authority_arm_is_reached_through_the_kernel_name() { /// [`judge_host_name`]: busbar_kernel_egress::trust::net::judge_host_name #[test] fn the_dialing_arm_and_the_config_arm_read_the_same_metadata_list() { - let names = busbar_kernel::net_guard::METADATA_HOSTS; + let names = busbar_contract::net::METADATA_HOSTS; assert_eq!( names, busbar_kernel_egress::trust::net::METADATA_HOSTS, - "the shim and the unit publish different metadata lists" + "the contract and the unit publish different metadata lists" ); assert!( names.len() >= 6 && names.contains(&"metadata.platformequinix.com"), "the metadata list shrank back toward the two-entry copy: {names:?}" ); - let permissive = busbar_kernel::net_guard::GuardPolicy { + let permissive = busbar_kernel_egress::trust::net::GuardPolicy { allow_private: true, - ..busbar_kernel::net_guard::GuardPolicy::default() + ..busbar_kernel_egress::trust::net::GuardPolicy::default() }; for name in names { // The DIALING arm, under the permissive stance: the metadata arm is unconditional, so // `allow_private` must not reach it. assert!( - busbar_kernel::net_guard::judge_host_name(name, permissive).is_err(), + busbar_kernel_egress::trust::net::judge_host_name(name, permissive).is_err(), "`{name}` is on the metadata list and the dialing arm let it through" ); // And the CONFIG arm, over the same name spelled as a URL. assert!( - busbar_kernel::net_guard::ssrf_blocked_host( + busbar_kernel::config_validate::ssrf_blocked_host( &format!("https://{name}/latest/meta-data/"), &[], false, @@ -271,7 +278,7 @@ fn the_hostile_table_actually_reaches_the_guard_through_both_names() { let mut blocked_through_kernel = 0usize; let mut blocked_through_unit = 0usize; for spelling in HOSTILE { - if busbar_kernel::net_guard::ssrf_blocked_host(spelling, &[], false, &[]).is_some() { + if busbar_kernel::config_validate::ssrf_blocked_host(spelling, &[], false, &[]).is_some() { blocked_through_kernel += 1; } if busbar_kernel_egress::trust::net::ssrf_blocked_host(spelling, &[], false, &[]).is_some() diff --git a/qa/kind-isolation.toml b/qa/kind-isolation.toml index 700c70b9bd..046ac027cf 100644 --- a/qa/kind-isolation.toml +++ b/qa/kind-isolation.toml @@ -898,6 +898,16 @@ cite = "BUSBAR-1.6.0.md:3780 (R2/#37) and ARCHITECT ruling: cleanliness crate why = "busbar-core-connector names busbar-kernel: declared normal in the shipped graph, 1 declaration(s) at this commit. The count is TODAY'S MEASUREMENT, exact in both directions — above it is the landing that grew the coupling, below it is stale slack that was not drained on the commit that drained the edge." drain = "none: BUSBAR-1.6.0.md grants this class. Draining it would be an architecture change and not a landing — what this row holds is the NUMBER, so a second declaration of a granted edge is still a landing that has to say why." +[[dep]] +from = "busbar-core-connector" +to = "busbar-kernel-egress" +half = "shipped" +count = "1" +verdict = "allowed" +cite = "BUSBAR-1.6.0.md:3780 (R2/#37) and ARCHITECT ruling: cleanliness crates are compiled-in with a one-way dependency on the kernel; the root links them (ARCHITECTURE_ALLOWED cleanliness -> kernel). Here it reads the egress unit's one http(s) URL reader (trust::net::split_url) and implements its Resolver seam with the system name lookup the kernel's resolve-then-pin dials ask (process::SystemNames): the kernel resolves no name itself (status row 130 E2; THE DESIGN §5: the one destination check is the connector's)." +why = "busbar-core-connector names busbar-kernel-egress: declared normal in the shipped graph, 1 declaration(s) at this commit. The count is TODAY'S MEASUREMENT, exact in both directions — above it is the landing that grew the coupling, below it is stale slack that was not drained on the commit that drained the edge." +drain = "none: BUSBAR-1.6.0.md grants this class. Draining it would be an architecture change and not a landing — what this row holds is the NUMBER, so a second declaration of a granted edge is still a landing that has to say why." + [[dep]] from = "busbar" to = "busbar-core-connector" diff --git a/xtask/src/gates/population.rs b/xtask/src/gates/population.rs index 3591dcea02..917f013400 100644 --- a/xtask/src/gates/population.rs +++ b/xtask/src/gates/population.rs @@ -17,7 +17,7 @@ //! //! * [`Population::below_floor`] — the aggregate ratchet. It is not `> 0`: a walk that collapses to //! a handful of files reports no findings and reads exactly like a clean tree. The floor is a -//! RATCHET pinned AT the measured count ([`FLOOR`], 948 after row 130 E1 merged with predev +//! RATCHET pinned AT the measured count ([`FLOOR`], 947 after row 130 E2 merged with predev //! 1c586876f8), not a margin below it: one file fewer is refused until a reviewed diff //! re-measures. It is raised as the tree grows, never lowered to accommodate a scan that stopped //! finding things. @@ -46,12 +46,14 @@ use crate::ctx::{Ctx, Overlay, SourceFile, WalkSpec}; /// `outputschema.rs` and added `door_sessions.rs`, a reviewed net removal of one file. Raised from /// 947 to 948 when row 130 E1 (#710's ruling) moved the inbound listener's serve loop out of /// `busbar-kernel/src/tls.rs` into the new `busbar/src/root/listener.rs` (the kernel file stays for -/// `read_pem`, so the tree grew by that one file). A drop below 948 is refused until a reviewed diff -/// re-measures. The selftest plant ([`one_file_short`]) +/// `read_pem`, so the tree grew by that one file). Lowered from 948 to 947 when row 130 E2 deleted +/// the kernel's `net_guard.rs` re-export shim (its call sites name the egress unit and the contract +/// directly; its resolver moved into the connector's existing `process.rs`), a reviewed removal of +/// one file. A drop below 947 is refused until a reviewed diff re-measures. The selftest plant ([`one_file_short`]) /// cuts the live population to `FLOOR - 1` whatever its size, so predev growing a file no longer /// forces a re-pin for the plant's sake. Lowering the floor is how a gate stops reading the /// repository without saying so, so a diff that lowers it is the diff to refuse. -pub const FLOOR: usize = 948; +pub const FLOOR: usize = 947; /// The `.rs` under `crates/` that are not test scaffolding, plus the accounting to refuse a /// population that cannot support a verdict. From 1378e884139eec97b86893cc25bd0d70a5a8f055 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:54:17 -0700 Subject: [PATCH 08/59] decisions: a request routes by its model, and upstream_model is spliced (audit plane-decisions#1, #2) THE DESIGN section 2, the decisions bullet: "a request routes by its model; one configured model and none named is the default (byte-identical); more than one and none named is 400; an unknown model is 404. `upstream_model` rewrites only the top-level model value, by span splice, when it is set and differs; otherwise the body passes through." DECISIONS D8b (BUSBAR-1.6.0.md:4798, :4802) lifts the exactly-one-model mount and adds /model to REQUEST_PTRS. plane-decisions#1: the snapshot claimed POST /v1/systemone only when exactly one model was configured, and arrive routed every request over that one model whatever its body named. Now the claim is published whenever at least one model is configured (none: nothing is claimed, and the kernel's unclaimed-route 404 stands byte for byte), and arrive reads the top-level `model` (driven::route_model): the named model routes ROUTE_DIRECT; one configured and none named is the default; several and none named is a refused arrival at 400; a name no model has is 404; a `model` that is not a string is 400. The refusals render in jev's error shape through the refusal slot. The door keeps every configured model of the latest generation (name -> upstream_model) in place of the single routed name. plane-decisions#2: `decisions.models..upstream_model` was read nowhere. The routed model's upstream_model is kept on the unit at arrive, and the caller's body bound for the far end has its top-level `model` value alone replaced (driven::spliced) when the two differ; every other byte is the caller's. The jev rig gains a second boot with two models (one with upstream_model) and three checks: models.unnamed-400, models.unknown-404, models.upstream-model; the plane's conformance inputs expect the claim on the two-model refresh. RED: tests/door.rs every_generation_with_a_model_claims_systemone, a_request_routes_by_its_model, upstream_model_is_spliced_into_the_top_level_model (over the door's ABI); src/tests/driven.rs one_or_more_models_mount_the_claim. --- .../busbar-plane-decisions/src/codec/mod.rs | 18 +- crates/busbar-plane-decisions/src/driven.rs | 142 +++++- crates/busbar-plane-decisions/src/plane.rs | 6 +- .../busbar-plane-decisions/src/plane_door.rs | 83 +-- .../busbar-plane-decisions/src/tests/codec.rs | 9 +- .../src/tests/driven.rs | 87 +++- .../src/tests/plane_door.rs | 10 +- .../tests/byte_fixtures.rs | 5 +- .../tests/conformance.json | 2 +- crates/busbar-plane-decisions/tests/door.rs | 149 ++++++ .../tests/door_harness/mod.rs | 477 ++++++++++++++++++ crates/busbar-plane-decisions/tests/jev.rs | 5 +- xtask/src/conformance_record/jev.rs | 192 ++++++- xtask/src/conformance_record/mod.rs | 10 +- xtask/tests/conformance_rigs.rs | 66 +++ 15 files changed, 1185 insertions(+), 76 deletions(-) create mode 100644 crates/busbar-plane-decisions/tests/door.rs create mode 100644 crates/busbar-plane-decisions/tests/door_harness/mod.rs diff --git a/crates/busbar-plane-decisions/src/codec/mod.rs b/crates/busbar-plane-decisions/src/codec/mod.rs index 29ac3a65c7..1a373e7809 100644 --- a/crates/busbar-plane-decisions/src/codec/mod.rs +++ b/crates/busbar-plane-decisions/src/codec/mod.rs @@ -46,14 +46,16 @@ pub const PTR_USAGE_UNITS: &str = "/usage/units"; /// rather than auditing every call site. pub const RESPONSE_PTRS: &[&str] = &[PTR_ERROR, PTR_REQUEST_ID, PTR_ID, PTR_USAGE_UNITS]; -/// Every pointer this plane resolves in a REQUEST body. -/// -/// Empty. A `systemone` request carries the caller's `state` and nothing else this plane has a use -/// for — there is no safe member to declare a pointer at, so none is declared. This is not an -/// oversight the way an empty ingress pointer list would be for MCP/A2A (which read a method name -/// and an id out of every request): jev names its operation in the path, not the body, so the -/// REQUEST body genuinely carries nothing this plane reads. -pub const REQUEST_PTRS: &[&str] = &[]; +/// The pointer a request names the model it is routed over at: its top-level `model` (THE DESIGN +/// section 2, model resolution; DECISIONS D8b "REQUEST_PTRS += /model (metadata)"). Routing +/// metadata, never decision content. +pub const PTR_MODEL: &str = "/model"; + +/// Every pointer this plane resolves in a REQUEST body: the model it routes over, and nothing else. +/// A `systemone` request carries the caller's `state`, which this plane never reads; jev names its +/// operation in the path, so the top-level `model` is the one member routing needs +/// (`driven::route_model`) and the one `upstream_model` rewrites (`driven::spliced`). +pub const REQUEST_PTRS: &[&str] = &[PTR_MODEL]; /// The span view of a body, built from one of the two declared pointer lists above. /// diff --git a/crates/busbar-plane-decisions/src/driven.rs b/crates/busbar-plane-decisions/src/driven.rs index 54ef26f741..43c5e24d12 100644 --- a/crates/busbar-plane-decisions/src/driven.rs +++ b/crates/busbar-plane-decisions/src/driven.rs @@ -17,22 +17,23 @@ //! Writing these answers into the host buffers of the plane ABI (`busbar_contract::abi::plane`) is //! the plane door's job, one generic adapter for every plane; it is not this plane's. //! -//! ZERO OR SEVERAL DECISIONS MODELS — the driver's rule is today's behaviour, byte for byte. A jev -//! request names no model, so it has a destination only when `decisions.models` holds exactly one. -//! Measured on the busbar binary before the driver serves this plane: +//! MODEL RESOLUTION (THE DESIGN section 2, the decisions bullet; DECISIONS D8b): "a request routes +//! by its model; one configured model and none named is the default (byte-identical); more than one +//! and none named is 400; an unknown model is 404. `upstream_model` rewrites only the top-level model +//! value, by span splice, when it is set and differs; otherwise the body passes through." //! -//! | config | `--validate` and boot | `POST /v1/systemone` | -//! |---|---|---| -//! | no jev provider, `decisions.models: {}` | accepted | `404`, the unclaimed-route body | -//! | a jev provider, `decisions.models: {}` | refused, `BUSBAR-3015`: provider '' has unknown protocol 'jev' | (no boot) | -//! | one model | accepted | `404`, the unclaimed-route body (the served operation is new surface) | -//! | two or more models | accepted | `404`, the unclaimed-route body | +//! | config | `POST /v1/systemone` | +//! |---|---| +//! | `decisions.models: {}` | `404`, the kernel's unclaimed-route body (nothing is claimed) | +//! | one model, none named | routed over that model, the body byte for byte | +//! | several models, none named | `400` ([`Unrouted::Unnamed`]) | +//! | a model named | routed over it; a name no model has is `404` ([`Unrouted::Unknown`]) | //! -//! The unclaimed-route body is `{"error":{"code":null,"message":"the requested resource was not -//! found","param":null,"type":"not_found_error"}}`. So with zero or several models the driver serves -//! no `systemone` claim for this generation (the snapshot publishes [`served_claims`], empty) and the request falls -//! through to that same 404; there is no boot check and no new refusal. Only exactly one model -//! mounts the claim. +//! With no model configured the snapshot publishes no claim ([`served_claims`]), so the request +//! falls through to the kernel's unclaimed-route body, +//! `{"error":{"code":null,"message":"the requested resource was not found","param":null,"type":"not_found_error"}}`, +//! byte for byte as before. The top-level `model` ([`codec::PTR_MODEL`]) is the one request member +//! this plane reads, and only to route ([`route_model`]) and splice ([`spliced`]). //! //! WHAT THIS PLANE DOES NOT CLAIM: `GET /v1/models`. That path keeps its 1.5.5 bytes (the model //! list busbar already serves there), so [`tail::CLAIMS`] and [`tail::OP_CLASSES`] carry `systemone` alone and @@ -122,14 +123,119 @@ pub mod tail { } /// The claims a generation's snapshot publishes, given how many `decisions.models` it configures: -/// [`tail::CLAIMS`] for exactly one, none otherwise (see the module doc's measured table). +/// [`tail::CLAIMS`] for one or more, none for none (see the module doc's table). #[must_use] pub fn served_claims(models: usize) -> &'static [(&'static str, &'static str)] { - if models == 1 { - tail::CLAIMS - } else { + if models == 0 { &[] + } else { + tail::CLAIMS + } +} + +/// Why a claimed request routes over no configured model (THE DESIGN section 2: "more than one and +/// none named is 400; an unknown model is 404"). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Unrouted { + /// The request names no model and the generation configures several. + Unnamed, + /// The request's `model` is not a string. + NotAName, + /// The request names a model the generation does not configure. + Unknown(String), +} + +impl Unrouted { + /// The status the refusal wears. + #[must_use] + pub const fn status(&self) -> u16 { + match self { + Self::Unnamed | Self::NotAName => 400, + Self::Unknown(_) => 404, + } + } + + /// The words the caller reads, in the dialect's error shape ([`refusal_body`]). + #[must_use] + pub fn text(&self) -> String { + match self { + Self::Unnamed => "the request names no model, and several decisions models are \ + configured: name one in `model`" + .to_string(), + Self::NotAName => "`model` must be a string naming a decisions model".to_string(), + Self::Unknown(model) => { + format!("The model '{model}' does not exist or you do not have access to it.") + } + } + } +} + +/// The top-level `model` a request body names: `Ok(None)` when it names none, and +/// [`Unrouted::NotAName`] when the member is there but is not a string. +/// +/// # Errors +/// +/// [`Unrouted::NotAName`], as above. +pub fn requested_model(body: &[u8]) -> Result>, Unrouted> { + let Some(raw) = codec::read_raw(body, codec::PTR_MODEL) else { + return Ok(None); + }; + if let Ok(name) = serde_json::from_slice::<&str>(raw) { + return Ok(Some(std::borrow::Cow::Borrowed(name))); + } + serde_json::from_slice::(raw) + .map(|name| Some(std::borrow::Cow::Owned(name))) + .map_err(|_| Unrouted::NotAName) +} + +/// THE MODEL A CLAIMED REQUEST ROUTES OVER among `models` (the generation's configured names, each +/// with what this plane keeps of it): the one it names, else the only one configured. Answers the +/// configured name and its entry. +/// +/// # Errors +/// +/// [`Unrouted`]: several configured and none named, a `model` that is not a string, or a name none +/// of `models` has. +pub fn route_model<'m, V>( + body: &[u8], + models: &'m std::collections::BTreeMap, +) -> Result<(&'m str, &'m V), Unrouted> { + match requested_model(body)? { + Some(name) => models + .get_key_value(name.as_ref()) + .map(|(k, v)| (k.as_str(), v)) + .ok_or_else(|| Unrouted::Unknown(name.into_owned())), + None => { + let mut all = models.iter(); + match (all.next(), all.next()) { + (Some((k, v)), None) => Ok((k.as_str(), v)), + _ => Err(Unrouted::Unnamed), + } + } + } +} + +/// THE `upstream_model` SPLICE: `body` with its top-level `model` value replaced by `upstream`, when +/// the body names a model and it differs from `upstream`; `None` when the body passes through +/// unchanged. Every byte outside the value's span is kept: member order, whitespace and the rest of +/// the document are the caller's. +#[must_use] +pub fn spliced(body: &[u8], upstream: &str) -> Option> { + let busbar_contract::spans::Resolved::Found(span) = + busbar_contract::spans::resolve_pointer(body, codec::PTR_MODEL) + else { + return None; + }; + let named = requested_model(body).ok().flatten()?; + if named == upstream { + return None; } + let value = serde_json::to_string(upstream).ok()?; + let mut out = Vec::with_capacity(body.len() - (span.end - span.start) + value.len()); + out.extend_from_slice(body.get(..span.start)?); + out.extend_from_slice(value.as_bytes()); + out.extend_from_slice(body.get(span.end..)?); + Some(out) } /// Whether the kernel must verify a principal before the first piece. diff --git a/crates/busbar-plane-decisions/src/plane.rs b/crates/busbar-plane-decisions/src/plane.rs index 0df633c4b6..152888403e 100644 --- a/crates/busbar-plane-decisions/src/plane.rs +++ b/crates/busbar-plane-decisions/src/plane.rs @@ -197,9 +197,9 @@ impl Plane for DecisionPlane { let facts = self.draft_facts(row); Ok(Ingress::OneShot(Box::new(UnitDraft { op: row.op, - // The request body is never read for a declared pointer — see `codec::REQUEST_PTRS`'s - // own note on why that list is empty. The view still carries the WHOLE body, because - // byte-identity forwarding reads `Ir::body()`, never a pointer. + // The request body is read at the declared pointers alone (`codec::REQUEST_PTRS`, the + // top-level `model`). The view still carries the WHOLE body, because byte-identity + // forwarding reads `Ir::body()`, never a pointer. body_ir: codec::view(body, REQUEST_PTRS, ctx)?, correlates: None, correlation_out: None, diff --git a/crates/busbar-plane-decisions/src/plane_door.rs b/crates/busbar-plane-decisions/src/plane_door.rs index ce5d1606da..4006ed2e42 100644 --- a/crates/busbar-plane-decisions/src/plane_door.rs +++ b/crates/busbar-plane-decisions/src/plane_door.rs @@ -22,10 +22,13 @@ //! configures (the kernel filters them by scope and `/v1/models` appends them), and binds no //! audience (the operation is served on the plain data plane to a keyed caller). `retire` drops //! a generation. -//! * `arrive`: [`crate::driven::arrive`]; an unclaimed request is refused at 404. +//! * `arrive`: [`crate::driven::arrive`], routed over the model the request names +//! ([`crate::driven::route_model`]); an unclaimed request is refused at 404, and one that routes +//! over no model at its [`crate::driven::Unrouted`] status. //! * `on_piece`: the ATTEMPT's request head ([`crate::driven::attempt_request`]), the caller's body to the -//! far end unchanged ([`crate::driven::caller_piece`]), and the far end's answer relayed unchanged -//! with its count read at the last piece ([`crate::driven::FarEndReading`]). +//! far end unchanged ([`crate::driven::caller_piece`]) but for the routed model's `upstream_model` +//! ([`crate::driven::spliced`]), and the far end's answer relayed unchanged with its count read at +//! the last piece ([`crate::driven::FarEndReading`]). //! * `refusal`: [`crate::driven::refusal_body`]. //! * `serve` and `project` are REFUSED: the plane publishes no admin route and binds no hook view. //! `hydrate`, `start`, `tick`, `drive`, `cancel`, `release` and `close` hold nothing. @@ -81,6 +84,9 @@ pub const UNCLAIMED: u32 = 1; /// The status an unclaimed request is refused at. const STATUS_NOT_FOUND: u32 = 404; +/// [`ArriveOut::refusal`]: the request routes over no configured model ([`driven::Unrouted`]). +pub const UNROUTED: u32 = 2; + /// The human label. const LABEL: &str = "Decisions"; @@ -320,6 +326,9 @@ struct Unit { caller: Vec<(Vec, Vec)>, /// The far end's status, read off its first piece; the request fee is judged on it. status: Option, + /// The routed model's `upstream_model`, kept from `arrive`: the caller's top-level `model` is + /// spliced to it on the way to the far end where the two differ ([`driven::spliced`]). + upstream: Option, } /// One instance: every live generation's snapshot, and the units in flight. @@ -327,28 +336,23 @@ struct Unit { pub struct DecisionsDoor { generations: Generations, units: Keyed, - /// The entry a claimed arrival routes over, directly: the one configured model of the latest - /// generation (none held when the section configures other than one, and nothing is claimed). - routed: Keyed<(), String>, + /// The models a claimed arrival routes over, directly, by name: every model the latest + /// generation configures, each with its `upstream_model`. + models: Keyed>, } impl DecisionsDoor { - /// Route claimed arrivals over `section`'s one model, or none. + /// Route claimed arrivals over `section`'s models. fn route_over(&self, section: &DecisionsSection) { - match routed_model(section) { - Some(model) => self.routed.insert((), model), - None => self.routed.remove(&()), - }; - } -} - -/// The entry a claimed arrival routes over under `section`: its one model, when it configures -/// exactly one ([`driven::served_claims`]' rule); `None` otherwise. -fn routed_model(section: &DecisionsSection) -> Option { - let mut models = section.models.keys(); - match (models.next(), models.next()) { - (Some(one), None) => Some(one.clone()), - _ => None, + self.models.with_all(|models| { + models.clear(); + models.extend( + section + .models + .iter() + .map(|(name, model)| (name.clone(), model.upstream_model.clone())), + ); + }); } } @@ -390,7 +394,7 @@ slot!( let plane = DecisionsDoor { generations: Generations::new(), units: Keyed::new(), - routed: Keyed::new(), + models: Keyed::new(), }; plane.route_over(§ion); let spec = generation_spec(§ion); @@ -459,8 +463,9 @@ slot!( ); slot!( - /// `arrive`: a claimed request's op class, principal need, dialect and route (its one model, - /// directly: ARCHITECT Q-SW6/Q-FL3); an unclaimed one is refused at 404. + /// `arrive`: a claimed request's op class, principal need, dialect and route (the model it + /// names, or the only one configured, directly: ARCHITECT Q-SW6/Q-FL3); an unclaimed one is + /// refused at 404, and one that routes over no model at its [`driven::Unrouted`] status. Arrive, ArriveIn, ArriveOut, |instance, input, mut out| { let verb = input.field(|i| &i.method).as_str().unwrap_or_default(); let target = input.field(|i| &i.target).as_str().unwrap_or_default(); @@ -480,9 +485,22 @@ slot!( }, ); if let Some(plane) = instance.get() { - if let Some(model) = plane.routed.get(&()) { - out.route(ROUTE_DIRECT, &model); - } + let body = input.field(|i| &i.body).bytes(); + let routed = plane.models.with_all(|models| { + driven::route_model(body, models) + .map(|(name, upstream)| (name.to_string(), upstream.clone())) + }); + let upstream = match routed { + Ok((model, upstream)) => { + out.route(ROUTE_DIRECT, &model); + upstream + } + Err(unrouted) => { + out.set(|o| &o.refusal, UNROUTED); + out.set(|o| &o.refusal_status, u32::from(unrouted.status())); + return out.fail(Refusal::refused(unrouted.text())); + } + }; // The caller's head crosses here once: keep what the far end receives of it. let caller = driven::relayed_fields(input.fields().iter().map(|f| { ( @@ -491,7 +509,11 @@ slot!( ) })); let unit = input.get().unit; - plane.units.with_all(|units| unit_of(units, unit).caller = caller); + plane.units.with_all(|units| { + let kept = unit_of(units, unit); + kept.caller = caller; + kept.upstream = upstream; + }); } Outcome::Ready } @@ -588,6 +610,7 @@ fn attempt_piece( *unit = Unit { attempt: true, caller: std::mem::take(&mut unit.caller), + upstream: unit.upstream.take(), ..Unit::default() }; out.set(|o| &o.verb, verb); @@ -685,7 +708,9 @@ slot!( FROM_CALLER => match driven::caller_piece(bytes, given.flags & PIECE_LAST != 0) { CallerAnswer::Empty => (Outcome::Refused, true), CallerAnswer::Keep if unit.attempt => { - owe(unit, bytes, EMIT_TO_FAR_END, input, &mut out); + let spliced = + unit.upstream.as_deref().and_then(|up| driven::spliced(bytes, up)); + owe(unit, spliced.as_deref().unwrap_or(bytes), EMIT_TO_FAR_END, input, &mut out); (Outcome::Ready, false) } CallerAnswer::Keep => (Outcome::Ready, false), diff --git a/crates/busbar-plane-decisions/src/tests/codec.rs b/crates/busbar-plane-decisions/src/tests/codec.rs index e56cd634af..2ea0874023 100644 --- a/crates/busbar-plane-decisions/src/tests/codec.rs +++ b/crates/busbar-plane-decisions/src/tests/codec.rs @@ -22,9 +22,14 @@ fn reads_the_declared_pointers_off_a_failure_body() { assert_eq!(read_u64(FAILURE, PTR_USAGE_UNITS), None); } +/// A request is read at its top-level `model` alone (DECISIONS D8b), never at `state`. #[test] -fn request_ptrs_is_empty() { - assert!(REQUEST_PTRS.is_empty()); +fn request_ptrs_is_the_model_alone() { + assert_eq!(REQUEST_PTRS, &[PTR_MODEL]); + assert_eq!( + read_str(br#"{"model":"jev","state":{}}"#, PTR_MODEL), + Some("jev") + ); } #[test] diff --git a/crates/busbar-plane-decisions/src/tests/driven.rs b/crates/busbar-plane-decisions/src/tests/driven.rs index 4f5e6d64ac..d22a609ff0 100644 --- a/crates/busbar-plane-decisions/src/tests/driven.rs +++ b/crates/busbar-plane-decisions/src/tests/driven.rs @@ -161,12 +161,93 @@ fn a_refusal_carries_the_kernel_text_in_the_dialect_shape() { ); } +/// RED (audit plane-decisions#1): every generation with a model mounts the claim (THE DESIGN +/// section 2: "more than one and none named is 400", so several models are served); none mounts +/// nothing, and the kernel's unclaimed-route bytes stand. #[test] -fn only_exactly_one_model_mounts_the_claim() { +fn one_or_more_models_mount_the_claim() { assert!(served_claims(0).is_empty()); assert_eq!(served_claims(1), tail::CLAIMS); - assert!(served_claims(2).is_empty()); - assert!(served_claims(7).is_empty()); + assert_eq!(served_claims(2), tail::CLAIMS); + assert_eq!(served_claims(7), tail::CLAIMS); +} + +fn models(names: &[&str]) -> std::collections::BTreeMap { + names.iter().map(|n| ((*n).to_string(), ())).collect() +} + +/// MODEL RESOLUTION (THE DESIGN section 2): the named model; one configured and none named is the +/// default; several and none named, or a `model` that is not a string, is 400; an unknown name 404. +#[test] +fn a_request_routes_over_the_model_it_names() { + let two = models(&["a", "b"]); + assert_eq!(route_model(br#"{"model":"b"}"#, &two).map(|r| r.0), Ok("b")); + assert_eq!( + route_model(br#"{"model":"\u0061"}"#, &two).map(|r| r.0), + Ok("a"), + "an escaped name is the name it spells" + ); + assert_eq!( + route_model(br#"{"state":{}}"#, &two), + Err(Unrouted::Unnamed) + ); + assert_eq!( + route_model(br#"{"model":"zz"}"#, &two), + Err(Unrouted::Unknown("zz".to_string())) + ); + assert_eq!( + route_model(br#"{"model":null}"#, &two), + Err(Unrouted::NotAName) + ); + assert_eq!( + route_model(br#"{"model":["a"]}"#, &two), + Err(Unrouted::NotAName) + ); + // A nested `model` is not the request's. + assert_eq!( + route_model(br#"{"state":{"model":"a"}}"#, &two), + Err(Unrouted::Unnamed) + ); + + let one = models(&["jev"]); + assert_eq!( + route_model(br#"{"state":{}}"#, &one).map(|r| r.0), + Ok("jev") + ); + assert_eq!(route_model(b"not json", &one).map(|r| r.0), Ok("jev")); + assert_eq!( + route_model(br#"{"model":"other"}"#, &one), + Err(Unrouted::Unknown("other".to_string())) + ); + assert_eq!(route_model(br#"{}"#, &models(&[])), Err(Unrouted::Unnamed)); + + assert_eq!( + (Unrouted::Unnamed.status(), Unrouted::NotAName.status()), + (400, 400) + ); + assert_eq!(Unrouted::Unknown("zz".to_string()).status(), 404); +} + +/// THE `upstream_model` SPLICE (THE DESIGN section 2): the top-level value alone, when it differs. +#[test] +fn the_splice_rewrites_the_top_level_model_value_alone() { + let body = br#"{"model" : "jev", "state":{"model":"jev"}}"#; + assert_eq!( + spliced(body, "jev-1.13.0").as_deref(), + Some(&br#"{"model" : "jev-1.13.0", "state":{"model":"jev"}}"#[..]) + ); + assert_eq!(spliced(body, "jev"), None, "the same name passes through"); + assert_eq!( + spliced(br#"{"state":{}}"#, "x"), + None, + "no model to rewrite" + ); + assert_eq!(spliced(br#"{"model":3}"#, "x"), None, "not a name"); + assert_eq!( + spliced(br#"{"model":"a"}"#, "q\"x").as_deref(), + Some(&br#"{"model":"q\"x"}"#[..]), + "the value is written as a JSON string" + ); } /// BILLABLE SUCCESS ONLY: the request fee is counted once for a far end's 2xx answer with no diff --git a/crates/busbar-plane-decisions/src/tests/plane_door.rs b/crates/busbar-plane-decisions/src/tests/plane_door.rs index 4fdabafd06..6765e863ce 100644 --- a/crates/busbar-plane-decisions/src/tests/plane_door.rs +++ b/crates/busbar-plane-decisions/src/tests/plane_door.rs @@ -30,7 +30,7 @@ fn a_member_the_section_does_not_declare_is_refused_in_the_grammars_words() { } #[test] -fn exactly_one_model_claims_systemone_and_nothing_else_claims_anything() { +fn one_or_more_models_claim_systemone_and_none_claims_nothing() { let one = snapshot_spec(1); assert_eq!( one.claims, @@ -44,8 +44,12 @@ fn exactly_one_model_claims_systemone_and_nothing_else_claims_anything() { assert!(one.admin_routes.is_empty()); // Served on the plain data plane to a keyed caller: no audience, no protected-resource document. assert!(one.audience.is_none() && one.resource_metadata.is_none()); - for models in [0, 2, 3] { - assert!(snapshot_spec(models).claims.is_empty(), "{models} models"); + assert!( + snapshot_spec(0).claims.is_empty(), + "no model claims nothing" + ); + for models in [2, 3] { + assert_eq!(snapshot_spec(models).claims, one.claims, "{models} models"); } } diff --git a/crates/busbar-plane-decisions/tests/byte_fixtures.rs b/crates/busbar-plane-decisions/tests/byte_fixtures.rs index 03f2bd3e9f..027aa97610 100644 --- a/crates/busbar-plane-decisions/tests/byte_fixtures.rs +++ b/crates/busbar-plane-decisions/tests/byte_fixtures.rs @@ -31,8 +31,9 @@ const SYSTEMONE_422: &[u8] = br#"{"error":{"code":"invalid_state","message":"bad const MODELS_LIST: &[u8] = br#"{"request_id":"req_2","data":[{"id":"jev-1.13.0","object":"model"}]}"#; -/// The request body a `systemone` call carries — this plane never reads a pointer off it (see -/// `codec::REQUEST_PTRS`), so its shape only matters for byte-identity forwarding. +/// The request body a `systemone` call carries — this plane reads it at its top-level `model` alone +/// (see `codec::REQUEST_PTRS`), and names none here, so its shape only matters for byte-identity +/// forwarding. const SYSTEMONE_REQUEST: &[u8] = br#"{"state":{"session":"caller-secret-state-xyz"},"context":{}}"#; #[test] diff --git a/crates/busbar-plane-decisions/tests/conformance.json b/crates/busbar-plane-decisions/tests/conformance.json index 3d4dedf437..bf72d3c51a 100644 --- a/crates/busbar-plane-decisions/tests/conformance.json +++ b/crates/busbar-plane-decisions/tests/conformance.json @@ -6,7 +6,7 @@ "models": { "a": { "provider": "typesafe" }, "b": { "provider": "typesafe" } } }, "open_claims": [["POST", "/v1/systemone"]], - "refresh_claims": [], + "refresh_claims": [["POST", "/v1/systemone"]], "member": "jev", "claimed": { "unit": 7, "method": "POST", "target": "/v1/systemone" }, "unclaimed": { "unit": 8, "method": "GET", "target": "/v1/models", "status": 404 }, diff --git a/crates/busbar-plane-decisions/tests/door.rs b/crates/busbar-plane-decisions/tests/door.rs new file mode 100644 index 0000000000..255a4aabd5 --- /dev/null +++ b/crates/busbar-plane-decisions/tests/door.rs @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! THE DOOR'S ANSWERS, DRIVEN OVER ITS ABI as the kernel's plane driver drives them. +//! +//! * MODEL RESOLUTION (THE DESIGN section 2, the decisions bullet; DECISIONS D8b): "a request routes +//! by its model; one configured model and none named is the default (byte-identical); more than +//! one and none named is 400; an unknown model is 404." The claim is published whenever a model is +//! configured. +//! * `upstream_model` (the same bullet): "rewrites only the top-level model value, by span splice, +//! when it is set and differs; otherwise the body passes through." + +#![allow(unsafe_code)] + +mod door_harness; + +use busbar_contract::abi::mechanism::call::Outcome; +use busbar_contract::abi::plane::ROUTE_DIRECT; +use door_harness::{open, Bufs, Given}; + +const SYSTEMONE: (&str, &str) = ("POST", "/v1/systemone"); + +const TWO: &str = r#"{"models":{"a":{"provider":"typesafe"},"b":{"provider":"typesafe"}}}"#; +const ONE: &str = r#"{"models":{"jev":{"provider":"typesafe"}}}"#; + +fn claims(c: &[(&str, &str)]) -> Vec<(String, String)> { + c.iter() + .map(|(v, t)| ((*v).to_string(), (*t).to_string())) + .collect() +} + +/// RED (audit plane-decisions#1): several configured models publish the claim; none publishes none. +#[test] +fn every_generation_with_a_model_claims_systemone() { + assert_eq!(open(TWO).claims, claims(&[SYSTEMONE]), "two models"); + assert_eq!(open(ONE).claims, claims(&[SYSTEMONE]), "one model"); + let none = open(r#"{"models":{}}"#); + assert!(none.claims.is_empty(), "no model claims nothing"); + // A refresh onto several models keeps the claim, and onto none drops it. + assert_eq!(none.refresh(TWO, 2), claims(&[SYSTEMONE])); + assert!(none.refresh("{}", 3).is_empty()); +} + +/// RED (audit plane-decisions#1): a request routes over the model it names; several configured and +/// none named is 400; a name no model has is 404; one configured and none named is the default. +#[test] +fn a_request_routes_by_its_model() { + let two = open(TWO); + let b = two.arrive(1, br#"{"model":"b","state":{}}"#); + assert_eq!( + (b.outcome, b.route, b.entry.as_str()), + (Outcome::Ready, ROUTE_DIRECT, "b"), + "{b:?}" + ); + let a = two.arrive(2, br#"{"state":{},"model":"a"}"#); + assert_eq!( + (a.outcome, a.entry.as_str()), + (Outcome::Ready, "a"), + "{a:?}" + ); + + let unnamed = two.arrive(3, br#"{"state":{}}"#); + assert_eq!( + (unnamed.outcome, unnamed.status), + (Outcome::Refused, 400), + "{unnamed:?}" + ); + assert!( + unnamed.refusal != 0 && !unnamed.text.is_empty(), + "{unnamed:?}" + ); + + let unknown = two.arrive(4, br#"{"model":"zz","state":{}}"#); + assert_eq!( + (unknown.outcome, unknown.status), + (Outcome::Refused, 404), + "{unknown:?}" + ); + assert!(unknown.text.contains("'zz'"), "{unknown:?}"); + + let not_a_name = two.arrive(5, br#"{"model":7}"#); + assert_eq!( + (not_a_name.outcome, not_a_name.status), + (Outcome::Refused, 400), + "{not_a_name:?}" + ); + + let one = open(ONE); + let default = one.arrive(6, br#"{"state":{}}"#); + assert_eq!( + (default.outcome, default.route, default.entry.as_str()), + (Outcome::Ready, ROUTE_DIRECT, "jev"), + "{default:?}" + ); + let named = one.arrive(7, br#"{"model":"jev"}"#); + assert_eq!( + (named.outcome, named.entry.as_str()), + (Outcome::Ready, "jev") + ); + let elsewhere = one.arrive(8, br#"{"model":"other"}"#); + assert_eq!( + (elsewhere.outcome, elsewhere.status), + (Outcome::Refused, 404), + "{elsewhere:?}" + ); +} + +/// A refused arrival is answered in jev's error shape, the arrival's words and status. +#[test] +fn an_unrouted_arrival_renders_in_the_dialect_shape() { + let two = open(TWO); + let unknown = two.arrive(1, br#"{"model":"zz"}"#); + let (outcome, body) = two.refusal(1, unknown.status, &unknown.text, true); + assert_eq!(outcome, Outcome::Ready); + let v: serde_json::Value = serde_json::from_slice(&body).expect("JSON"); + assert_eq!(v["error"]["message"], unknown.text.as_str(), "{v}"); + assert!(v["error"]["code"].is_string(), "{v}"); +} + +/// The caller's body as the far end receives it, for a unit routed over `settings` by `body`. +fn sent(settings: &str, body: &[u8]) -> String { + let door = open(settings); + let arrived = door.arrive(1, body); + assert_eq!(arrived.outcome, Outcome::Ready, "{arrived:?}"); + let mut bufs = Bufs::new(1 << 16); + let attempt = door.piece(1, Given::attempt(1), &mut bufs); + assert_eq!(attempt.request, "POST /v1/systemone"); + let caller = door.piece(1, Given::caller(body), &mut bufs); + assert!(caller.far, "the caller's body goes to the far end"); + String::from_utf8(caller.bytes).expect("UTF-8") +} + +/// RED (audit plane-decisions#2): a set `upstream_model` that differs replaces the top-level model +/// value alone; every other byte (member order, whitespace) is the caller's. +#[test] +fn upstream_model_is_spliced_into_the_top_level_model() { + let upstream = r#"{"models":{"jev":{"provider":"typesafe","upstream_model":"jev-1.13.0"}}}"#; + let body = "{ \"state\" : {\"model\":\"inner\"},\n \"model\" : \"jev\" , \"n\":[1, 2] }"; + assert_eq!( + sent(upstream, body.as_bytes()), + "{ \"state\" : {\"model\":\"inner\"},\n \"model\" : \"jev-1.13.0\" , \"n\":[1, 2] }" + ); + // The same name, or none set, or a body that names no model: the body passes through. + let same = r#"{"models":{"jev":{"provider":"typesafe","upstream_model":"jev"}}}"#; + assert_eq!(sent(same, body.as_bytes()), body); + assert_eq!(sent(ONE, body.as_bytes()), body); + let unnamed = "{\"state\":{}}"; + assert_eq!(sent(upstream, unnamed.as_bytes()), unnamed); +} diff --git a/crates/busbar-plane-decisions/tests/door_harness/mod.rs b/crates/busbar-plane-decisions/tests/door_harness/mod.rs new file mode 100644 index 0000000000..a9850efa72 --- /dev/null +++ b/crates/busbar-plane-decisions/tests/door_harness/mod.rs @@ -0,0 +1,477 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! THE DOOR, DRIVEN OVER ITS OWN ABI as the kernel's plane driver drives it: `open`, `refresh`, +//! `arrive`, `on_piece`, `refusal` and `cancel`, each a frame of plain C data crossing the door's +//! table. The decisions door calls no host service, so the host tables it is opened with are empty. + +#![allow(dead_code)] + +use std::os::raw::c_void; + +use busbar_contract::abi::mechanism::call::{ + AbiStr, Blob, Field, InHead, OutHead, Outcome, Span, BLOB_OCTETS, +}; +use busbar_contract::abi::mechanism::lifecycle::{ + slot as life, CancelIn, CancelOut, OpenIn, OpenOut, OpsHead, RefreshIn, +}; +use busbar_contract::abi::mechanism::ticket::{HostCtx, HostTables, Ticket}; +use busbar_contract::abi::plane::{ + slot, ArriveIn, ArriveOut, OnPieceIn, OnPieceOut, Ops, OutField, PlaneCancelIn, PlaneCancelOut, + PlaneOpenIn, PlaneOpenOut, PlaneRefreshOut, PlaneSnapshot, RecordWrite, RefusalIn, RefusalOut, + UnitCount, EMIT_DONE, EMIT_TO_FAR_END, FROM_CALLER, FROM_FAR_END, FROM_KERNEL, + PIECE_HAS_STATUS, PIECE_LAST, REFUSAL_ARRIVE, REFUSAL_KERNEL, +}; +use busbar_plane_decisions::plane_door::door; + +pub fn z() -> T { + // SAFETY: every `in`/`out` here is plain C data; all-zero is a valid value of each. + unsafe { std::mem::zeroed() } +} + +fn str_of(s: &[u8]) -> AbiStr { + AbiStr { + ptr: s.as_ptr(), + len: s.len(), + } +} + +fn octets(b: &[u8]) -> Blob { + Blob { + ptr: b.as_ptr(), + len: b.len(), + fmt: BLOB_OCTETS, + flags: 0, + } +} + +fn read(s: AbiStr) -> String { + if s.ptr.is_null() || s.len == 0 { + return String::new(); + } + // SAFETY: the door's memory, valid until the instance's next call. + String::from_utf8_lossy(unsafe { std::slice::from_raw_parts(s.ptr, s.len) }).into_owned() +} + +fn head(op: u32, ticket: Ticket) -> InHead { + InHead { + size: std::mem::size_of::() as u32, + op, + ticket, + ..z() + } +} + +fn out_head() -> OutHead { + OutHead { + size: std::mem::size_of::() as u32, + ..z() + } +} + +fn ops() -> &'static Ops { + // SAFETY: the door is a `'static` table whose ops are the plane kind's. + unsafe { &*(*door()).ops.cast::() } +} + +fn lifecycle() -> &'static OpsHead { + &ops().head +} + +struct Tables(HostTables); +// SAFETY: the tables name no function and no memory. +unsafe impl Sync for Tables {} + +static TABLES: Tables = Tables(HostTables { + size: std::mem::size_of::() as u32, + _reserved: 0, + ctx: HostCtx { + ptr: std::ptr::null_mut(), + }, + wake: None, + conns: std::ptr::null(), + io: std::ptr::null(), + services: std::ptr::null(), +}); + +/// The claims a snapshot publishes, `(verb, target)`. +fn claims_of(snapshot: *const PlaneSnapshot) -> Vec<(String, String)> { + if snapshot.is_null() { + return Vec::new(); + } + // SAFETY: the door's snapshot, valid until its generation retires. + let s = unsafe { &*snapshot }; + if s.claims.is_null() { + return Vec::new(); + } + // SAFETY: as above, `claims_len` claims. + unsafe { std::slice::from_raw_parts(s.claims, s.claims_len) } + .iter() + .map(|c| (read(c.verb), read(c.target))) + .collect() +} + +/// The ticket a unit's pieces are pushed on: one per unit. +pub fn ticket_of(unit: u64) -> Ticket { + Ticket { + slot: u32::try_from(unit).expect("a small unit"), + generation: 1, + } +} + +/// One opened instance of the door. +pub struct Door { + instance: *mut c_void, + /// The first generation's claims. + pub claims: Vec<(String, String)>, +} + +/// Open the door over `settings` (the `decisions:` section, as written). +pub fn open(settings: &str) -> Door { + let mut err = [0_u8; 1024]; + let mut i: PlaneOpenIn = z(); + i.open = OpenIn { + head: head::(life::OPEN, Ticket::NONE), + host: &TABLES.0, + settings: octets(settings.as_bytes()), + generation: 1, + err_buf: err.as_mut_ptr(), + err_cap: err.len(), + ..z() + }; + let mut o: PlaneOpenOut = z(); + o.open = OpenOut { + head: out_head::(), + ..z() + }; + let f = lifecycle().open.expect("the door opens"); + let ret = f( + std::ptr::null_mut(), + std::ptr::from_ref(&i).cast(), + std::ptr::from_mut(&mut o).cast(), + ); + assert_eq!( + ret.outcome(), + Outcome::Ready, + "the door opens over {settings}" + ); + Door { + instance: o.open.instance, + claims: claims_of(o.snapshot), + } +} + +/// What `arrive` answered. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Arrived { + pub outcome: Outcome, + /// The plane's own refusal code, `0` unless refused. + pub refusal: u32, + /// The status a refusal wears. + pub status: u32, + /// The refusal's words (`head.error`). + pub text: String, + /// The route class and the entry it names. + pub route: u8, + pub entry: String, +} + +/// What one `on_piece` answered, re-called while it said `more`. +#[derive(Debug, Clone, Default)] +pub struct Answered { + pub outcome: Option, + /// The bytes emitted, over every re-call. + pub bytes: Vec, + /// The bytes were bound for the far end. + pub far: bool, + /// The answer said done. + pub done: bool, + /// The reply status. + pub status: u32, + /// The request line of an ATTEMPT, `" "`. + pub request: String, + /// The head fields written, `(name, value)`. + pub fields: Vec<(String, String)>, + /// The units written, `(class, source, amount)`. + pub units: Vec<(u32, u32, u64)>, + /// Short-answer needs, when the answer was short. + pub short: bool, +} + +/// One piece as the kernel pushes it. +#[derive(Debug, Clone, Copy)] +pub struct Given<'a> { + pub from: u32, + pub flags: u32, + pub status: u32, + pub attempt: u32, + pub bytes: &'a [u8], +} + +impl<'a> Given<'a> { + /// The ATTEMPT piece of attempt `n`. + pub const fn attempt(n: u32) -> Self { + Self { + from: FROM_KERNEL, + flags: 0, + status: 0, + attempt: n, + bytes: b"", + } + } + + /// The caller's whole body. + pub const fn caller(bytes: &'a [u8]) -> Self { + Self { + from: FROM_CALLER, + flags: PIECE_LAST, + status: 0, + attempt: 0, + bytes, + } + } + + /// The far end's whole answer, its status on it. + pub const fn whole(status: u32, bytes: &'a [u8]) -> Self { + Self { + from: FROM_FAR_END, + flags: PIECE_HAS_STATUS | PIECE_LAST, + status, + attempt: 0, + bytes, + } + } + + /// One piece of the far end's answer: the first carries `status`, the last says so. + pub const fn far(first: Option, last: bool, bytes: &'a [u8]) -> Self { + let mut flags = 0; + let mut status = 0; + if let Some(s) = first { + flags |= PIECE_HAS_STATUS; + status = s; + } + if last { + flags |= PIECE_LAST; + } + Self { + from: FROM_FAR_END, + flags, + status, + attempt: 0, + bytes, + } + } +} + +/// The host buffers one `on_piece` is lent, reused across calls so a measured call allocates only +/// what the door does. +pub struct Bufs { + reply: Vec, + fields: Vec, + arena: Vec, + records: Vec, + units: Vec, +} + +impl Bufs { + pub fn new(reply_cap: usize) -> Self { + Self { + reply: vec![0; reply_cap], + fields: vec![z(); 16], + arena: vec![0; 1 << 14], + records: vec![z(); 4], + units: vec![z(); 8], + } + } +} + +impl Door { + /// `refresh` onto `generation` over `settings`: the new generation's claims. + pub fn refresh(&self, settings: &str, generation: u64) -> Vec<(String, String)> { + let mut i: RefreshIn = z(); + i.head = head::(life::REFRESH, Ticket::NONE); + i.generation = generation; + i.settings = octets(settings.as_bytes()); + let mut o: PlaneRefreshOut = z(); + o.head = out_head::(); + let f = lifecycle().refresh.expect("refresh"); + let ret = f( + self.instance, + std::ptr::from_ref(&i).cast(), + std::ptr::from_mut(&mut o).cast(), + ); + assert_eq!(ret.outcome(), Outcome::Ready, "the door refreshes"); + claims_of(o.snapshot) + } + + /// `arrive` of `unit` on `POST /v1/systemone` with `body`. + pub fn arrive(&self, unit: u64, body: &[u8]) -> Arrived { + self.arrive_on(unit, "POST", "/v1/systemone", body, &[]) + } + + /// `arrive` of `unit` on `verb target` with `body` and the caller's `fields`. + pub fn arrive_on( + &self, + unit: u64, + verb: &str, + target: &str, + body: &[u8], + fields: &[(&str, &str)], + ) -> Arrived { + let fields: Vec = fields + .iter() + .map(|(n, v)| Field { + name: str_of(n.as_bytes()), + value: str_of(v.as_bytes()), + }) + .collect(); + let mut i: ArriveIn = z(); + i.head = head::(slot::ARRIVE, Ticket::NONE); + i.unit = unit; + i.target = str_of(target.as_bytes()); + i.method = str_of(verb.as_bytes()); + i.body = octets(body); + (i.fields, i.fields_len) = (fields.as_ptr(), fields.len()); + let mut units = [z::(); 4]; + (i.units_buf, i.units_cap) = (units.as_mut_ptr(), units.len()); + let mut o: ArriveOut = z(); + o.head = out_head::(); + let f = ops().arrive.expect("arrive"); + let ret = f( + self.instance, + std::ptr::from_ref(&i).cast(), + std::ptr::from_mut(&mut o).cast(), + ); + Arrived { + outcome: ret.outcome(), + refusal: o.refusal, + status: o.refusal_status, + text: read(o.head.error), + route: o.route, + entry: read(o.pool), + } + } + + /// One piece of `unit` on its ticket, re-called while the door says `more`. + pub fn piece(&self, unit: u64, given: Given<'_>, bufs: &mut Bufs) -> Answered { + let ticket = ticket_of(unit); + let mut got = Answered::default(); + let mut first = true; + for _ in 0..1024 { + let mut i: OnPieceIn = z(); + i.head = head::(slot::ON_PIECE, ticket); + i.unit = unit; + i.from = given.from; + if first { + i.flags = given.flags; + i.bytes = octets(given.bytes); + i.status_code = given.status; + i.attempt_no = given.attempt; + } else { + i.bytes = octets(b""); + } + (i.reply_buf, i.reply_cap) = (bufs.reply.as_mut_ptr(), bufs.reply.len()); + (i.fields_buf, i.fields_cap) = (bufs.fields.as_mut_ptr(), bufs.fields.len()); + (i.arena_buf, i.arena_cap) = (bufs.arena.as_mut_ptr(), bufs.arena.len()); + (i.records_buf, i.records_cap) = (bufs.records.as_mut_ptr(), bufs.records.len()); + (i.units_buf, i.units_cap) = (bufs.units.as_mut_ptr(), bufs.units.len()); + let mut o: OnPieceOut = z(); + o.head = out_head::(); + let f = ops().on_piece.expect("on_piece"); + let ret = f( + self.instance, + std::ptr::from_ref(&i).cast(), + std::ptr::from_mut(&mut o).cast(), + ); + first = false; + got.outcome = Some(ret.outcome()); + got.short |= o.units_needed != 0 || o.fields_needed != 0 || o.arena_needed != 0; + if o.reply_status != 0 { + got.status = o.reply_status; + } + let arena = &bufs.arena; + let at = |s: Span| { + let from = s.offset as usize; + String::from_utf8_lossy(&arena[from..from + s.len as usize]).into_owned() + }; + if ret.outcome() == Outcome::Ready { + got.fields.extend( + bufs.fields[..o.fields_written as usize] + .iter() + .map(|f| (at(f.name), at(f.value))), + ); + got.units.extend( + bufs.units[..o.units_written as usize] + .iter() + .map(|u| (u.class, u.source, u.amount)), + ); + if o.verb.len != 0 { + got.request = format!("{} {}", at(o.verb), at(o.target)); + } + } + got.far |= o.flags & EMIT_TO_FAR_END != 0; + got.done |= o.flags & EMIT_DONE != 0; + got.bytes + .extend_from_slice(&bufs.reply[..usize::try_from(o.emitted).unwrap()]); + if ret.outcome() != Outcome::Ready || o.more == 0 { + break; + } + } + got + } + + /// `refusal` of `unit` (`0`: none arrived) at `status` with the kernel's `text`; `arrived` + /// says the refusal is the plane's own arrival's. Answers the outcome and the rendered body. + pub fn refusal(&self, unit: u64, status: u32, text: &str, arrived: bool) -> (Outcome, Vec) { + let (mut reply, mut arena) = (vec![0_u8; 4096], vec![0_u8; 1024]); + let mut fields = [z::(); 4]; + let mut i: RefusalIn = z(); + i.head = head::(slot::REFUSAL, Ticket::NONE); + i.cause = if arrived { + REFUSAL_ARRIVE + } else { + REFUSAL_KERNEL + }; + i.status = status; + i.text = str_of(text.as_bytes()); + i.unit = unit; + (i.reply_buf, i.reply_cap) = (reply.as_mut_ptr(), reply.len()); + (i.fields_buf, i.fields_cap) = (fields.as_mut_ptr(), fields.len()); + (i.arena_buf, i.arena_cap) = (arena.as_mut_ptr(), arena.len()); + let mut o: RefusalOut = z(); + o.head = out_head::(); + let f = ops().refusal.expect("refusal"); + let ret = f( + self.instance, + std::ptr::from_ref(&i).cast(), + std::ptr::from_mut(&mut o).cast(), + ); + let n = usize::try_from(o.reply_written).unwrap().min(reply.len()); + (ret.outcome(), reply[..n].to_vec()) + } + + /// `cancel` of the op on `ticket`: the outcome and the disposition. + pub fn cancel(&self, ticket: Ticket) -> (Outcome, u32) { + let mut records = [z::(); 4]; + let mut arena = [0_u8; 256]; + let mut i: PlaneCancelIn = z(); + i.cancel = CancelIn { + head: head::(life::CANCEL, ticket), + ticket, + }; + (i.records_buf, i.records_cap) = (records.as_mut_ptr(), records.len()); + (i.arena_buf, i.arena_cap) = (arena.as_mut_ptr(), arena.len()); + let mut o: PlaneCancelOut = z(); + o.cancel = CancelOut { + head: out_head::(), + ..z() + }; + let f = lifecycle().cancel.expect("cancel"); + let ret = f( + self.instance, + std::ptr::from_ref(&i).cast(), + std::ptr::from_mut(&mut o).cast(), + ); + (ret.outcome(), o.cancel.disposition) + } +} diff --git a/crates/busbar-plane-decisions/tests/jev.rs b/crates/busbar-plane-decisions/tests/jev.rs index 579015acce..035915f459 100644 --- a/crates/busbar-plane-decisions/tests/jev.rs +++ b/crates/busbar-plane-decisions/tests/jev.rs @@ -225,7 +225,10 @@ fn pii_witness_never_surfaces_state_or_answers_in_any_fact() { // behind every assertion above. assert!(!busbar_plane_decisions::codec::RESPONSE_PTRS.contains(&"/state")); assert!(!busbar_plane_decisions::codec::RESPONSE_PTRS.contains(&"/answers")); - assert!(busbar_plane_decisions::codec::REQUEST_PTRS.is_empty()); + assert_eq!( + busbar_plane_decisions::codec::REQUEST_PTRS, + &[busbar_plane_decisions::codec::PTR_MODEL] + ); assert!(!facts::CONTENT_FACTS .iter() .any(|k| *k == "state" || *k == "answers")); diff --git a/xtask/src/conformance_record/jev.rs b/xtask/src/conformance_record/jev.rs index b265b9fc52..1b96585367 100644 --- a/xtask/src/conformance_record/jev.rs +++ b/xtask/src/conformance_record/jev.rs @@ -6,8 +6,8 @@ //! * BATTERY — `cargo test -p busbar-plane-decisions`: the codec, the fixtures, the PII witness, //! purity and invariance, and the both-ways door. It says the plane reads and writes jev. It //! cannot say busbar SERVES jev, and a battery that selected zero tests says nothing at all. -//! * SERVED — a busbar built from this checkout, booted with exactly one `decisions.models` entry -//! (the one shape that mounts `POST /v1/systemone`, `busbar-plane-decisions/src/driven.rs`), +//! * SERVED — a busbar built from this checkout, booted with one `decisions.models` entry (so a +//! request naming no model routes over it, the default, `busbar-plane-decisions/src/driven.rs`), //! whose provider is a far end this rig owns ([`super::upstream::FarEnd`]). The leg judges the //! SERVED BYTES from both ends of the relay: //! @@ -48,6 +48,16 @@ //! (`decisions.rate_card..units.decision: 1`, in the rig's generated config only), so the //! lane's `priced_micros` IS the unit count: 42. //! +//! THE MODEL-RESOLUTION CHECKS (THE DESIGN §2, the decisions bullet; DECISIONS D8b) run on a second +//! boot whose `decisions.models` holds two models, [`MODEL`] and [`UPSTREAM_MODEL`] (its +//! `upstream_model` set to [`UPSTREAM_NAME`]), on the same far end: +//! +//! | check | what a correct busbar does | +//! |---|---| +//! | `models.unnamed-400` | two models and none named: `400` in jev's shape, no far-end hop | +//! | `models.unknown-404` | a model no entry has: `404` in jev's shape, no far-end hop | +//! | `models.upstream-model` | the second model named: served, and the far end receives the caller's bytes with the top-level `model` value alone spliced to its `upstream_model` | +//! //! The rules, in order: battery red or empty ⇒ `fail`; no subject binary ⇒ `not-run` (nothing was //! booted); a subject that does not boot ⇒ `fail`; `POST /v1/systemone` answering the absence code //! measured on the same boot ⇒ `fail` "not served"; any served check red ⇒ `fail` naming it; every @@ -67,6 +77,9 @@ use super::Outcome; const MODEL: &str = "jev-1"; const PROVIDER: &str = "typesafe"; const PATH: &str = "/v1/systemone"; +/// The second boot's second model, and the `upstream_model` it names. +pub const UPSTREAM_MODEL: &str = "jev-2"; +pub const UPSTREAM_NAME: &str = "jev-upstream-2"; /// The header outside the deny-set the caller sends and the far end must receive unchanged. const PROBE_HEADER: &str = "x-jev-conformance-probe"; @@ -559,6 +572,76 @@ pub fn jev_subject_config(data: u16, admin: u16, key_file: &Path) -> String { ) } +/// The second boot's config: [`MODEL`] and [`UPSTREAM_MODEL`] (its `upstream_model` +/// [`UPSTREAM_NAME`]) on the one provider, each priced on its own card entry, the admin token, and +/// the far end's loopback address declared as an allowed destination. +pub fn jev_models_config(data: u16, admin: u16, key_file: &Path) -> String { + format!( + "listen: \"127.0.0.1:{data}\"\n\ + admin_listen: \"127.0.0.1:{admin}\"\n\ + store: {{ module: memory }}\n\ + providers:\n {PROVIDER}:\n api_key: {{ env: JEV_CONFORMANCE_PROVIDER_KEY }}\n\ + models: {{}}\n\ + identity-providers:\n admin-tokens: {{ module: admin-tokens, token: {{ env: BUSBAR_ADMIN_TOKEN }} }}\n\ + auth:\n chain: [keys]\n admin_auth: [admin-tokens]\n signing_key: {{ file: {} }}\n\ + advanced:\n allow_destinations: [\"127.0.0.1\"]\n\ + decisions:\n models:\n {MODEL}:\n provider: {PROVIDER}\n\ + \x20 {UPSTREAM_MODEL}:\n provider: {PROVIDER}\n upstream_model: {UPSTREAM_NAME}\n\ + \x20 rate_card:\n {MODEL}: {{ units: {{ decision: 1 }} }}\n\ + \x20 {UPSTREAM_MODEL}: {{ units: {{ decision: 1 }} }}\n", + key_file.display() + ) +} + +/// A request naming `model` (none: [`REQUEST`]'s own bytes), the odd spacing kept. +pub fn models_request(model: Option<&str>) -> Vec { + match model { + None => REQUEST.to_vec(), + Some(m) => format!( + "{{ \"model\" : \"{m}\" , \"state\" : {{\"session\":\"conformance-state\"}},\n \"context\":{{}} }}" + ) + .into_bytes(), + } +} + +/// `models.unnamed-400` and `models.unknown-404`: the `status` refusal in jev's shape (`code`), and +/// no request at the far end. +pub fn judge_unrouted(call: &Call, status: u16, code: &str) -> Result<(), String> { + if call.status != status { + return Err(format!( + "answered {} `{}`, not the {status} refusal", + call.status, + show(&call.body) + )); + } + if call.hops != 0 { + return Err(format!( + "the unrouted request reached the far end ({} hop(s))", + call.hops + )); + } + is_jev_refusal(&call.body, code) +} + +/// `models.upstream-model`: the request naming [`UPSTREAM_MODEL`] is served (the far end's 200 bytes +/// over one dial), and the far end received `sent` with the top-level `model` value alone spliced +/// to [`UPSTREAM_NAME`]. +pub fn judge_upstream_model(call: &Call, sent: &[u8], received: &[u8]) -> Result<(), String> { + served_once(call)?; + let want = String::from_utf8_lossy(sent).replacen( + &format!("\"{UPSTREAM_MODEL}\""), + &format!("\"{UPSTREAM_NAME}\""), + 1, + ); + check(received == want.as_bytes(), || { + format!( + "the far end received `{}`, not the caller's bytes with `model` spliced to \ + `{UPSTREAM_NAME}`: `{want}`", + show(received) + ) + }) +} + fn check(ok: bool, finding: impl FnOnce() -> String) -> Result<(), String> { if ok { Ok(()) @@ -601,6 +684,9 @@ impl Runner { run.build = Ok(()); run.boot = Ok(()); self.served(&bin, &dir, &mut run); + if run.boot.is_ok() && !run.checks.is_empty() { + self.served_models(&bin, &dir, &mut run); + } } Err(e) => run.build = Err(e), } @@ -883,6 +969,108 @@ impl Runner { } } +impl Runner { + /// THE MODEL-RESOLUTION BOOT: two models on the rig's far end, and the three `models.*` checks. + fn served_models(&self, bin: &Path, dir: &Path, run: &mut JevRun) { + let dir = dir.join("models"); + let scratch = dir.join("http"); + let mut fail = |why: String| { + run.checks.push(("models.boot".to_string(), Err(why))); + }; + let far = match FarEnd::start() { + Ok(f) => f, + Err(e) => return fail(e), + }; + let (ports, admin_token, provider_key) = match ( + subject::free_ports(2), + subject::random_hex(24), + subject::random_hex(16), + ) { + (Ok(p), Ok(a), Ok(k)) => (p, a, k), + (p, a, k) => { + return fail( + [p.err(), a.err(), k.err()] + .into_iter() + .flatten() + .collect::>() + .join("; "), + ) + } + }; + let (data, admin) = (ports[0], ports[1]); + let key_file = match subject::signing_key(bin, &dir) { + Ok(k) => k, + Err(e) => return fail(e), + }; + let providers = format!( + "{PROVIDER}:\n protocol: jev\n base_url: http://127.0.0.1:{}\n error_map: {{}}\n", + far.port + ); + let booted = match subject::boot( + bin, + &dir, + &jev_models_config(data, admin, &key_file), + &providers, + &[ + ("BUSBAR_ADMIN_TOKEN", admin_token.clone()), + ("JEV_CONFORMANCE_PROVIDER_KEY", provider_key), + ], + &format!("http://127.0.0.1:{data}/stats"), + &[], + ) { + Ok(b) => b, + Err(e) => return fail(format!("the two-model subject did not boot: {e}")), + }; + let bearer = match subject::mint_key(&scratch, admin, &admin_token, "jev-models") { + Ok((key, _)) => format!("Bearer {key}"), + Err(e) => return fail(format!("{e}: {}", booted.log_tail())), + }; + let call = |model: Option<&str>| -> Result<(Call, Vec), String> { + let body = models_request(model); + let before = far.seen().len(); + let r = curl( + &scratch, + "POST", + &format!("http://127.0.0.1:{data}{PATH}"), + &[ + ("content-type", "application/json"), + ("authorization", bearer.as_str()), + ], + Some(body.as_slice()), + &[], + )?; + Ok(( + Call { + status: r.status, + body: r.body, + hops: far.seen().len() - before, + }, + body, + )) + }; + run.checks.push(( + "models.unnamed-400".to_string(), + call(None).and_then(|(c, _)| judge_unrouted(&c, 400, "invalid_request")), + )); + run.checks.push(( + "models.unknown-404".to_string(), + call(Some("jev-no-such-model")) + .and_then(|(c, _)| judge_unrouted(&c, 404, "invalid_params")), + )); + far.clear_replies(); + far.reply(200, SUCCESS); + run.checks.push(( + "models.upstream-model".to_string(), + call(Some(UPSTREAM_MODEL)).and_then(|(c, sent)| { + let seen = far.seen(); + let received = seen.last().map(|s| s.body.clone()).unwrap_or_default(); + judge_upstream_model(&c, &sent, &received) + }), + )); + drop(booted); + } +} + /// How long a read waits for the node's postings and seals to land before it is final. const SETTLE: Duration = Duration::from_millis(300); /// How long a read waits for a posting or a seal the rig is owed. diff --git a/xtask/src/conformance_record/mod.rs b/xtask/src/conformance_record/mod.rs index 98811e748d..a057c27518 100644 --- a/xtask/src/conformance_record/mod.rs +++ b/xtask/src/conformance_record/mod.rs @@ -48,10 +48,12 @@ use crate::gitp; pub use a2a::{discriminates, governance_observed, NEGATIVE_PAIRS}; pub use h2::{decide_h2, junit_cases, Case, CaseResult, H2Run}; pub use jev::{ - decide_jev, is_jev_refusal, jev_refusal_code, jev_subject_config, judge_admit, judge_audit, - judge_audit_window, judge_exit, judge_jev_ledger, judge_meter, judge_refused_before_dial, - judge_route, ledger_spend, tests_passed, Call, JevRun, Spend, AUDIT_OP_CLASS, ELSEWHERE, - H2_CHECKS, ONE_REQUEST_GROUP, REPORTED_UNITS, SUCCESS as JEV_SUCCESS, UNAVAILABLE, + decide_jev, is_jev_refusal, jev_models_config, jev_refusal_code, jev_subject_config, + judge_admit, judge_audit, judge_audit_window, judge_exit, judge_jev_ledger, judge_meter, + judge_refused_before_dial, judge_route, judge_unrouted, judge_upstream_model, ledger_spend, + models_request, tests_passed, Call, JevRun, Spend, AUDIT_OP_CLASS, ELSEWHERE, H2_CHECKS, + ONE_REQUEST_GROUP, REPORTED_UNITS, SUCCESS as JEV_SUCCESS, UNAVAILABLE, UPSTREAM_MODEL, + UPSTREAM_NAME, }; pub use oidf::{ as_signing_key, base64_std, decide_oidf, es256_jwk, idp_reply, module_results, diff --git a/xtask/tests/conformance_rigs.rs b/xtask/tests/conformance_rigs.rs index 25cff8d349..1acd1084db 100644 --- a/xtask/tests/conformance_rigs.rs +++ b/xtask/tests/conformance_rigs.rs @@ -221,6 +221,72 @@ fn at_the_node_width_the_jev_ledger_is_the_callers_bucket() { assert!(judge_jev_ledger(&bucketed(&[("vk_caller", "", "", 1, "41")]), "vk_caller").is_err()); } +/// THE MODEL-RESOLUTION BOOT (THE DESIGN §2, the decisions bullet): two models, the second with an +/// `upstream_model`, each priced. +#[test] +fn the_models_subject_configures_two_models_one_with_an_upstream_model() { + use xtask::conformance_record::{jev_models_config, UPSTREAM_MODEL, UPSTREAM_NAME}; + let text = jev_models_config(41000, 41001, std::path::Path::new("/tmp/signing.key")); + let doc: serde_yaml::Value = serde_yaml::from_str(&text).expect("the config is YAML"); + let models = &doc["decisions"]["models"]; + assert_eq!(models["jev-1"]["provider"].as_str(), Some("typesafe")); + assert!(models["jev-1"]["upstream_model"].is_null()); + assert_eq!( + models[UPSTREAM_MODEL]["upstream_model"].as_str(), + Some(UPSTREAM_NAME) + ); + for m in ["jev-1", UPSTREAM_MODEL] { + assert_eq!( + doc["decisions"]["rate_card"][m]["units"]["decision"].as_u64(), + Some(1), + "{m}" + ); + } + assert_eq!(doc["store"]["module"].as_str(), Some("memory")); +} + +fn call(status: u16, body: &[u8], hops: usize) -> xtask::conformance_record::Call { + xtask::conformance_record::Call { + status, + body: body.to_vec(), + hops, + } +} + +/// `models.unnamed-400` / `models.unknown-404`: the status in jev's shape, before any hop. +#[test] +fn an_unrouted_request_is_refused_in_jev_shape_before_any_hop() { + use xtask::conformance_record::judge_unrouted; + let bad = br#"{"error":{"code":"invalid_request","message":"name one"}}"#; + assert!(judge_unrouted(&call(400, bad, 0), 400, "invalid_request").is_ok()); + // RED ARMS: another status, a hop, another shape. + assert!(judge_unrouted(&call(404, bad, 0), 400, "invalid_request").is_err()); + assert!(judge_unrouted(&call(400, bad, 1), 400, "invalid_request").is_err()); + assert!(judge_unrouted(&call(400, b"{}", 0), 400, "invalid_request").is_err()); +} + +/// `models.upstream-model`: served, and the far end saw the top-level `model` value alone spliced. +#[test] +fn the_upstream_model_is_spliced_into_what_the_far_end_receives() { + use xtask::conformance_record::{ + judge_upstream_model, models_request, JEV_SUCCESS, UPSTREAM_MODEL, UPSTREAM_NAME, + }; + let sent = models_request(Some(UPSTREAM_MODEL)); + let spliced = String::from_utf8(sent.clone()) + .unwrap() + .replace(UPSTREAM_MODEL, UPSTREAM_NAME); + let ok = call(200, JEV_SUCCESS, 1); + assert!(judge_upstream_model(&ok, &sent, spliced.as_bytes()).is_ok()); + // RED ARMS: the caller's own name relayed, the far end not reached, another answer. + assert!(judge_upstream_model(&ok, &sent, &sent).is_err()); + assert!(judge_upstream_model(&call(200, JEV_SUCCESS, 0), &sent, spliced.as_bytes()).is_err()); + assert!(judge_upstream_model(&call(400, b"{}", 1), &sent, spliced.as_bytes()).is_err()); + // The unnamed request names no model. + assert!(!String::from_utf8(models_request(None)) + .unwrap() + .contains("\"model\"")); +} + // ── a2a ─────────────────────────────────────────────────────────────────────────────────────── fn report(rows: &[(&str, &str)]) -> String { From fd8bf00bf7fd86b7a154e78d032783c14508c8fe Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:00:06 -0700 Subject: [PATCH 09/59] decisions: a success bills one decision or its count, and its tokens in family decision (audit plane-decisions#4, #5) THE DESIGN section 7, owner money rulings 2026-09-28: "The decisions plane bills one `decision` per successful, settled answer from every provider; its card prices `decision`, `input_tokens` and `output_tokens` (family `decision`, all three required, 0 = free); the hosted provider keeps 1.5.5's `/usage/units` as the decision count (DECISIONS D9, 2026-09-30)." DECISIONS Q1-5 (BUSBAR-1.6.0.md:4780): the usage pointers /usage/units, /usage/input_tokens and /usage/output_tokens are dialect data. Finding 395 is kept (:4690): a usage member present but not a whole count is located, never dropped. plane-decisions#4: the decision count was read with no regard to the status (a 500 carrying /usage/units billed decisions), a success with no count billed no decision at all, and a count that was negative, fractional, a string or past i64::MAX was dropped silently. Now one success rule (FarEndReading::success: a 2xx status and no /error member, a null one being none) gates every count: a non-success bills nothing; a success bills its whole /usage/units, or one decision when it reports none, and its request fee; a usage member that is present but not a whole count fails the far end's last piece (Outcome::Failed, the plane fault the kernel ends the unit on) naming the member, never a silent 0. plane-decisions#5: the tail declared `decision` and the fee unit only. It now declares `input_tokens` and `output_tokens` in family `decision`, appended after the fee unit (the fee keeps index 1), and a success reports each where its answer carries a whole count. The jev rig's cards price both at 0, and the docs' declared-class list names them. RED: tests/door.rs a_success_with_no_count_bills_one_decision, a_non_success_bills_nothing, a_success_with_an_unreadable_count_fails_loudly, the_tokens_a_success_reports_are_billed_in_family_decision (over the door's ABI); src/tests/driven.rs a_success_bills_one_decision_or_the_count_it_reports, a_non_success_bills_nothing, a_usage_member_that_is_not_a_whole_count_fails_loudly, a_success_bills_the_tokens_it_reports, the_tail_names_the_decision_class_at_its_index. --- .../busbar-plane-decisions/src/codec/mod.rs | 16 +- crates/busbar-plane-decisions/src/driven.rs | 131 ++++++++---- .../busbar-plane-decisions/src/plane_door.rs | 35 ++-- .../src/tests/driven.rs | 190 +++++++++++++----- crates/busbar-plane-decisions/tests/door.rs | 114 +++++++++++ docs/config-at-a-glance.md | 2 +- docs/design/1.6.0-QUESTIONS.md | 2 +- xtask/src/conformance_record/jev.rs | 11 +- xtask/tests/conformance_rigs.rs | 17 ++ 9 files changed, 411 insertions(+), 107 deletions(-) diff --git a/crates/busbar-plane-decisions/src/codec/mod.rs b/crates/busbar-plane-decisions/src/codec/mod.rs index 1a373e7809..434df3936c 100644 --- a/crates/busbar-plane-decisions/src/codec/mod.rs +++ b/crates/busbar-plane-decisions/src/codec/mod.rs @@ -41,10 +41,24 @@ pub const PTR_ID: &str = "/id"; /// without this plane growing a second top-level member to ignore. pub const PTR_USAGE_UNITS: &str = "/usage/units"; +/// The pointers a SUCCESSFUL `systemone` response reports its input and output tokens at, where it +/// reports them (DECISIONS Q1-5: "usage pointers are DIALECT data (/usage/units, +/// /usage/input_tokens,/usage/output_tokens)"). +pub const PTR_USAGE_INPUT_TOKENS: &str = "/usage/input_tokens"; +/// See [`PTR_USAGE_INPUT_TOKENS`]. +pub const PTR_USAGE_OUTPUT_TOKENS: &str = "/usage/output_tokens"; + /// Every pointer this plane resolves in a RESPONSE body (`systemone` or `models`). Declared here /// once, so the "never resolves `/state` or `/answers`" claim is checkable by reading one list /// rather than auditing every call site. -pub const RESPONSE_PTRS: &[&str] = &[PTR_ERROR, PTR_REQUEST_ID, PTR_ID, PTR_USAGE_UNITS]; +pub const RESPONSE_PTRS: &[&str] = &[ + PTR_ERROR, + PTR_REQUEST_ID, + PTR_ID, + PTR_USAGE_UNITS, + PTR_USAGE_INPUT_TOKENS, + PTR_USAGE_OUTPUT_TOKENS, +]; /// The pointer a request names the model it is routed over at: its top-level `model` (THE DESIGN /// section 2, model resolution; DECISIONS D8b "REQUEST_PTRS += /model (metadata)"). Routing diff --git a/crates/busbar-plane-decisions/src/driven.rs b/crates/busbar-plane-decisions/src/driven.rs index 43c5e24d12..d21405d06b 100644 --- a/crates/busbar-plane-decisions/src/driven.rs +++ b/crates/busbar-plane-decisions/src/driven.rs @@ -39,14 +39,17 @@ //! list busbar already serves there), so [`tail::CLAIMS`] and [`tail::OP_CLASSES`] carry `systemone` alone and //! [`arrive`] answers the models operation as unclaimed. //! -//! Money-blind, like the rest of the crate: the plane reports how many decision units the far end -//! said it used, in its one billable class, and never what they are worth. +//! Money-blind, like the rest of the crate: the plane reports what a successful answer bills, in its +//! billable classes, and never what it is worth (THE DESIGN section 7, the owner money rulings +//! 2026-09-28: "bills one `decision` per successful, settled answer from every provider; its card +//! prices `decision`, `input_tokens` and `output_tokens` (family `decision`, all three required, 0 +//! = free); the hosted provider keeps 1.5.5's `/usage/units` as the decision count"). use busbar_contract::ids::{MeterClassId, OpClassId}; use crate::codec::{ self, error_body, CONTENT_TYPE_JSON, EGRESS_SCHEME, FIELD_CONTENT_TYPE, PTR_ERROR, - PTR_USAGE_UNITS, + PTR_USAGE_INPUT_TOKENS, PTR_USAGE_OUTPUT_TOKENS, PTR_USAGE_UNITS, }; use crate::ops; @@ -88,16 +91,36 @@ pub mod tail { /// The op classes this plane serves, in index order. `systemone` alone (see the module doc). pub const OP_CLASSES: &[OpClassId] = &[ops::OP_SYSTEMONE]; - /// The billable classes, in index order, each with its family: the far end's decision count, - /// and the fee unit (also a class: the tail check holds `fee_units ⊆ billable_classes`). + /// The billable classes, in index order, each with its family: the decision count, the fee + /// unit (also a class: the tail check holds `fee_units ⊆ billable_classes`), and the far end's + /// input and output tokens. THE DESIGN section 7 (owner money rulings 2026-09-28): the card + /// "prices `decision`, `input_tokens` and `output_tokens` (family `decision`, all three + /// required, 0 = free)". pub const BILLABLE_CLASSES: &[(MeterClassId, &str)] = &[ - (meta::CLASS_DECISION, "decision"), + (meta::CLASS_DECISION, DECISION_FAMILY), (MeterClassId::new(FEE_PER_REQUEST), FEE_FAMILY), + (CLASS_INPUT_TOKENS, DECISION_FAMILY), + (CLASS_OUTPUT_TOKENS, DECISION_FAMILY), ]; + /// The family every priced class of this plane is in. + pub const DECISION_FAMILY: &str = "decision"; + + /// The far end's input tokens, where its answer reports them (`/usage/input_tokens`). + pub const CLASS_INPUT_TOKENS: MeterClassId = MeterClassId::new("input_tokens"); + + /// The far end's output tokens, where its answer reports them (`/usage/output_tokens`). + pub const CLASS_OUTPUT_TOKENS: MeterClassId = MeterClassId::new("output_tokens"); + /// The index of [`meta::CLASS_DECISION`] in [`BILLABLE_CLASSES`]. pub const CLASS_DECISION_INDEX: u32 = 0; + /// The index of [`CLASS_INPUT_TOKENS`] in [`BILLABLE_CLASSES`]. + pub const CLASS_INPUT_TOKENS_INDEX: u32 = 2; + + /// The index of [`CLASS_OUTPUT_TOKENS`] in [`BILLABLE_CLASSES`]. + pub const CLASS_OUTPUT_TOKENS_INDEX: u32 = 3; + /// THE FEE UNIT: the request fee (`decisions.fees.per_request`), counted `1` on a unit whose /// far end answered a success, so a refused or failed unit owes none (the signed design C-3, /// "billable-success fee gated kernel-side", the tool plane's twin; ARCHITECT Q-L5-FEE (C)). A @@ -339,10 +362,20 @@ pub struct Units { pub amount: u64, } +/// A usage member the far end's success answer carries but that is not a whole count: the money +/// rule's loud failure (finding 395, kept: "a usage member present but not a whole count is +/// LOCATED, never dropped"), never a silent 0. Names the member's pointer. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Unreadable(pub &'static str); + +/// What one answer bills, in [`tail::BILLABLE_CLASSES`]' order of reading: the decision count, +/// the input and output tokens, and the request fee. `None` = nothing in that class. +pub type Counts = [Option; 4]; + /// The far end's answer, read as it is relayed. /// -/// Every piece is relayed to the caller unchanged, as it arrives. The count lives inside the body -/// (`/usage/units`), so the bytes are also kept until the last piece, and the count is read once, +/// Every piece is relayed to the caller unchanged, as it arrives. The counts live inside the body +/// (`/usage/*`), so the bytes are also kept until the last piece, and the counts are read once, /// over the whole answer. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct FarEndReading { @@ -362,39 +395,65 @@ impl FarEndReading { bytes } - /// The units the far end reported, read at the last piece, by the rule the plane's - /// `decode_response` reads them (`plane.rs`): a count is reported only for an answer with no - /// `/error` member, whose `/usage/units` is a whole number a signed 64-bit count holds. An - /// error answer, or a count that is missing, negative, fractional or past `i64::MAX`, reports - /// nothing. The status plays no part: `decode_response` judges success by the `/error` member - /// alone. + /// Whether the answer, with the far end's `status`, is a SUCCESS: a `2xx` status and no + /// `/error` member (a `null` one is none). The one success rule every count below reads. #[must_use] - pub fn units(&self) -> Option { - if codec::has(&self.body, PTR_ERROR) { - return None; - } - codec::read_u64(&self.body, PTR_USAGE_UNITS) - .filter(|amount| i64::try_from(*amount).is_ok()) - .map(|amount| Units { - class: tail::CLASS_DECISION_INDEX, - reported: true, - amount, - }) + pub fn success(&self, status: Option) -> bool { + status.is_some_and(|s| (200..300).contains(&s)) + && codec::read_raw(&self.body, PTR_ERROR).is_none_or(|e| e == b"null") } - /// THE REQUEST FEE, read at the last piece: `1` of [`tail::FEE_PER_REQUEST`] for an answer the - /// far end gave as a success — a `2xx` status and no `/error` member, the success rule - /// [`Self::units`] reads — and nothing otherwise, so a refused or failed unit owes no fee - /// (billable success only). - #[must_use] - pub fn fee(&self, status: Option) -> Option { - let success = status.is_some_and(|s| (200..300).contains(&s)); - (success && !codec::has(&self.body, PTR_ERROR)).then_some(Units { - class: tail::CLASS_FEE_INDEX, + /// WHAT THE ANSWER BILLS, read at the last piece (THE DESIGN section 7, the owner money rulings): + /// a non-success bills nothing; a success bills one `decision`, or the whole count its + /// `/usage/units` reports (the hosted provider's count, DECISIONS D9), its `/usage/input_tokens` + /// and `/usage/output_tokens` where it reports them, and the request fee (`1` of + /// [`tail::FEE_PER_REQUEST`]: a refused or failed unit owes none). + /// + /// # Errors + /// + /// [`Unreadable`]: a success whose usage member is present but is not a whole count a signed + /// 64-bit count holds (negative, fractional, a string, `null`, past `i64::MAX`). + pub fn counts(&self, status: Option) -> Result { + if !self.success(status) { + return Ok([None; 4]); + } + let decisions = self.count(PTR_USAGE_UNITS)?.unwrap_or(1); + let reported = |class: u32, amount: u64| Units { + class, reported: true, - amount: 1, - }) + amount, + }; + Ok([ + Some(reported(tail::CLASS_DECISION_INDEX, decisions)), + self.count(PTR_USAGE_INPUT_TOKENS)? + .map(|n| reported(tail::CLASS_INPUT_TOKENS_INDEX, n)), + self.count(PTR_USAGE_OUTPUT_TOKENS)? + .map(|n| reported(tail::CLASS_OUTPUT_TOKENS_INDEX, n)), + Some(reported(tail::CLASS_FEE_INDEX, 1)), + ]) + } + + /// The whole count at `pointer`: `None` when the answer has no such member. + fn count(&self, pointer: &'static str) -> Result, Unreadable> { + let Some(raw) = codec::read_raw(&self.body, pointer) else { + return Ok(None); + }; + whole(raw).map(Some).ok_or(Unreadable(pointer)) + } +} + +/// A usage member's raw bytes read as a whole count a signed 64-bit count holds: ASCII digits +/// alone, at most `i64::MAX`. +#[must_use] +pub fn whole(raw: &[u8]) -> Option { + if raw.is_empty() || !raw.iter().all(u8::is_ascii_digit) { + return None; } + core::str::from_utf8(raw) + .ok()? + .parse::() + .ok() + .filter(|n| i64::try_from(*n).is_ok()) } /// THE CALLER FIELDS THE FAR END NEVER RECEIVES: the ones the plane writes itself (the document diff --git a/crates/busbar-plane-decisions/src/plane_door.rs b/crates/busbar-plane-decisions/src/plane_door.rs index 4006ed2e42..9ac3a0de76 100644 --- a/crates/busbar-plane-decisions/src/plane_door.rs +++ b/crates/busbar-plane-decisions/src/plane_door.rs @@ -128,16 +128,15 @@ const OP_CLASSES: &[OpClass] = &[OpClass { name: abi_str(tail::OP_CLASSES[0].as_str()), }]; -const BILLABLE_CLASSES: &[BillableClass] = &[ - BillableClass { - class: abi_str(tail::BILLABLE_CLASSES[0].0.as_str()), - family: abi_str(tail::BILLABLE_CLASSES[0].1), - }, +const BILLABLE_CLASSES: &[BillableClass] = &[billable(0), billable(1), billable(2), billable(3)]; + +/// [`tail::BILLABLE_CLASSES`]' class `i`, on the ABI. +const fn billable(i: usize) -> BillableClass { BillableClass { - class: abi_str(tail::BILLABLE_CLASSES[1].0.as_str()), - family: abi_str(tail::BILLABLE_CLASSES[1].1), - }, -]; + class: abi_str(tail::BILLABLE_CLASSES[i].0.as_str()), + family: abi_str(tail::BILLABLE_CLASSES[i].1), + } +} /// The fee unit the plane counts ([`tail::FEE_UNITS`]). const FEE_UNITS: &[AbiStr] = &[abi_str(tail::FEE_UNITS[0])]; @@ -651,10 +650,20 @@ fn far_end( } } if last { - for count in [unit.reading.units(), unit.reading.fee(unit.status)] - .into_iter() - .flatten() - { + // THE MONEY RULE (THE DESIGN section 7): a success bills its decision count, its tokens and + // its fee; a usage member that is not a whole count fails the unit loudly, never a silent 0. + let counts = match unit.reading.counts(unit.status) { + Ok(counts) => counts, + Err(driven::Unreadable(pointer)) => { + return ( + out.fail(Refusal::failed(format!( + "the far end's answer carries `{pointer}`, which is not a whole count" + ))), + true, + ); + } + }; + for count in counts.into_iter().flatten() { units.push(UnitCount { class: count.class, source: UNITS_REPORTED, diff --git a/crates/busbar-plane-decisions/src/tests/driven.rs b/crates/busbar-plane-decisions/src/tests/driven.rs index d22a609ff0..e12c2aef7d 100644 --- a/crates/busbar-plane-decisions/src/tests/driven.rs +++ b/crates/busbar-plane-decisions/src/tests/driven.rs @@ -31,11 +31,21 @@ fn every_claim_arrives() { } } +/// RED (audit plane-decisions#5): THE DESIGN section 7, "its card prices `decision`, `input_tokens` +/// and `output_tokens` (family `decision`, all three required, 0 = free)". #[test] fn the_tail_names_the_decision_class_at_its_index() { let (class, family) = tail::BILLABLE_CLASSES[tail::CLASS_DECISION_INDEX as usize]; assert_eq!(class, CLASS_DECISION); assert_eq!(family, "decision"); + for (index, name) in [ + (tail::CLASS_INPUT_TOKENS_INDEX, "input_tokens"), + (tail::CLASS_OUTPUT_TOKENS_INDEX, "output_tokens"), + ] { + let (class, family) = tail::BILLABLE_CLASSES[index as usize]; + assert_eq!((class.as_str(), family), (name, "decision")); + } + assert_eq!(tail::BILLABLE_CLASSES.len(), 4); // The request fee is the one fee unit, and it is a class at its own index. assert_eq!(tail::FEE_UNITS, &[busbar_contract::plane::PER_REQUEST]); let (fee, fee_family) = tail::BILLABLE_CLASSES[tail::CLASS_FEE_INDEX as usize]; @@ -67,6 +77,23 @@ fn an_attempt_forwards_the_caller_body_unchanged() { ); } +/// The answer's counts as `(class, amount)`, for a far end answering `status` with `body`. +fn billed(status: u32, body: &[u8]) -> Result, Unreadable> { + let mut reading = FarEndReading::new(); + reading.piece(body); + reading.counts(Some(status)).map(|c| { + c.into_iter() + .flatten() + .map(|u| (u.class, u.amount)) + .collect() + }) +} + +const DECISION: u32 = tail::CLASS_DECISION_INDEX; +const FEE: u32 = tail::CLASS_FEE_INDEX; +const INPUT: u32 = tail::CLASS_INPUT_TOKENS_INDEX; +const OUTPUT: u32 = tail::CLASS_OUTPUT_TOKENS_INDEX; + #[test] fn far_end_pieces_are_relayed_unchanged_and_the_count_read_over_the_whole_answer() { let mut reading = FarEndReading::new(); @@ -74,10 +101,11 @@ fn far_end_pieces_are_relayed_unchanged_and_the_count_read_over_the_whole_answer let b = br#"its":42},"answers":{"decision":"approve"}}"#; assert_eq!(reading.piece(a), &a[..]); assert_eq!(reading.piece(b), &b[..]); + let counts = reading.counts(Some(200)).expect("a whole count"); assert_eq!( - reading.units(), + counts[0], Some(Units { - class: 0, + class: DECISION, reported: true, amount: 42 }) @@ -85,56 +113,115 @@ fn far_end_pieces_are_relayed_unchanged_and_the_count_read_over_the_whole_answer } #[test] -fn an_error_answer_reports_no_units() { - let mut reading = FarEndReading::new(); - reading.piece(br#"{"error":{"code":"invalid_state"},"usage":{"units":3}}"#); - assert_eq!(reading.units(), None); +fn an_error_answer_bills_nothing() { + assert_eq!( + billed( + 200, + br#"{"error":{"code":"invalid_state"},"usage":{"units":3}}"# + ), + Ok(vec![]) + ); } -/// RED: the count is read as `decode_response` reads it. Success is the absence of an `/error` -/// member, whatever the status (a 4xx with no `/error` still reports its count, as predev's decode -/// sets the fact), and a count past `i64::MAX` reports nothing (predev's `i64::try_from` drops it). +/// RED (audit plane-decisions#4): THE DESIGN section 7, "bills one `decision` per successful, settled +/// answer from every provider ... the hosted provider keeps 1.5.5's `/usage/units` as the decision +/// count". A success with no count bills one decision; one with a whole count bills the count. #[test] -fn the_count_is_read_as_decode_response_reads_it() { - let mut reading = FarEndReading::new(); - reading.piece(br#"{"usage":{"units":3}}"#); - assert_eq!(reading.units().map(|u| u.amount), Some(3)); - for (body, amount) in [ - ( - format!(r#"{{"usage":{{"units":{}}}}}"#, i64::MAX), - Some(i64::MAX as u64), - ), - ( - format!(r#"{{"usage":{{"units":{}}}}}"#, i64::MAX as u64 + 1), - None, - ), - (format!(r#"{{"usage":{{"units":{}}}}}"#, u64::MAX), None), - ] { - let mut reading = FarEndReading::new(); - reading.piece(body.as_bytes()); - assert_eq!(reading.units().map(|u| u.amount), amount, "{body}"); +fn a_success_bills_one_decision_or_the_count_it_reports() { + assert_eq!(billed(200, br#"{}"#), Ok(vec![(DECISION, 1), (FEE, 1)])); + assert_eq!( + billed(200, br#"{"usage":{}}"#), + Ok(vec![(DECISION, 1), (FEE, 1)]) + ); + assert_eq!( + billed(201, br#"{"usage":{"units":3}}"#), + Ok(vec![(DECISION, 3), (FEE, 1)]) + ); + assert_eq!( + billed(200, br#"{"usage":{"units":3},"error":null}"#), + Ok(vec![(DECISION, 3), (FEE, 1)]), + "a null error is no error" + ); + let max = format!(r#"{{"usage":{{"units":{}}}}}"#, i64::MAX); + assert_eq!( + billed(200, max.as_bytes()), + Ok(vec![(DECISION, i64::MAX as u64), (FEE, 1)]) + ); +} + +/// RED (audit plane-decisions#4): one success rule for every count: a non-2xx answer bills nothing, +/// whatever usage it carries. +#[test] +fn a_non_success_bills_nothing() { + for status in [400, 422, 500, 503] { + assert_eq!( + billed(status, br#"{"usage":{"units":2}}"#), + Ok(vec![]), + "{status}" + ); } + let mut reading = FarEndReading::new(); + reading.piece(br#"{"usage":{"units":2}}"#); + assert_eq!( + reading.counts(None), + Ok([None; 4]), + "no status read, no bill" + ); } +/// RED (audit plane-decisions#4): a success whose usage member is there but is not a whole count +/// fails loudly, naming the member (finding 395 kept: "LOCATED, never dropped"), never a silent 0. #[test] -fn a_count_that_is_not_a_whole_number_reports_no_units() { - for body in [ - &br#"{"usage":{"units":-1}}"#[..], - br#"{"usage":{"units":1.5}}"#, - br#"{"usage":{}}"#, - br#"{}"#, +fn a_usage_member_that_is_not_a_whole_count_fails_loudly() { + for units in [ + "-1".to_string(), + "1.5".to_string(), + "7.0".to_string(), + "1e3".to_string(), + "\"7\"".to_string(), + "null".to_string(), + "{}".to_string(), + (i64::MAX as u64 + 1).to_string(), + u64::MAX.to_string(), ] { - let mut reading = FarEndReading::new(); - reading.piece(body); - assert_eq!(reading.units(), None, "{}", String::from_utf8_lossy(body)); + let body = format!(r#"{{"usage":{{"units":{units}}}}}"#); + assert_eq!( + billed(200, body.as_bytes()), + Err(Unreadable(crate::codec::PTR_USAGE_UNITS)), + "{body}" + ); } + assert_eq!( + billed(200, br#"{"usage":{"units":1,"output_tokens":-2}}"#), + Err(Unreadable(crate::codec::PTR_USAGE_OUTPUT_TOKENS)) + ); } +/// RED (audit plane-decisions#5): the token counts a success reports are billed in their classes. #[test] -fn the_units_never_read_state_or_answers() { - let mut reading = FarEndReading::new(); - reading.piece(br#"{"usage":{"units":7},"state":{"units":999},"answers":{"units":888}}"#); - assert_eq!(reading.units().map(|u| u.amount), Some(7)); +fn a_success_bills_the_tokens_it_reports() { + assert_eq!( + billed( + 200, + br#"{"usage":{"units":2,"input_tokens":12,"output_tokens":5}}"# + ), + Ok(vec![(DECISION, 2), (INPUT, 12), (OUTPUT, 5), (FEE, 1)]) + ); + assert_eq!( + billed(200, br#"{"usage":{"input_tokens":9}}"#), + Ok(vec![(DECISION, 1), (INPUT, 9), (FEE, 1)]) + ); +} + +#[test] +fn the_counts_never_read_state_or_answers() { + assert_eq!( + billed( + 200, + br#"{"usage":{"units":7},"state":{"usage":{"units":999}},"answers":{"units":888}}"# + ), + Ok(vec![(DECISION, 7), (FEE, 1)]) + ); } #[test] @@ -256,18 +343,21 @@ fn the_splice_rewrites_the_top_level_model_value_alone() { fn the_request_fee_is_counted_on_a_success_alone() { let mut ok = FarEndReading::new(); ok.piece(br#"{"usage":{"units":3}}"#); - let fee = ok.fee(Some(200)).expect("a success owes its fee"); + let fee = ok.counts(Some(200)).expect("a success")[3].expect("a success owes its fee"); + assert_eq!((fee.class, fee.amount, fee.reported), (FEE, 1, true)); + // RED ARMS. assert_eq!( - (fee.class, fee.amount, fee.reported), - (tail::CLASS_FEE_INDEX, 1, true) + ok.counts(Some(422)), + Ok([None; 4]), + "a refused request owes none" + ); + assert_eq!(ok.counts(Some(500)), Ok([None; 4])); + assert_eq!(ok.counts(None), Ok([None; 4]), "no status read, no fee"); + assert_eq!( + billed(200, br#"{"error":{"code":"x","message":"y"}}"#), + Ok(vec![]), + "an error answer owes none" ); - // RED ARMS. - assert_eq!(ok.fee(Some(422)), None, "a refused request owes none"); - assert_eq!(ok.fee(Some(500)), None); - assert_eq!(ok.fee(None), None, "no status read, no fee"); - let mut failed = FarEndReading::new(); - failed.piece(br#"{"error":{"code":"x","message":"y"}}"#); - assert_eq!(failed.fee(Some(200)), None, "an error answer owes none"); } /// THE CALLER'S FIELDS REACH THE FAR END (DEC-SERVE Q2, DIALECT-FIDELITY F2): every one, in order, diff --git a/crates/busbar-plane-decisions/tests/door.rs b/crates/busbar-plane-decisions/tests/door.rs index 255a4aabd5..b005ab797a 100644 --- a/crates/busbar-plane-decisions/tests/door.rs +++ b/crates/busbar-plane-decisions/tests/door.rs @@ -147,3 +147,117 @@ fn upstream_model_is_spliced_into_the_top_level_model() { let unnamed = "{\"state\":{}}"; assert_eq!(sent(upstream, unnamed.as_bytes()), unnamed); } + +/// One unit served over [`ONE`]: its ATTEMPT, the caller's body, and the far end's whole answer, +/// `status` with `answer`. Answers the far end's piece. +fn answered(status: u32, answer: &[u8]) -> door_harness::Answered { + let door = open(ONE); + assert_eq!(door.arrive(1, br#"{"state":{}}"#).outcome, Outcome::Ready); + let mut bufs = Bufs::new(1 << 16); + door.piece(1, Given::attempt(1), &mut bufs); + door.piece(1, Given::caller(br#"{"state":{}}"#), &mut bufs); + door.piece(1, Given::whole(status, answer), &mut bufs) +} + +/// The class index the tail names `name` at. +fn class(name: &str) -> u32 { + let tail = busbar_plane_decisions::plane_door::TAIL; + // SAFETY: the tail's billable classes are a `'static` list of `billable_classes_len` entries, + // each naming `'static` strings. + let classes = + unsafe { std::slice::from_raw_parts(tail.billable_classes, tail.billable_classes_len) }; + let named = |c: &busbar_contract::abi::plane::BillableClass| { + // SAFETY: as above. + unsafe { std::slice::from_raw_parts(c.class.ptr, c.class.len) == name.as_bytes() } + }; + let at = classes + .iter() + .position(named) + .unwrap_or_else(|| panic!("the tail declares no class `{name}`")); + u32::try_from(at).unwrap() +} + +/// `(class, amount)` of every count an answer reported, each REPORTED. +fn counts(a: &door_harness::Answered) -> Vec<(u32, u64)> { + assert!( + a.units + .iter() + .all(|(_, source, _)| *source == busbar_contract::abi::plane::UNITS_REPORTED), + "{a:?}" + ); + a.units.iter().map(|(c, _, n)| (*c, *n)).collect() +} + +/// RED (audit plane-decisions#4): THE DESIGN section 7, "bills one `decision` per successful, settled +/// answer from every provider": a success with no count bills one decision, beside its fee. +#[test] +fn a_success_with_no_count_bills_one_decision() { + let fee = class(busbar_contract::plane::PER_REQUEST); + let a = answered(200, br#"{"answers":{}}"#); + assert_eq!(a.outcome, Some(Outcome::Ready), "{a:?}"); + assert_eq!(counts(&a), vec![(class("decision"), 1), (fee, 1)]); + let reported = answered(200, br#"{"usage":{"units":42}}"#); + assert_eq!(counts(&reported), vec![(class("decision"), 42), (fee, 1)]); +} + +/// RED (audit plane-decisions#4): one success rule: a non-2xx answer bills nothing, whatever count +/// it carries. +#[test] +fn a_non_success_bills_nothing() { + let a = answered(500, br#"{"usage":{"units":2}}"#); + assert_eq!(a.outcome, Some(Outcome::Ready), "{a:?}"); + assert!(a.units.is_empty(), "{a:?}"); + assert_eq!(a.bytes, br#"{"usage":{"units":2}}"#, "relayed unchanged"); +} + +/// RED (audit plane-decisions#4): a success whose count is not a whole count fails the unit loudly +/// (finding 395, kept: "LOCATED, never dropped"), never a silent zero. +#[test] +fn a_success_with_an_unreadable_count_fails_loudly() { + for answer in [ + &br#"{"usage":{"units":7.5}}"#[..], + br#"{"usage":{"units":"7"}}"#, + br#"{"usage":{"units":-3}}"#, + ] { + let a = answered(200, answer); + assert_eq!( + (a.outcome, a.short), + (Some(Outcome::Failed), false), + "{}: {a:?}", + String::from_utf8_lossy(answer) + ); + assert!(a.units.is_empty() && a.bytes.is_empty(), "{a:?}"); + } +} + +/// RED (audit plane-decisions#5): THE DESIGN section 7, "its card prices `decision`, `input_tokens` +/// and `output_tokens` (family `decision`, all three required, 0 = free)": the tail declares the +/// three in family `decision`, and a success's reported tokens are billed in them. +#[test] +fn the_tokens_a_success_reports_are_billed_in_family_decision() { + let tail = busbar_plane_decisions::plane_door::TAIL; + // SAFETY: as in `class`. + let classes = + unsafe { std::slice::from_raw_parts(tail.billable_classes, tail.billable_classes_len) }; + let family = |name: &str| { + let c = &classes[class(name) as usize]; + // SAFETY: as in `class`. + String::from_utf8_lossy(unsafe { std::slice::from_raw_parts(c.family.ptr, c.family.len) }) + .into_owned() + }; + for name in ["decision", "input_tokens", "output_tokens"] { + assert_eq!(family(name), "decision", "{name}"); + } + let a = answered( + 200, + br#"{"usage":{"units":2,"input_tokens":12,"output_tokens":5}}"#, + ); + let got = counts(&a); + for want in [ + (class("decision"), 2), + (class("input_tokens"), 12), + (class("output_tokens"), 5), + ] { + assert!(got.contains(&want), "{want:?} in {got:?}"); + } +} diff --git a/docs/config-at-a-glance.md b/docs/config-at-a-glance.md index 7faf3fbff1..7abc5684ee 100644 --- a/docs/config-at-a-glance.md +++ b/docs/config-at-a-glance.md @@ -235,7 +235,7 @@ providers_file: providers.yaml # provider catalog pointer (overridden by the -- plane declares. The fix is additive: add the `units:` map above (every LLM open class at `0`) to any one entry of the flat/`pools` card (declared classes per plane: `pools` = `input`, `output`, `cache_read`, `cache_write`, `search_units`, `classifications`, `web_fetch_requests`, `unitemized_tokens`, `images`, `audio_ms`, `guardrail_automated_reasoning_policies`, `guardrail_automated_reasoning_policy_units`, `guardrail_content_policy_image_units`, `guardrail_content_policy_units`, `guardrail_contextual_grounding_policy_units`, `guardrail_sensitive_information_policy_free_units`, `guardrail_sensitive_information_policy_units`, `guardrail_topic_policy_units`, `guardrail_word_policy_units` · `tools` = `tool_calls`, `bytes` · `agents` = `bytes` · - `decisions` = `decision` · `streams` = `audio_tokens_in`, `audio_tokens_out`, `text_tokens_in`, + `decisions` = `decision`, `input_tokens`, `output_tokens` · `streams` = `audio_tokens_in`, `audio_tokens_out`, `text_tokens_in`, `text_tokens_out`, `cached_tokens`, `audio_seconds_in`, `tool_calls`). A plane section with no card of its own is unaffected. - **[Validation](configuration.md#startup-validation-summary)**: `busbar --validate` runs the exact diff --git a/docs/design/1.6.0-QUESTIONS.md b/docs/design/1.6.0-QUESTIONS.md index bb3c2fddd9..8bacb27058 100644 --- a/docs/design/1.6.0-QUESTIONS.md +++ b/docs/design/1.6.0-QUESTIONS.md @@ -70,7 +70,7 @@ reading this cold, after the fact, with no memory of your slot. | Q39 | P2-verifyrunner | Q5 follow-up — OWNER ACTION (infra) | The getbusbar.com checks now run in their own `verify-site` job on the self-hosted `latchkey-small` runners (d33648d43). But the old combined job was ALREADY on `latchkey-small` when Cloudflare answered 403/429 — so Cloudflare is blocking those runners' egress IPs too. ACTION for you in Cloudflare (getbusbar.com zone): add a WAF custom rule / IP Access rule that SKIPS bot fight mode + rate limiting for the latchkey runners' egress IP(s) (or for a secret header — tell me the header name and I wire it from a repo secret). Nothing in this repo can clear it | workflow side done | **ANSWERED 2026-10-01 (owner): the ARCHITECT adds the Cloudflare WAF skip rule (secret-header bypass) itself.** DONE 2026-10-02: the getbusbar.com custom rule 'busbar release-gate site checks' skips WAF, managed rules, rate limiting and security level for header `X-Busbar-Verify` equal to the repo secret `SITE_VERIFY_TOKEN` (the existing site-curl.sh wiring). Free plan: Bot Fight Mode cannot be skipped by rules and stays on. | | Q40 | W2.2 | 281 (operator /metrics — DECIDE) + 283/556/570 (told) | (a) DECIDE — `busbar_lane_state{pool}` today reads a lane's state across ALL pools: a pool whose own breaker cell is Open while a sibling pool's cell for the same lane is healthy reads 1 (half-open). The fix makes the gauge read that pool's OWN cell: it then reads 2 (tripped) — correct per pool — but state 1 (half-open) becomes unreachable for pool lanes unless the per-pool half-open probe state is exported too. Options: (1) per-pool own cell AND export the per-pool half-open state so 0/1/2 all stay meaningful — ARCHITECT RECOMMENDATION; (2) per-pool own cell, 1 unreachable, document it; (3) keep the cross-pool reading. (b) told: two plane claims differing only in ALPN case no longer refuse boot (contract byte-exact rule; no shipped plane declares such a pair); a hook plugin's diagnostics now reach the log; "OTLP tracing enabled" is no longer printed when the exporter failed | (a) patch ready, not landed; (b) FIXED | (a) the gauge keeps the cross-pool reading | | Q41 | P2-coreadmin | openapi.json drift (DECIDE — §9.6 artifact) | Your Q18/Q22 ruling reverted the pre-session openapi edit, but the openapi drift test is still RED at HEAD: COMMITTED code already generates exactly those hunks — (1) `scheme` is required on AuditEntry (the audit record carries its scheme byte, pinned by W2.9/7c3306990), (2) POST /keys/{id}/rotate documents the 400 invalid_request Overlong response the handler really emits, (3) a restart_required_default doc-path rename. Choices: (a) REGENERATE openapi.json(.gz) so the published spec describes what the code does — ARCHITECT RECOMMENDATION (the spec must not lie; each behaviour is already in the tree); (b) revert the code producing those three hunks so the document stays byte-identical to the pre-session HEAD | openapi.json = HEAD + the Q25b 409 only | drift test stays red | -| Q42 | P2-billunits | generic billable-class boot rule (Q29/Q35 — FIXED, told) | Landed exactly as ruled. Upgrade impact to publish: EVERY 1.5.5 config with a `rate_card` now FAILS BOOT with `pools.rate_card does not configure billable unit(s) search_units declared by this plane; add them (0 to make them free)` — fix: add `units: { search_units: 0 }` to any card entry. Declared classes per plane: pools = input, output, cache_read, cache_write, search_units · tools = tool_calls, bytes · agents = bytes · decisions = decision · streams = audio_tokens_in, audio_tokens_out, text_tokens_in, text_tokens_out, cached_tokens, audio_seconds_in, tool_calls. For the 1.5.5 oracle (1.5.5 rejects `units:`), the Phase 3 harness adds these classes at 0 to the 1.6.0 run's config only — the operator upgrade step — golden untouched | FIXED (dbc02c8b9) | — | +| Q42 | P2-billunits | generic billable-class boot rule (Q29/Q35 — FIXED, told) | Landed exactly as ruled. Upgrade impact to publish: EVERY 1.5.5 config with a `rate_card` now FAILS BOOT with `pools.rate_card does not configure billable unit(s) search_units declared by this plane; add them (0 to make them free)` — fix: add `units: { search_units: 0 }` to any card entry. Declared classes per plane: pools = input, output, cache_read, cache_write, search_units · tools = tool_calls, bytes · agents = bytes · decisions = decision, input_tokens, output_tokens · streams = audio_tokens_in, audio_tokens_out, text_tokens_in, text_tokens_out, cached_tokens, audio_seconds_in, tool_calls. For the 1.5.5 oracle (1.5.5 rejects `units:`), the Phase 3 harness adds these classes at 0 to the 1.6.0 run's config only — the operator upgrade step — golden untouched | FIXED (dbc02c8b9) | — | | Q43 | P2-voice | Q21b voice invoices (FIXED as ruled — told) + Q21a | Voice now bills on the streaming plane's units, priced by `streams.rate_card` at read: audio seconds and tool calls start billing; with NO streams card voice reads 0 even if an llm card exists (it used to be priced by the llm card, and a present llm card missing the model closed the session on its first turn). Fixed script (card 1/token, streams card 1/count): before 18 (lease stored 180,000,000 nano), after 20 (audio_tokens_out 11, text_tokens_out 4, text_tokens_in 3, audio_seconds_in 1, tool_calls 1), nothing stored. A key whose requests/tokens cap is exhausted is refused a voice session (402); a live session closes when its budget runs dry from any traffic. Q21a: no session is cut unless `streams.session_max_secs` is set (then a `session_expired` error frame + settle). Being fixed: `cached_tokens` is a subset of input tokens so pricing it would double-bill — dropped from the billable classes (disjoint-classes rule) | FIXED (f87fd3800, 2beb4fb96) | — | | Q44 | P2-fees | Q32 per-plane fees (FIXED as ruled — told) | Customer-visible money moves (24bb507d0): (1) MCP and A2A calls no longer carry the pools `per_request_fee` — budget gate, /keys and /groups usage, /metrics spend gauges all move: flat fee 5, 4 MCP calls on a plane with no `fees:` 20 → 0; a group at 3/day that used to 429 on MCP call 1 is now served and reads 0; with tools.fees.per_request 3, 2 LLM + 3 MCP calls 25 → 19. (2) New optional config `
.fees: {per_request, per_session}` (additive): 2 voice sessions at per_session 40 → 80. (3) Durable budget rows gain a `␟` model entry for fee counts; durable billable_requests counts pools-plane requests only. (4) /metrics gains a zero-valued `␟` model series. (5) A session fee is counted after the open-time dry check, so one session can pass a cap by at most one session fee. Being fixed by the architect: GET /admin/usage still prices MCP metering rows at the flat fee — made to agree with the budget book. | | Q45 | P2-landpatches | rerank search units (FIXED — told) | Customer-visible money move (d7bcbaf2b): a same-protocol Cohere→Cohere or Bedrock→Bedrock rerank now reports its search units to both books. With card `rerank-v3.5: { units: { search_units: 2000 } }`, a rerank billing 50 search units goes from 0 to 10 cents on the invoice and /usage. A present card that does not price search_units now refuses it (#42); no card still reads 0. A cross-protocol rerank with a Bedrock egress now bills its search units, where before it billed flat. A Bedrock rerank body whose search_units is present but unreadable is now refused (item 133 rule). | diff --git a/xtask/src/conformance_record/jev.rs b/xtask/src/conformance_record/jev.rs index 1b96585367..2c55155a03 100644 --- a/xtask/src/conformance_record/jev.rs +++ b/xtask/src/conformance_record/jev.rs @@ -45,8 +45,9 @@ //! The 1.6.0 node keeps its rows at bucket-day width (no lane, no provider), so there the jev //! lane's row is the rig's caller's own bucket ([`judge_jev_ledger`]). //! The subject's own card prices the `decision` class at 1 micro-unit per unit -//! (`decisions.rate_card..units.decision: 1`, in the rig's generated config only), so the -//! lane's `priced_micros` IS the unit count: 42. +//! (`decisions.rate_card..units.decision: 1`, in the rig's generated config only) and the +//! two token classes the plane also declares at 0 (THE DESIGN §7: "all three required, 0 = +//! free"), so the lane's `priced_micros` IS the unit count: 42. //! //! THE MODEL-RESOLUTION CHECKS (THE DESIGN §2, the decisions bullet; DECISIONS D8b) run on a second //! boot whose `decisions.models` holds two models, [`MODEL`] and [`UPSTREAM_MODEL`] (its @@ -567,7 +568,7 @@ pub fn jev_subject_config(data: u16, admin: u16, key_file: &Path) -> String { advanced:\n allow_destinations: [\"127.0.0.1\"]\n\ groups:\n {ONE_REQUEST_GROUP}:\n limits:\n - {{ requests: 1, per: day }}\n\ decisions:\n models:\n {MODEL}:\n provider: {PROVIDER}\n\ - \x20 rate_card:\n {MODEL}: {{ units: {{ decision: 1 }} }}\n", + \x20 rate_card:\n {MODEL}: {{ units: {{ decision: 1, input_tokens: 0, output_tokens: 0 }} }}\n", key_file.display() ) } @@ -587,8 +588,8 @@ pub fn jev_models_config(data: u16, admin: u16, key_file: &Path) -> String { advanced:\n allow_destinations: [\"127.0.0.1\"]\n\ decisions:\n models:\n {MODEL}:\n provider: {PROVIDER}\n\ \x20 {UPSTREAM_MODEL}:\n provider: {PROVIDER}\n upstream_model: {UPSTREAM_NAME}\n\ - \x20 rate_card:\n {MODEL}: {{ units: {{ decision: 1 }} }}\n\ - \x20 {UPSTREAM_MODEL}: {{ units: {{ decision: 1 }} }}\n", + \x20 rate_card:\n {MODEL}: {{ units: {{ decision: 1, input_tokens: 0, output_tokens: 0 }} }}\n\ + \x20 {UPSTREAM_MODEL}: {{ units: {{ decision: 1, input_tokens: 0, output_tokens: 0 }} }}\n", key_file.display() ) } diff --git a/xtask/tests/conformance_rigs.rs b/xtask/tests/conformance_rigs.rs index 1acd1084db..e7b956cfdf 100644 --- a/xtask/tests/conformance_rigs.rs +++ b/xtask/tests/conformance_rigs.rs @@ -242,6 +242,23 @@ fn the_models_subject_configures_two_models_one_with_an_upstream_model() { "{m}" ); } + // THE DESIGN §7: the card prices `decision`, `input_tokens` and `output_tokens`, all three + // required (0 = free), on both subjects. + let main = xtask::conformance_record::jev_subject_config( + 41000, + 41001, + std::path::Path::new("/tmp/signing.key"), + ); + let main: serde_yaml::Value = serde_yaml::from_str(&main).expect("the config is YAML"); + for (doc, m) in [(&doc, "jev-1"), (&doc, UPSTREAM_MODEL), (&main, "jev-1")] { + for class in ["input_tokens", "output_tokens"] { + assert_eq!( + doc["decisions"]["rate_card"][m]["units"][class].as_u64(), + Some(0), + "{m} {class}" + ); + } + } assert_eq!(doc["store"]["module"].as_str(), Some("memory")); } From 4809d85794b66013410047172b354bc5f4e49721 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:13:53 -0700 Subject: [PATCH 10/59] decisions: a unit's state ends with the unit, live units are never evicted, and an answer is read as it passes (audit plane-decisions#6, #8) THE DESIGN section 11.2 (the shared mechanism): "memory a plugin returns is plugin-owned and valid until that plugin's next refresh generation; no allocation and no blocking on the hot path". And section 7: a settled success's count is billed, so a live unit's reading must not be lost. plane-decisions#6: arrive kept state for every claimed unit, but only a unit's last far-end piece removed it: a refused unit (rate limit, budget, grant) or a cancelled one stayed until eviction, and past 4096 the smallest key was popped, which in steady state was a live unit (its ATTEMPT or its reading lost, so its body was held or its count unbilled). `cancel` dropped nothing and answered CANCEL_ABORTED even after far-end bytes were relayed. Now `refusal` drops its unit (RefusalIn.unit); each piece records its ticket and `cancel` drops the unit on the cancelled ticket, answering CANCEL_ABORTED before the far end answered and CANCEL_OK_PARTIAL after; at MAX_UNITS a new arrival is refused at 503 about the entry it routed over (the ABI's refused-arrival rule 6, before anything is charged) and no live unit is dropped. A unit that arrived and never crossed a piece while MAX_UNITS arrivals followed is reclaimed, so an arrival the kernel never started cannot leave the instance refusing every arrival for good. plane-decisions#8: every far-end piece was appended to a growing whole-answer buffer (no bound) and copied again into the unit's owed bytes before it was written; the caller's fields were a vector of vectors per arrival. Now the answer is read as it passes (FarEndReading scans the JSON structure in fixed space for /error and the three /usage counts, wherever the pieces split it), a piece is written straight into the host's reply buffer and only what does not fit is kept for the `more` re-calls, and the caller's relayed fields are one buffer sized once per unit. Nothing caps the answer's size (#41: never a size-based refusal). RED: tests/door.rs a_refused_unit_is_dropped_and_a_live_unit_kept, a_cancelled_unit_is_dropped_and_a_live_unit_kept, a_unit_cancelled_after_its_far_end_answered_is_cut_partway, past_the_cap_an_arrival_is_refused_and_no_live_unit_dropped (over the door's ABI); tests/door_alloc.rs relaying_an_answer_piece_by_piece_allocates_nothing; src/tests/driven.rs the_reading_is_the_pointers_reading_wherever_the_pieces_split. --- crates/busbar-plane-decisions/src/driven.rs | 320 ++++++++++++++++-- .../busbar-plane-decisions/src/plane_door.rs | 142 ++++++-- .../src/tests/driven.rs | 94 ++++- crates/busbar-plane-decisions/tests/door.rs | 104 ++++++ .../tests/door_alloc.rs | 88 +++++ .../tests/door_harness/mod.rs | 28 ++ 6 files changed, 713 insertions(+), 63 deletions(-) create mode 100644 crates/busbar-plane-decisions/tests/door_alloc.rs diff --git a/crates/busbar-plane-decisions/src/driven.rs b/crates/busbar-plane-decisions/src/driven.rs index d21405d06b..61869a0f40 100644 --- a/crates/busbar-plane-decisions/src/driven.rs +++ b/crates/busbar-plane-decisions/src/driven.rs @@ -13,7 +13,8 @@ //! | `refusal` | [`refusal_body`]: the kernel's status and text, in this dialect's error shape | //! //! Every answer is plain data. Nothing here holds a byte across calls: [`FarEndReading`] is a value -//! the caller owns for the length of one unit, and it is the only accumulating thing in the module. +//! the caller owns for the length of one unit, in fixed space, and it is the only accumulating +//! thing in the module. //! Writing these answers into the host buffers of the plane ABI (`busbar_contract::abi::plane`) is //! the plane door's job, one generic adapter for every plane; it is not this plane's. //! @@ -48,8 +49,8 @@ use busbar_contract::ids::{MeterClassId, OpClassId}; use crate::codec::{ - self, error_body, CONTENT_TYPE_JSON, EGRESS_SCHEME, FIELD_CONTENT_TYPE, PTR_ERROR, - PTR_USAGE_INPUT_TOKENS, PTR_USAGE_OUTPUT_TOKENS, PTR_USAGE_UNITS, + self, error_body, CONTENT_TYPE_JSON, EGRESS_SCHEME, FIELD_CONTENT_TYPE, PTR_USAGE_INPUT_TOKENS, + PTR_USAGE_OUTPUT_TOKENS, PTR_USAGE_UNITS, }; use crate::ops; @@ -372,14 +373,73 @@ pub struct Unreadable(pub &'static str); /// the input and output tokens, and the request fee. `None` = nothing in that class. pub type Counts = [Option; 4]; +/// A usage member as the answer states it. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum Member { + /// The answer has no such member. + #[default] + Absent, + /// A whole count. + Whole(u64), + /// Present, and not a whole count. + Unreadable, +} + +/// What a member of the answer is to the reading: `/error`, `/usage`, one of the three usage +/// counts (by its index in [`COUNTED`]), or none of them. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum Slot { + #[default] + Other, + Error, + Usage, + Count(usize), +} + +/// The usage members read, `(member name under /usage, its pointer)`, in [`Counts`]' order. +const COUNTED: [(&[u8], &str); 3] = [ + (b"units", PTR_USAGE_UNITS), + (b"input_tokens", PTR_USAGE_INPUT_TOKENS), + (b"output_tokens", PTR_USAGE_OUTPUT_TOKENS), +]; + +/// The longest member name compared ("output_tokens"), with room. +const KEY_CAP: usize = 16; +/// The longest scalar a whole count is spelled in (`u64::MAX` is 20 digits), with room. +const SCALAR_CAP: usize = 24; + /// The far end's answer, read as it is relayed. /// -/// Every piece is relayed to the caller unchanged, as it arrives. The counts live inside the body -/// (`/usage/*`), so the bytes are also kept until the last piece, and the counts are read once, -/// over the whole answer. +/// Every piece is relayed to the caller unchanged, as it arrives, and read as it passes: a scan of +/// the JSON structure that keeps only what the money rule reads (whether `/error` is there and not +/// `null`, and the three `/usage/*` counts), in fixed space. No byte of the answer is kept, so an +/// answer of any size is read in the same few bytes (THE DESIGN section 11.2: "no allocation and no +/// blocking on the hot path"), and the pieces may split the answer anywhere. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct FarEndReading { - body: Vec, + /// The nesting depth: `1` inside the top-level value. + depth: u32, + /// Whether the container at depth 1 and at depth 2 is an object. + object: [bool; 3], + /// In an object at depth 1 or 2: the next string is a member name. + want_key: bool, + /// Inside a string; the string is a member name; the last byte was its escape. + in_str: bool, + str_key: bool, + escaped: bool, + /// The member name being read (cut at [`KEY_CAP`]: a longer one is none compared). + key: [u8; KEY_CAP], + key_len: usize, + /// The member a value at depth 1, and at depth 2, belongs to. + at: [Slot; 3], + /// The scalar being read for `scalar_of` (cut at [`SCALAR_CAP`]: a longer one is no count). + scalar: [u8; SCALAR_CAP], + scalar_len: usize, + scalar_of: Option, + /// `/error`: absent (`None`), `null` (`Some(false)`), or there (`Some(true)`). + error: Option, + /// The three usage counts, in [`COUNTED`]' order. + members: [Member; 3], } impl FarEndReading { @@ -391,16 +451,160 @@ impl FarEndReading { /// Take one far-end piece and return the bytes to relay to the caller: the piece itself. pub fn piece<'p>(&mut self, bytes: &'p [u8]) -> &'p [u8] { - self.body.extend_from_slice(bytes); + for &b in bytes { + self.byte(b); + } bytes } + /// The member at the current depth a value starting now belongs to. + fn slot(&self) -> Slot { + match self.depth { + 1 if self.object[1] => self.at[1], + 2 if self.object[2] && self.at[1] == Slot::Usage => self.at[2], + _ => Slot::Other, + } + } + + /// Whether the container the reading is in is an object it reads member names of. + fn in_object(&self) -> bool { + matches!(self.depth, 1 | 2) && self.object[self.depth as usize] + } + + /// A value starts: `scalar` = it is a bare scalar (a number, `true`, `false`, `null`). + fn value(&mut self, scalar: Option) { + let slot = self.slot(); + match (slot, scalar) { + (Slot::Error, None) => self.error = Some(true), + (Slot::Count(i), None) => self.members[i] = Member::Unreadable, + (Slot::Error | Slot::Count(_), Some(b)) => { + self.scalar[0] = b; + self.scalar_len = 1; + self.scalar_of = Some(slot); + } + _ => {} + } + } + + /// The scalar being read, ended. + fn end_scalar(&mut self) { + let Some(slot) = self.scalar_of.take() else { + return; + }; + let raw = &self.scalar[..self.scalar_len]; + match slot { + Slot::Error => self.error = Some(raw != b"null"), + Slot::Count(i) => { + self.members[i] = if self.scalar_len > SCALAR_CAP - 1 { + Member::Unreadable + } else { + whole(raw).map_or(Member::Unreadable, Member::Whole) + }; + } + _ => {} + } + } + + /// A member name, read whole. + fn end_key(&mut self) { + let key = &self.key[..self.key_len.min(KEY_CAP)]; + let named = if self.key_len > KEY_CAP { + Slot::Other + } else if self.depth == 1 { + match key { + b"error" => Slot::Error, + b"usage" => Slot::Usage, + _ => Slot::Other, + } + } else { + COUNTED + .iter() + .position(|(name, _)| *name == key) + .map_or(Slot::Other, Slot::Count) + }; + if let 1 | 2 = self.depth { + self.at[self.depth as usize] = named; + } + } + + /// One byte of the answer. + fn byte(&mut self, b: u8) { + if self.in_str { + if self.escaped { + self.escaped = false; + } else if b == b'"' { + self.in_str = false; + if self.str_key { + self.end_key(); + } + return; + } else if b == b'\\' { + self.escaped = true; + } + if self.str_key { + if self.key_len < KEY_CAP { + self.key[self.key_len] = b; + } + self.key_len = self.key_len.saturating_add(1); + } + return; + } + if self.scalar_of.is_some() { + if !matches!( + b, + b' ' | b'\t' | b'\n' | b'\r' | b',' | b'}' | b']' | b':' | b'"' | b'{' | b'[' + ) { + if self.scalar_len < SCALAR_CAP { + self.scalar[self.scalar_len] = b; + self.scalar_len += 1; + } + return; + } + self.end_scalar(); + } + match b { + b' ' | b'\t' | b'\n' | b'\r' | b':' => {} + b'"' => { + self.in_str = true; + self.escaped = false; + self.str_key = self.in_object() && self.want_key; + if self.str_key { + self.key_len = 0; + self.want_key = false; + } else { + self.value(None); + } + } + b'{' | b'[' => { + self.value(None); + self.depth = self.depth.saturating_add(1); + if let 1 | 2 = self.depth { + self.object[self.depth as usize] = b == b'{'; + self.at[self.depth as usize] = Slot::Other; + } + self.want_key = b == b'{'; + } + b'}' | b']' => { + self.depth = self.depth.saturating_sub(1); + self.want_key = false; + } + b',' => self.want_key = self.in_object(), + _ => self.value(Some(b)), + } + } + + /// The reading as the answer stands: a scalar the answer ended in is read as it is. + fn settled(&self) -> (Option, [Member; 3]) { + let mut done = self.clone(); + done.end_scalar(); + (done.error, done.members) + } + /// Whether the answer, with the far end's `status`, is a SUCCESS: a `2xx` status and no /// `/error` member (a `null` one is none). The one success rule every count below reads. #[must_use] pub fn success(&self, status: Option) -> bool { - status.is_some_and(|s| (200..300).contains(&s)) - && codec::read_raw(&self.body, PTR_ERROR).is_none_or(|e| e == b"null") + status.is_some_and(|s| (200..300).contains(&s)) && self.settled().0 != Some(true) } /// WHAT THE ANSWER BILLS, read at the last piece (THE DESIGN section 7, the owner money rulings): @@ -417,29 +621,27 @@ impl FarEndReading { if !self.success(status) { return Ok([None; 4]); } - let decisions = self.count(PTR_USAGE_UNITS)?.unwrap_or(1); + let (_, members) = self.settled(); + let mut whole = [None; 3]; + for (i, member) in members.iter().enumerate() { + whole[i] = match member { + Member::Absent => None, + Member::Whole(n) => Some(*n), + Member::Unreadable => return Err(Unreadable(COUNTED[i].1)), + }; + } let reported = |class: u32, amount: u64| Units { class, reported: true, amount, }; Ok([ - Some(reported(tail::CLASS_DECISION_INDEX, decisions)), - self.count(PTR_USAGE_INPUT_TOKENS)? - .map(|n| reported(tail::CLASS_INPUT_TOKENS_INDEX, n)), - self.count(PTR_USAGE_OUTPUT_TOKENS)? - .map(|n| reported(tail::CLASS_OUTPUT_TOKENS_INDEX, n)), + Some(reported(tail::CLASS_DECISION_INDEX, whole[0].unwrap_or(1))), + whole[1].map(|n| reported(tail::CLASS_INPUT_TOKENS_INDEX, n)), + whole[2].map(|n| reported(tail::CLASS_OUTPUT_TOKENS_INDEX, n)), Some(reported(tail::CLASS_FEE_INDEX, 1)), ]) } - - /// The whole count at `pointer`: `None` when the answer has no such member. - fn count(&self, pointer: &'static str) -> Result, Unreadable> { - let Some(raw) = codec::read_raw(&self.body, pointer) else { - return Ok(None); - }; - whole(raw).map(Some).ok_or(Unreadable(pointer)) - } } /// A usage member's raw bytes read as a whole count a signed 64-bit count holds: ASCII digits @@ -467,23 +669,67 @@ pub const NOT_RELAYED: &[&str] = &[ "proxy-authorization", ]; +/// The caller's fields the far end receives, held in one buffer per unit: each field's name +/// (lowercased) and value, back to back, and where each ends. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Relayed { + bytes: Vec, + /// Per field, `(end of its name, end of its value)` in `bytes`. + ends: Vec<(usize, usize)>, +} + +impl Relayed { + /// Every field, `(name, value)`, in the caller's order. + pub fn iter(&self) -> impl Iterator + '_ { + let mut from = 0; + self.ends.iter().map(move |&(name, value)| { + let field = (&self.bytes[from..name], &self.bytes[name..value]); + from = value; + field + }) + } + + /// Whether no field is relayed. + #[must_use] + pub fn is_empty(&self) -> bool { + self.ends.is_empty() + } +} + +/// Whether the caller's field `name` reaches the far end. +fn relayed(name: &[u8]) -> bool { + !NOT_RELAYED + .iter() + .any(|n| name.eq_ignore_ascii_case(n.as_bytes())) +} + /// THE CALLER'S FIELDS THE FAR END RECEIVES, in the order the caller sent them, a repeated name /// keeping every value: every field the caller sent but [`NOT_RELAYED`]'s (a same-dialect relay /// passes every header but the governed set; ARCHITECT DEC-SERVE Q2, the OWNER's DIALECT-FIDELITY -/// F2). Names are lowercased, as the wire compares them. +/// F2). Names are lowercased, as the wire compares them. Kept in one buffer sized once. #[must_use] -pub fn relayed_fields<'f>( - caller: impl IntoIterator, -) -> Vec<(Vec, Vec)> { - caller - .into_iter() - .filter(|(name, _)| { - !NOT_RELAYED - .iter() - .any(|n| name.eq_ignore_ascii_case(n.as_bytes())) - }) - .map(|(name, value)| (name.to_ascii_lowercase(), value.to_vec())) - .collect() +pub fn relayed_fields<'f, I>(caller: I) -> Relayed +where + I: IntoIterator, + I::IntoIter: Clone, +{ + let fields = caller.into_iter(); + let (mut size, mut count) = (0, 0); + for (name, value) in fields.clone().filter(|(name, _)| relayed(name)) { + size += name.len() + value.len(); + count += 1; + } + let mut kept = Relayed { + bytes: Vec::with_capacity(size), + ends: Vec::with_capacity(count), + }; + for (name, value) in fields.filter(|(name, _)| relayed(name)) { + kept.bytes.extend(name.iter().map(u8::to_ascii_lowercase)); + let name_end = kept.bytes.len(); + kept.bytes.extend_from_slice(value); + kept.ends.push((name_end, kept.bytes.len())); + } + kept } /// Render a refusal the kernel decided, in this dialect's error shape: `{"error": {"code", "message"}}`. diff --git a/crates/busbar-plane-decisions/src/plane_door.rs b/crates/busbar-plane-decisions/src/plane_door.rs index 9ac3a0de76..5281688310 100644 --- a/crates/busbar-plane-decisions/src/plane_door.rs +++ b/crates/busbar-plane-decisions/src/plane_door.rs @@ -29,13 +29,21 @@ //! far end unchanged ([`crate::driven::caller_piece`]) but for the routed model's `upstream_model` //! ([`crate::driven::spliced`]), and the far end's answer relayed unchanged with its count read at //! the last piece ([`crate::driven::FarEndReading`]). -//! * `refusal`: [`crate::driven::refusal_body`]. +//! * `refusal`: [`crate::driven::refusal_body`]; the refused unit's state is dropped. +//! * `cancel`: the unit on the cancelled ticket is dropped, `CANCEL_ABORTED` before its far end +//! answered and `CANCEL_OK_PARTIAL` after. //! * `serve` and `project` are REFUSED: the plane publishes no admin route and binds no hook view. -//! `hydrate`, `start`, `tick`, `drive`, `cancel`, `release` and `close` hold nothing. +//! `hydrate`, `start`, `tick`, `drive`, `release` and `close` hold nothing. +//! +//! A unit's state lives from its arrival to its end (its last far-end piece, its refusal or its +//! cancel), and at most [`MAX_UNITS`] live at once: an arrival past that is refused, and a live +//! unit is never dropped to make room (THE DESIGN section 11.2: memory is plugin-owned, and no +//! state grows without bound on the hot path). use std::collections::BTreeMap; use std::mem::size_of; use std::ptr; +use std::sync::atomic::{AtomicU64, Ordering}; use busbar_contract::abi::host::conn::connector::{ Need, DIRECTION_OUTBOUND, EGRESS_PROVIDER, KEEP_NAMED, @@ -47,13 +55,15 @@ use busbar_contract::abi::mechanism::door::{ use busbar_contract::abi::mechanism::lifecycle::{ GenIn, RefreshIn, ReleaseIn, TickIn, TickOut, ValidateIn, }; +use busbar_contract::abi::mechanism::ticket::Ticket; use busbar_contract::abi::plane::{ ArriveIn, ArriveOut, BillableClass, DialectAuth, OnPieceIn, OnPieceOut, OpClass, OutField, PlaneDriveIn, PlaneDriveOut, PlaneOpenIn, PlaneOpenOut, PlaneRefreshOut, PlaneSnapshot, PlaneTail, ProjectIn, ProjectOut, RefusalIn, RefusalOut, ServeIn, ServeOut, UnitCount, - CANCEL_ABORTED, CLAIM_EXACT, EMIT_DONE, EMIT_TO_FAR_END, FROM_CALLER, FROM_FAR_END, - FROM_KERNEL, INGRESS_REQUEST_RESPONSE, PIECE_HAS_STATUS, PIECE_LAST, PRINCIPAL_NONE, - PRINCIPAL_OPTIONAL, PRINCIPAL_REQUIRED, ROUTE_DIRECT, SHAPE_WHOLE, UNITS_REPORTED, + CANCEL_ABORTED, CANCEL_OK_PARTIAL, CLAIM_EXACT, EMIT_DONE, EMIT_TO_FAR_END, FROM_CALLER, + FROM_FAR_END, FROM_KERNEL, INGRESS_REQUEST_RESPONSE, PIECE_HAS_STATUS, PIECE_LAST, + PRINCIPAL_NONE, PRINCIPAL_OPTIONAL, PRINCIPAL_REQUIRED, ROUTE_DIRECT, SHAPE_WHOLE, + UNITS_REPORTED, }; use busbar_contract::abi::plane::{PlaneCancelIn, PlaneCancelOut}; use busbar_contract::abi::sdk::door::{abi_str, statement}; @@ -75,7 +85,8 @@ pub const VERSION: &str = "1.6.0"; /// The most calls the kernel keeps in flight on one instance. const MAX_INFLIGHT: u32 = 64; -/// The most units the instance keeps state for at once; past it, the oldest is dropped first. +/// The most units the instance keeps state for at once; past it, a new arrival is refused +/// ([`BUSY`]) and no live unit is dropped. pub const MAX_UNITS: usize = 4096; /// [`ArriveOut::refusal`]: the request names no operation this plane claims. @@ -87,6 +98,13 @@ const STATUS_NOT_FOUND: u32 = 404; /// [`ArriveOut::refusal`]: the request routes over no configured model ([`driven::Unrouted`]). pub const UNROUTED: u32 = 2; +/// [`ArriveOut::refusal`]: the instance holds [`MAX_UNITS`] live units; the arrival is refused at +/// [`STATUS_BUSY`], about the entry it routed over, before anything is charged. +pub const BUSY: u32 = 3; + +/// The status a [`BUSY`] arrival is refused at. +const STATUS_BUSY: u32 = 503; + /// The human label. const LABEL: &str = "Decisions"; @@ -322,7 +340,11 @@ struct Unit { owed_flags: u32, /// The caller's fields the far end receives ([`driven::relayed_fields`]), kept from `arrive` /// (the caller's head crosses once) for every ATTEMPT of the unit. - caller: Vec<(Vec, Vec)>, + caller: driven::Relayed, + /// The ticket the unit's last piece crossed on: what a `cancel` names it by. + ticket: Option, + /// Its place in the instance's arrivals ([`DecisionsDoor::arrivals`]). + seq: u64, /// The far end's status, read off its first piece; the request fee is judged on it. status: Option, /// The routed model's `upstream_model`, kept from `arrive`: the caller's top-level `model` is @@ -338,6 +360,8 @@ pub struct DecisionsDoor { /// The models a claimed arrival routes over, directly, by name: every model the latest /// generation configures, each with its `upstream_model`. models: Keyed>, + /// How many arrivals the instance has kept: each unit's [`Unit::seq`]. + arrivals: AtomicU64, } impl DecisionsDoor { @@ -394,6 +418,7 @@ slot!( generations: Generations::new(), units: Keyed::new(), models: Keyed::new(), + arrivals: AtomicU64::new(0), }; plane.route_over(§ion); let spec = generation_spec(§ion); @@ -444,9 +469,24 @@ slot!( ); slot!( - /// `cancel`: a unit is never answered before its far end has, so nothing is moved. - Cancel, PlaneCancelIn, PlaneCancelOut, |_, _, mut out| { - out.set(|o| &o.cancel.disposition, CANCEL_ABORTED); + /// `cancel`: the unit on the cancelled ticket is dropped. Before its far end answered it was + /// aborted (nothing billed); after, it was cut partway. + Cancel, PlaneCancelIn, PlaneCancelOut, |instance, input, mut out| { + let ticket = input.get().cancel.ticket; + let answered = instance.get().is_some_and(|plane| { + plane.units.with_all(|units| { + let key = units + .iter() + .find(|(_, unit)| unit.ticket == Some(ticket)) + .map(|(key, _)| *key); + key.and_then(|key| units.remove(&key)) + .is_some_and(|unit| unit.status.is_some()) + }) + }); + out.set( + |o| &o.cancel.disposition, + if answered { CANCEL_OK_PARTIAL } else { CANCEL_ABORTED }, + ); Outcome::Ready } ); @@ -508,11 +548,26 @@ slot!( ) })); let unit = input.get().unit; - plane.units.with_all(|units| { - let kept = unit_of(units, unit); + let seq = plane.arrivals.fetch_add(1, Ordering::Relaxed); + let kept = plane.units.with_all(|units| { + reclaim_stale(units, unit, seq); + let Some(kept) = unit_of(units, unit) else { + return false; + }; kept.caller = caller; kept.upstream = upstream; + kept.seq = seq; + true }); + if !kept { + // Full: the arrival is refused, about the entry it routed over, and no live unit + // is dropped to make room. + out.set(|o| &o.refusal, BUSY); + out.set(|o| &o.refusal_status, STATUS_BUSY); + return out.fail(Refusal::refused( + "the decisions plane is serving as many requests as it holds; retry", + )); + } } Outcome::Ready } @@ -563,7 +618,8 @@ fn pay(unit: &mut Unit, input: Lent<'_, OnPieceIn>, out: &mut Out<'_, OnPieceOut done } -/// Owe `bytes` under `flags`, and pay what fits now. Answers whether the unit is done. +/// Owe `bytes` under `flags`: write what fits straight from `bytes`, and keep only what does not +/// fit for the re-calls `more = 1` asks for. Answers whether the unit is done. fn owe( unit: &mut Unit, bytes: &[u8], @@ -571,11 +627,18 @@ fn owe( input: Lent<'_, OnPieceIn>, out: &mut Out<'_, OnPieceOut>, ) -> bool { + let n = input.reply_buf().stream(bytes); unit.owed.clear(); - unit.owed.extend_from_slice(bytes); unit.at = 0; unit.owed_flags = flags; - pay(unit, input, out) + let more = n < bytes.len(); + if more { + unit.owed.extend_from_slice(&bytes[n..]); + } + out.set(|o| &o.emitted, n as u64); + out.set(|o| &o.more, u32::from(more)); + out.set(|o| &o.flags, if more { flags & !EMIT_DONE } else { flags }); + !more && flags & EMIT_DONE != 0 } /// An ATTEMPT: the request bound for the far end, its verb, target and dialect fields. A new @@ -597,7 +660,7 @@ fn attempt_piece( value: arena.span(value), }); } - for (name, value) in &unit.caller { + for (name, value) in unit.caller.iter() { fields.push(OutField { name: arena.span(name), value: arena.span(value), @@ -610,8 +673,12 @@ fn attempt_piece( attempt: true, caller: std::mem::take(&mut unit.caller), upstream: unit.upstream.take(), + ticket: unit.ticket, + seq: unit.seq, + owed: std::mem::take(&mut unit.owed), ..Unit::default() }; + unit.owed.clear(); out.set(|o| &o.verb, verb); out.set(|o| &o.target, target); out.set(|o| &o.flags, EMIT_TO_FAR_END); @@ -683,14 +750,30 @@ fn far_end( (Outcome::Ready, done) } -/// Keep `unit`'s state in `units`, dropping the smallest (oldest) keys first past [`MAX_UNITS`]. -fn unit_of(units: &mut BTreeMap, key: u64) -> &mut Unit { - while units.len() >= MAX_UNITS && !units.contains_key(&key) { - if units.pop_first().is_none() { - break; - } +/// At [`MAX_UNITS`], the oldest unit that arrived [`MAX_UNITS`] or more arrivals before `seq` and +/// never crossed a piece is reclaimed: the kernel never started it, and keeping it would leave the +/// instance refusing every arrival for good. A unit that crossed a piece is never reclaimed. +fn reclaim_stale(units: &mut BTreeMap, key: u64, seq: u64) { + if units.len() < MAX_UNITS || units.contains_key(&key) { + return; + } + let stale = units + .iter() + .filter(|(_, u)| u.ticket.is_none() && seq.saturating_sub(u.seq) >= MAX_UNITS as u64) + .min_by_key(|(_, u)| u.seq) + .map(|(k, _)| *k); + if let Some(stale) = stale { + units.remove(&stale); } - units.entry(key).or_default() +} + +/// `unit`'s state in `units`, a new entry when it has none; `None` when it has none and +/// [`MAX_UNITS`] live already (a live unit is never dropped to make room). +fn unit_of(units: &mut BTreeMap, key: u64) -> Option<&mut Unit> { + if units.len() >= MAX_UNITS && !units.contains_key(&key) { + return None; + } + Some(units.entry(key).or_default()) } slot!( @@ -703,7 +786,10 @@ slot!( let given = input.get(); let bytes = input.field(|i| &i.bytes).bytes(); plane.units.with_all(|units| { - let unit = unit_of(units, given.unit); + let Some(unit) = unit_of(units, given.unit) else { + return Outcome::Refused; + }; + unit.ticket = Some(given.head.ticket); // A re-call after `more = 1` carries no bytes, no flags and no attempt: it is paid from // what is owed. let continues = bytes.is_empty() && given.flags == 0 && given.attempt_no == 0; @@ -740,8 +826,14 @@ slot!( /// `refusal`: the kernel's status and text in this dialect's error shape, as JSON, and the /// wait the kernel handed beside it (`retry_after_s`, a limit's rolling window or the walk's /// floor) as `Retry-After`, as every plane answers it. - RefusalSlot, RefusalIn, RefusalOut, |_, input, mut out| { + RefusalSlot, RefusalIn, RefusalOut, |instance, input, mut out| { let given = input.get(); + // The refusal ends the unit: its state goes with it. + if let Some(plane) = instance.get() { + if given.unit != 0 { + plane.units.remove(&given.unit); + } + } let status = u16::try_from(given.status).unwrap_or(0); let text = input.field(|i| &i.text).as_str().unwrap_or_default(); let body = driven::refusal_body(status, text); diff --git a/crates/busbar-plane-decisions/src/tests/driven.rs b/crates/busbar-plane-decisions/src/tests/driven.rs index e12c2aef7d..b85820039a 100644 --- a/crates/busbar-plane-decisions/src/tests/driven.rs +++ b/crates/busbar-plane-decisions/src/tests/driven.rs @@ -374,8 +374,11 @@ fn every_caller_field_but_the_governed_set_is_relayed() { (b"Proxy-Authorization", b"Basic x"), (b"accept-language", b"en"), ]; + let kept = relayed_fields(caller); assert_eq!( - relayed_fields(caller), + kept.iter() + .map(|(n, v)| (n.to_vec(), v.to_vec())) + .collect::>(), vec![ (b"x-trace".to_vec(), b"a".to_vec()), (b"x-trace".to_vec(), b"b".to_vec()), @@ -384,3 +387,92 @@ fn every_caller_field_but_the_governed_set_is_relayed() { ); assert!(relayed_fields(std::iter::empty()).is_empty()); } + +/// The money rule's reading of a whole answer by its declared pointers, as the body would be read +/// kept whole: the reference the piece-by-piece reading is held to. +fn by_pointer(status: u32, body: &[u8]) -> Result, Unreadable> { + use crate::codec::{read_raw, PTR_ERROR}; + let ok = (200..300).contains(&status) && read_raw(body, PTR_ERROR).is_none_or(|e| e == b"null"); + if !ok { + return Ok(vec![]); + } + let count = |p: &'static str| match read_raw(body, p) { + None => Ok(None), + Some(raw) => whole(raw).map(Some).ok_or(Unreadable(p)), + }; + let mut out = vec![(DECISION, count(crate::codec::PTR_USAGE_UNITS)?.unwrap_or(1))]; + if let Some(n) = count(crate::codec::PTR_USAGE_INPUT_TOKENS)? { + out.push((INPUT, n)); + } + if let Some(n) = count(crate::codec::PTR_USAGE_OUTPUT_TOKENS)? { + out.push((OUTPUT, n)); + } + out.push((FEE, 1)); + Ok(out) +} + +/// The answer read in two pieces split at `at`. +fn split_billed(status: u32, body: &[u8], at: usize) -> Result, Unreadable> { + let mut reading = FarEndReading::new(); + reading.piece(&body[..at]); + reading.piece(&body[at..]); + reading.counts(Some(status)).map(|c| { + c.into_iter() + .flatten() + .map(|u| (u.class, u.amount)) + .collect() + }) +} + +/// RED (audit plane-decisions#8): the answer is read as it passes, in fixed space, wherever the +/// pieces split it, and reads exactly what the declared pointers read over the whole answer. +#[test] +fn the_reading_is_the_pointers_reading_wherever_the_pieces_split() { + let answers: [&[u8]; 14] = [ + br#"{"request_id":"req_1","usage":{"units":42},"answers":{"decision":"approve"}}"#, + br#" { "usage" : { "input_tokens" : 12 , "units" : 3, "output_tokens":5 } } "#, + br#"{"answers":{"usage":{"units":9}},"usage":{"units":2}}"#, + br#"{"state":{"error":"x","usage":{"units":"q"}},"usage":{}}"#, + br#"{"error":null,"usage":{"units":7}}"#, + br#"{"error":{"code":"x","message":"y"},"usage":{"units":3}}"#, + br#"{"error":"down"}"#, + br#"{"usage":{"units":7.5}}"#, + br#"{"usage":{"units":"7"}}"#, + br#"{"usage":{"units":[1]}}"#, + br#"{"usage":{"units":99999999999999999999999999}}"#, + br#"{"a\"usage":{"units":"no"},"usage":{"output_tokens":-1}}"#, + br#"{"usage":"n/a","x":[{"usage":{"units":1}}]}"#, + br#"[{"usage":{"units":5}}]"#, + ]; + for body in answers { + for status in [200, 500] { + let want = by_pointer(status, body); + for at in 0..=body.len() { + assert_eq!( + split_billed(status, body, at), + want, + "{status} {} split at {at}", + String::from_utf8_lossy(body) + ); + } + } + } +} + +/// An answer of any size is read in the reading's own fixed space. +#[test] +fn a_large_answer_is_read_without_keeping_it() { + let mut reading = FarEndReading::new(); + let before = std::mem::size_of_val(&reading); + reading.piece(br#"{"answers":{"blob":""#); + let filler = [b'x'; 4096]; + for _ in 0..256 { + reading.piece(&filler); + } + reading.piece(br#""},"usage":{"units":4}}"#); + assert_eq!(std::mem::size_of_val(&reading), before); + assert_eq!( + reading.counts(Some(200)).map(|c| c[0].map(|u| u.amount)), + Ok(Some(4)) + ); +} diff --git a/crates/busbar-plane-decisions/tests/door.rs b/crates/busbar-plane-decisions/tests/door.rs index b005ab797a..f38bfd2135 100644 --- a/crates/busbar-plane-decisions/tests/door.rs +++ b/crates/busbar-plane-decisions/tests/door.rs @@ -261,3 +261,107 @@ fn the_tokens_a_success_reports_are_billed_in_family_decision() { assert!(got.contains(&want), "{want:?} in {got:?}"); } } + +const MAX_UNITS: u64 = busbar_plane_decisions::plane_door::MAX_UNITS as u64; + +/// A live unit over [`ONE`]: arrived, its ATTEMPT crossed. +fn live(door: &door_harness::Door, unit: u64, bufs: &mut Bufs) { + let arrived = door.arrive(unit, br#"{"state":{}}"#); + assert_eq!(arrived.outcome, Outcome::Ready, "unit {unit}: {arrived:?}"); + let attempt = door.piece(unit, Given::attempt(1), bufs); + assert_eq!(attempt.request, "POST /v1/systemone", "unit {unit}"); +} + +/// Whether `unit`'s body still reaches the far end: its ATTEMPT's state is still held. +fn still_live(door: &door_harness::Door, unit: u64, bufs: &mut Bufs) -> bool { + door.piece(unit, Given::caller(br#"{"state":{}}"#), bufs) + .far +} + +/// RED (audit plane-decisions#6): a refusal ends its unit and drops its state, so refused traffic +/// never crowds out a live unit. +#[test] +fn a_refused_unit_is_dropped_and_a_live_unit_kept() { + let door = open(ONE); + let mut bufs = Bufs::new(1 << 16); + live(&door, 1, &mut bufs); + for unit in 2..2 + MAX_UNITS { + assert_eq!( + door.arrive(unit, br#"{}"#).outcome, + Outcome::Ready, + "unit {unit}" + ); + door.refusal(unit, 429, "the key's limit is spent", false); + } + assert!(still_live(&door, 1, &mut bufs), "the live unit was dropped"); +} + +/// RED (audit plane-decisions#6): a cancel ends the unit on its ticket and drops its state. +#[test] +fn a_cancelled_unit_is_dropped_and_a_live_unit_kept() { + let door = open(ONE); + let mut bufs = Bufs::new(1 << 16); + live(&door, 1, &mut bufs); + for unit in 2..2 + MAX_UNITS { + live(&door, unit, &mut bufs); + let (outcome, disposition) = door.cancel(door_harness::ticket_of(unit)); + assert_eq!( + (outcome, disposition), + (Outcome::Ready, busbar_contract::abi::plane::CANCEL_ABORTED), + "unit {unit}" + ); + } + assert!(still_live(&door, 1, &mut bufs), "the live unit was dropped"); +} + +/// RED (audit plane-decisions#6): a unit cut after its far end answered was not aborted before it. +#[test] +fn a_unit_cancelled_after_its_far_end_answered_is_cut_partway() { + let door = open(ONE); + let mut bufs = Bufs::new(1 << 16); + live(&door, 1, &mut bufs); + assert!(still_live(&door, 1, &mut bufs)); + let first = door.piece(1, Given::far(Some(200), false, br#"{"usage":"#), &mut bufs); + assert_eq!(first.outcome, Some(Outcome::Ready)); + assert_eq!( + door.cancel(door_harness::ticket_of(1)), + ( + Outcome::Ready, + busbar_contract::abi::plane::CANCEL_OK_PARTIAL + ) + ); +} + +/// RED (audit plane-decisions#6): past [`MAX_UNITS`] live units a new arrival is refused, about +/// the entry it routed over, and no live unit is dropped to make room. +#[test] +fn past_the_cap_an_arrival_is_refused_and_no_live_unit_dropped() { + let door = open(ONE); + let mut bufs = Bufs::new(1 << 16); + for unit in 1..=MAX_UNITS { + live(&door, unit, &mut bufs); + } + let over = door.arrive(MAX_UNITS + 1, br#"{"state":{}}"#); + assert_eq!( + (over.outcome, over.status, over.route, over.entry.as_str()), + (Outcome::Refused, 503, ROUTE_DIRECT, "jev"), + "{over:?}" + ); + assert!( + still_live(&door, 1, &mut bufs), + "the first live unit was dropped" + ); + assert!(still_live(&door, MAX_UNITS, &mut bufs)); +} + +/// A unit that arrived and never crossed a piece while [`MAX_UNITS`] arrivals followed is +/// reclaimed, so the instance never refuses every arrival for good. +#[test] +fn an_arrival_never_started_is_reclaimed_once_the_cap_is_reached() { + let door = open(ONE); + for unit in 1..=MAX_UNITS { + assert_eq!(door.arrive(unit, br#"{}"#).outcome, Outcome::Ready); + } + let next = door.arrive(MAX_UNITS + 1, br#"{}"#); + assert_eq!(next.outcome, Outcome::Ready, "{next:?}"); +} diff --git a/crates/busbar-plane-decisions/tests/door_alloc.rs b/crates/busbar-plane-decisions/tests/door_alloc.rs new file mode 100644 index 0000000000..4570d98400 --- /dev/null +++ b/crates/busbar-plane-decisions/tests/door_alloc.rs @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! WHAT RELAYING A FAR-END ANSWER ALLOCATES (THE DESIGN section 11.2: "no allocation and no +//! blocking on the hot path"): a piece of the far end's answer is read as it passes and written +//! straight into the host's reply buffer, so relaying piece after piece of an answer allocates +//! nothing, however long the answer runs. + +#![allow(unsafe_code)] + +mod door_harness; + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::cell::Cell; + +use busbar_contract::abi::mechanism::call::Outcome; +use door_harness::{open, Bufs, Given}; + +thread_local! { + static ALLOCS: Cell = const { Cell::new(0) }; +} + +struct Counting; + +// SAFETY: every call is forwarded verbatim to the system allocator; the counter is a thread-local +// `Cell` of a plain integer, touched only on the allocating thread, and never reads or writes the +// memory being handed out. +unsafe impl GlobalAlloc for Counting { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + let _ = ALLOCS.try_with(|c| c.set(c.get() + 1)); + // SAFETY: forwarded as given. + unsafe { System.alloc(layout) } + } + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + // SAFETY: forwarded as given. + unsafe { System.dealloc(ptr, layout) } + } + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + let _ = ALLOCS.try_with(|c| c.set(c.get() + 1)); + // SAFETY: forwarded as given. + unsafe { System.realloc(ptr, layout, new_size) } + } +} + +#[global_allocator] +static ALLOC: Counting = Counting; + +fn allocations_of(f: impl FnOnce()) -> u64 { + let before = ALLOCS.with(Cell::get); + f(); + ALLOCS.with(Cell::get) - before +} + +/// RED (audit plane-decisions#8): 256 pieces of a 1 MiB answer, each 4 KiB, relayed through the +/// door: no allocation (predev kept the whole answer, growing a buffer, and copied each piece +/// again before writing it). +#[test] +fn relaying_an_answer_piece_by_piece_allocates_nothing() { + let door = open(r#"{"models":{"jev":{"provider":"typesafe"}}}"#); + let mut bufs = Bufs::new(1 << 16); + assert_eq!(door.arrive(1, br#"{"state":{}}"#).outcome, Outcome::Ready); + door.piece(1, Given::attempt(1), &mut bufs); + door.piece(1, Given::caller(br#"{"state":{}}"#), &mut bufs); + let head = door.piece( + 1, + Given::far(Some(200), false, br#"{"answers":{"blob":""#), + &mut bufs, + ); + assert_eq!(head.outcome, Some(Outcome::Ready)); + let filler = [b'x'; 4096]; + let piece = Given::far(None, false, &filler); + // Warm: one piece outside the window. + door.piece(1, piece, &mut bufs); + let mut relayed = 0; + let count = allocations_of(|| { + for _ in 0..256 { + relayed += door.piece_bytes(1, piece, &mut bufs); + } + }); + assert_eq!(relayed, 256 * filler.len(), "every byte is relayed"); + assert_eq!(count, 0, "relaying 256 pieces allocated {count} times"); + let last = door.piece( + 1, + Given::far(None, true, br#""},"usage":{"units":4}}"#), + &mut bufs, + ); + assert_eq!(last.units.first().map(|u| u.2), Some(4), "{last:?}"); +} diff --git a/crates/busbar-plane-decisions/tests/door_harness/mod.rs b/crates/busbar-plane-decisions/tests/door_harness/mod.rs index a9850efa72..9a378e82bb 100644 --- a/crates/busbar-plane-decisions/tests/door_harness/mod.rs +++ b/crates/busbar-plane-decisions/tests/door_harness/mod.rs @@ -420,6 +420,34 @@ impl Door { got } + /// One crossing of `unit`'s piece that keeps nothing of the answer (so it allocates nothing + /// of its own): the bytes emitted. The answer must fit and be READY. + pub fn piece_bytes(&self, unit: u64, given: Given<'_>, bufs: &mut Bufs) -> usize { + let mut i: OnPieceIn = z(); + i.head = head::(slot::ON_PIECE, ticket_of(unit)); + i.unit = unit; + i.from = given.from; + i.flags = given.flags; + i.bytes = octets(given.bytes); + i.status_code = given.status; + i.attempt_no = given.attempt; + (i.reply_buf, i.reply_cap) = (bufs.reply.as_mut_ptr(), bufs.reply.len()); + (i.fields_buf, i.fields_cap) = (bufs.fields.as_mut_ptr(), bufs.fields.len()); + (i.arena_buf, i.arena_cap) = (bufs.arena.as_mut_ptr(), bufs.arena.len()); + (i.records_buf, i.records_cap) = (bufs.records.as_mut_ptr(), bufs.records.len()); + (i.units_buf, i.units_cap) = (bufs.units.as_mut_ptr(), bufs.units.len()); + let mut o: OnPieceOut = z(); + o.head = out_head::(); + let f = ops().on_piece.expect("on_piece"); + let ret = f( + self.instance, + std::ptr::from_ref(&i).cast(), + std::ptr::from_mut(&mut o).cast(), + ); + assert!(ret.outcome() == Outcome::Ready && o.more == 0); + usize::try_from(o.emitted).unwrap() + } + /// `refusal` of `unit` (`0`: none arrived) at `status` with the kernel's `text`; `arrived` /// says the refusal is the plane's own arrival's. Answers the outcome and the rendered body. pub fn refusal(&self, unit: u64, status: u32, text: &str, arrived: bool) -> (Outcome, Vec) { From 7ff46e3716239450adfe51b2f07fe737e6c30bd8 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:23:45 -0700 Subject: [PATCH 11/59] decisions: the second dialect, nanojev, on /api/evaluate, metered by execution.states (audit plane-decisions#3) THE DESIGN section 2, the decisions bullet: "a second dialect exists only where a provider's request spine really differs (NanoJev is the one, dialect `nanojev`: `/api/evaluate` is its second exact claim; a model's dialect is its override or its provider's protocol; a model of the wrong dialect gets that dialect's existing unknown-model refusal; it meters the upstream's own billing unit, counted from the answer already read (`execution.states`; an answer with no count refuses loudly; `$`, alone), with no extra egress". DECISIONS (BUSBAR-1.6.0.md:4960): "Plane gets its referenced providers' dialect fields at open (PlaneOpenIn list ...); the plane resolves model->dialect and selects itself; no kernel dialect check." :4987: "NanoJev unreadable count = same outcome as item 395 /usage/units". plane-decisions#3: the plane spoke `jev` alone, claimed POST /v1/systemone alone, and refused any other protocol. Now the tail states two dialects (`jev`, `nanojev`, each reached as a bearer), two op classes (`systemone`, `evaluate`) and two exact claims; a generation publishes each dialect's claim when it configures a model of that dialect, wearing that dialect for a refusal before arrive. Each model's dialect is its own `protocol`, else its provider's as the host states it at open (PlaneOpenIn::providers, kept for every refresh), `jev` for a provider the host states none for; a dialect the plane does not speak fails closed at validate, open and refresh, naming the two it does. A request routes among the models of its claim's dialect, so a model of the other dialect is that dialect's unknown model (404), and one model of the dialect is its default. A nanojev unit is sent to POST /api/evaluate, and its answer is read by its dialect's count: `/execution/states` is the decision count, and a success with no count, or one that is not a whole count, fails the unit loudly; jev's /usage members are not read on it. The PII pointer test reads each declared pointer by its segments: `/execution/states` passes through no `state` member. The jev rig gains a third boot (a nanojev provider and model beside the jev one) and three checks: nanojev.served, nanojev.wrong-dialect-404, nanojev.no-count-refused; the extra boots share one leg helper; the registry plan names what the rig serves (registry.toml and manifest.json). The binary's dialect probe in tests/cli_validate.rs reads the two dialects the decisions plane now states. RED: tests/nanojev.rs evaluate_is_claimed_with_a_nanojev_model, a_model_of_the_wrong_dialect_is_an_unknown_model, a_nanojev_unit_is_sent_to_evaluate_and_bills_its_states, an_unspoken_protocol_is_refused_at_validate (over the door's ABI); src/tests/driven.rs a_nanojev_answer_bills_its_execution_states, a_model_of_the_other_dialect_is_this_dialects_unknown_model; src/tests/plane_door.rs each_dialects_claim_is_published_with_a_model_of_it, a_models_dialect_is_its_override_or_its_providers_protocol; crates/busbar/tests/cli_validate.rs validate_ok_on_a_decisions_config_in_the_second_dialect (the binary). --- conformance/manifest.json | 2 +- conformance/registry.toml | 2 +- .../busbar-plane-decisions/src/codec/mod.rs | 8 +- crates/busbar-plane-decisions/src/config.rs | 35 ++- crates/busbar-plane-decisions/src/driven.rs | 181 ++++++++--- crates/busbar-plane-decisions/src/ops.rs | 15 +- .../busbar-plane-decisions/src/plane_door.rs | 190 ++++++++---- .../busbar-plane-decisions/src/tests/codec.rs | 8 +- .../src/tests/driven.rs | 145 +++++++-- .../busbar-plane-decisions/src/tests/ops.rs | 7 + .../src/tests/plane_door.rs | 72 ++++- .../tests/door_harness/mod.rs | 48 ++- .../busbar-plane-decisions/tests/nanojev.rs | 130 ++++++++ crates/busbar/tests/cli_validate.rs | 33 +- xtask/src/conformance_record/jev.rs | 289 +++++++++++++----- xtask/src/conformance_record/mod.rs | 12 +- xtask/tests/conformance_rigs.rs | 63 ++++ 17 files changed, 1014 insertions(+), 226 deletions(-) create mode 100644 crates/busbar-plane-decisions/tests/nanojev.rs diff --git a/conformance/manifest.json b/conformance/manifest.json index a154e21d4f..a7f59508cf 100644 --- a/conformance/manifest.json +++ b/conformance/manifest.json @@ -37,7 +37,7 @@ }, { "claim": null, - "plan": "plane battery + served POST /v1/systemone on a booted busbar (systemone only; GET /v1/models keeps its 1.5.5 bytes)", + "plan": "plane battery + served POST /v1/systemone (one model; two models with upstream_model) and POST /api/evaluate (nanojev) on booted busbars; GET /v1/models keeps its 1.5.5 bytes", "public": true, "reason": "not served: POST /v1/systemone answers 404, the absence code on this boot; the decisions plane is registered and configured but no route serves its operation (the kernel plane driver serves it)", "standard": "jev decision plane", diff --git a/conformance/registry.toml b/conformance/registry.toml index 5b5bdc7d9e..e15f07d5de 100644 --- a/conformance/registry.toml +++ b/conformance/registry.toml @@ -149,7 +149,7 @@ not_run_reason = "planned 1.6.0 — h2spec leg not yet green (MATRIX-RULING B-ti [[suite]] id = "jev" standard = "jev decision plane" -plan = "plane battery + served POST /v1/systemone on a booted busbar (systemone only; GET /v1/models keeps its 1.5.5 bytes)" +plan = "plane battery + served POST /v1/systemone (one model; two models with upstream_model) and POST /api/evaluate (nanojev) on booted busbars; GET /v1/models keeps its 1.5.5 bytes" tier = "spec-conformant" label = "jev" verdict = "conformance-verdict-jev" diff --git a/crates/busbar-plane-decisions/src/codec/mod.rs b/crates/busbar-plane-decisions/src/codec/mod.rs index 434df3936c..f8122c99c1 100644 --- a/crates/busbar-plane-decisions/src/codec/mod.rs +++ b/crates/busbar-plane-decisions/src/codec/mod.rs @@ -48,7 +48,12 @@ pub const PTR_USAGE_INPUT_TOKENS: &str = "/usage/input_tokens"; /// See [`PTR_USAGE_INPUT_TOKENS`]. pub const PTR_USAGE_OUTPUT_TOKENS: &str = "/usage/output_tokens"; -/// Every pointer this plane resolves in a RESPONSE body (`systemone` or `models`). Declared here +/// The pointer a SUCCESSFUL `nanojev` answer states the upstream's own billing unit at: the states +/// its evaluation ran (THE DESIGN section 2: "it meters the upstream's own billing unit, counted +/// from the answer already read (`execution.states`; an answer with no count refuses loudly"). +pub const PTR_EXECUTION_STATES: &str = "/execution/states"; + +/// Every pointer this plane resolves in a RESPONSE body (`systemone`, `evaluate` or `models`). Declared here /// once, so the "never resolves `/state` or `/answers`" claim is checkable by reading one list /// rather than auditing every call site. pub const RESPONSE_PTRS: &[&str] = &[ @@ -58,6 +63,7 @@ pub const RESPONSE_PTRS: &[&str] = &[ PTR_USAGE_UNITS, PTR_USAGE_INPUT_TOKENS, PTR_USAGE_OUTPUT_TOKENS, + PTR_EXECUTION_STATES, ]; /// The pointer a request names the model it is routed over at: its top-level `model` (THE DESIGN diff --git a/crates/busbar-plane-decisions/src/config.rs b/crates/busbar-plane-decisions/src/config.rs index 4c736e96ba..8f5e5d3856 100644 --- a/crates/busbar-plane-decisions/src/config.rs +++ b/crates/busbar-plane-decisions/src/config.rs @@ -89,6 +89,15 @@ pub struct DecisionsSection { /// transport-protocol string). pub const PROTOCOL: &str = "jev"; +/// THE SECOND WIRE DIALECT (THE DESIGN section 2, the decisions bullet): "a second dialect exists +/// only where a provider's request spine really differs (NanoJev is the one, dialect `nanojev`: +/// `/api/evaluate` is its second exact claim; a model's dialect is its override or its provider's +/// protocol ...)". +pub const PROTOCOL_NANOJEV: &str = "nanojev"; + +/// Every dialect this plane speaks, in its dialect-index order. +pub const PROTOCOLS: &[&str] = &[PROTOCOL, PROTOCOL_NANOJEV]; + /// THE DECLARING SECTION: the top-level config key whose presence declares this plane, beside /// `pools:`, `tools:`, `agents:` and `streams:`. The plane owns it and says so here; the composition /// root reads this constant for its registry declaration and spells no literal of its own. @@ -147,14 +156,26 @@ pub fn validate_cross_refs( } )); } - Some(protocol) if protocol != PROTOCOL => { + Some(protocol) + if !PROTOCOLS + .contains(&model_cfg.protocol.as_deref().unwrap_or(protocol.as_str())) => + { + let (key, protocol) = match model_cfg.protocol.as_deref() { + Some(own) => (format!("decisions.models.{model_name}.protocol"), own), + None => ( + format!( + "decisions.models.{model_name}.provider '{}'", + model_cfg.provider + ), + protocol.as_str(), + ), + }; errors.push(format!( - "decisions.models.{model_name}.provider '{}' resolves to protocol '{protocol}', \ - but the decision plane speaks only '{PROTOCOL}' (jev is a direct HTTP+JSON \ - passthrough with no translating IR — BUSBAR-1.6.0.md #51: an unknown dialect \ - FAILS CLOSED rather than boot). Point decisions.models.{model_name} at a \ - provider whose protocol resolves to '{PROTOCOL}', or remove this model.", - model_cfg.provider + "{key} resolves to protocol '{protocol}', but the decision plane speaks only \ + {} (BUSBAR-1.6.0.md #51: an unknown dialect FAILS CLOSED rather than boot). \ + Point decisions.models.{model_name} at a provider whose protocol resolves to \ + one of them, or remove this model.", + PROTOCOLS.join(", ") )); } Some(_) => {} diff --git a/crates/busbar-plane-decisions/src/driven.rs b/crates/busbar-plane-decisions/src/driven.rs index 61869a0f40..244aa1f9de 100644 --- a/crates/busbar-plane-decisions/src/driven.rs +++ b/crates/busbar-plane-decisions/src/driven.rs @@ -36,6 +36,16 @@ //! byte for byte as before. The top-level `model` ([`codec::PTR_MODEL`]) is the one request member //! this plane reads, and only to route ([`route_model`]) and splice ([`spliced`]). //! +//! THE SECOND DIALECT (THE DESIGN section 2): "NanoJev is the one, dialect `nanojev`: `/api/evaluate` +//! is its second exact claim; a model's dialect is its override or its provider's protocol; a model +//! of the wrong dialect gets that dialect's existing unknown-model refusal; it meters the upstream's +//! own billing unit, counted from the answer already read (`execution.states`; an answer with no +//! count refuses loudly ...), with no extra egress". Each dialect's claim is published when the +//! generation configures a model of that dialect ([`served_claims`]); a request routes among the +//! models of the dialect its claim is ([`route_model`]), so a model of the other dialect is that +//! dialect's unknown model (`404`). Its answer is read by its dialect's count +//! ([`FarEndReading::for_dialect`]): `/execution/states`, required. +//! //! WHAT THIS PLANE DOES NOT CLAIM: `GET /v1/models`. That path keeps its 1.5.5 bytes (the model //! list busbar already serves there), so [`tail::CLAIMS`] and [`tail::OP_CLASSES`] carry `systemone` alone and //! [`arrive`] answers the models operation as unclaimed. @@ -49,8 +59,8 @@ use busbar_contract::ids::{MeterClassId, OpClassId}; use crate::codec::{ - self, error_body, CONTENT_TYPE_JSON, EGRESS_SCHEME, FIELD_CONTENT_TYPE, PTR_USAGE_INPUT_TOKENS, - PTR_USAGE_OUTPUT_TOKENS, PTR_USAGE_UNITS, + self, error_body, CONTENT_TYPE_JSON, EGRESS_SCHEME, FIELD_CONTENT_TYPE, PTR_EXECUTION_STATES, + PTR_USAGE_INPUT_TOKENS, PTR_USAGE_OUTPUT_TOKENS, PTR_USAGE_UNITS, }; use crate::ops; @@ -65,8 +75,15 @@ pub mod tail { /// The sections this plane reads and does not own: every model names its provider there. pub const SECTIONS_CONSUMED: &[&str] = &["providers"]; - /// The one dialect this plane speaks. Dialect index 0 everywhere below. - pub const DIALECTS: &[&str] = &[config::PROTOCOL]; + /// The dialects this plane speaks, in index order: `jev` ([`DIALECT_JEV`]) and `nanojev` + /// ([`DIALECT_NANOJEV`]). + pub const DIALECTS: &[&str] = config::PROTOCOLS; + + /// The index of `jev` in [`DIALECTS`]. + pub const DIALECT_JEV: u32 = 0; + + /// The index of `nanojev` in [`DIALECTS`]. + pub const DIALECT_NANOJEV: u32 = 1; /// The outbound auth style a jev far end is reached under: the operator's credential as a /// bearer (the signed design: "auth = core gateway credential, plane returns a @@ -75,7 +92,10 @@ pub mod tail { /// Each dialect's default outbound auth style, `(dialect index, style)` (the design, "Outbound auth", step 2: /// a provider entry's `auth:`, else this; ARCHITECT Q-L1-AUTH (A), 2026-10-03). - pub const DIALECT_AUTH: &[(u32, &str)] = &[(0, OUTBOUND_STYLE)]; + pub const DIALECT_AUTH: &[(u32, &str)] = &[ + (DIALECT_JEV, OUTBOUND_STYLE), + (DIALECT_NANOJEV, OUTBOUND_STYLE), + ]; /// The resource granularity a grant names: a configured decision provider. pub const SCOPE_KINDS: &[&str] = &["decision_provider"]; @@ -89,8 +109,9 @@ pub mod tail { /// The record resource kind a registration is audited under: the scope kind. pub const AUDIT_KIND: &str = SCOPE_KINDS[0]; - /// The op classes this plane serves, in index order. `systemone` alone (see the module doc). - pub const OP_CLASSES: &[OpClassId] = &[ops::OP_SYSTEMONE]; + /// The op classes this plane serves, in index order: each dialect's decision call (see the + /// module doc). + pub const OP_CLASSES: &[OpClassId] = &[ops::OP_SYSTEMONE, ops::OP_EVALUATE]; /// The billable classes, in index order, each with its family: the decision count, the fee /// unit (also a class: the tail check holds `fee_units ⊆ billable_classes`), and the far end's @@ -142,19 +163,32 @@ pub mod tail { /// The plane names the style and never holds what is behind it. pub const NEEDS: &[(&str, &str)] = &[(claims::TRANSPORT, OUTBOUND_STYLE)]; - /// The claims the generation snapshot publishes, `(verb, target)`. - pub const CLAIMS: &[(&str, &str)] = &[("POST", ops::PATH_SYSTEMONE)]; + /// The claims a generation snapshot may publish, `(verb, target, dialect)`: each dialect's one + /// exact claim. + pub const CLAIMS: &[(&str, &str, u32)] = &[ + ("POST", ops::PATH_SYSTEMONE, DIALECT_JEV), + ("POST", ops::PATH_EVALUATE, DIALECT_NANOJEV), + ]; } -/// The claims a generation's snapshot publishes, given how many `decisions.models` it configures: -/// [`tail::CLAIMS`] for one or more, none for none (see the module doc's table). +/// The claims a generation's snapshot publishes, given the dialect of each model it configures: +/// each dialect's claim when at least one model is of it, none for none (see the module doc). #[must_use] -pub fn served_claims(models: usize) -> &'static [(&'static str, &'static str)] { - if models == 0 { - &[] - } else { - tail::CLAIMS - } +pub fn served_claims(dialects: &[u32]) -> Vec<(&'static str, &'static str, u32)> { + tail::CLAIMS + .iter() + .filter(|(_, _, dialect)| dialects.contains(dialect)) + .copied() + .collect() +} + +/// A protocol's index in [`tail::DIALECTS`], where this plane speaks it. +#[must_use] +pub fn dialect_of(protocol: &str) -> Option { + tail::DIALECTS + .iter() + .position(|d| *d == protocol) + .and_then(|i| u32::try_from(i).ok()) } /// Why a claimed request routes over no configured model (THE DESIGN section 2: "more than one and @@ -212,25 +246,29 @@ pub fn requested_model(body: &[u8]) -> Result>, .map_err(|_| Unrouted::NotAName) } -/// THE MODEL A CLAIMED REQUEST ROUTES OVER among `models` (the generation's configured names, each -/// with what this plane keeps of it): the one it names, else the only one configured. Answers the -/// configured name and its entry. +/// THE MODEL A CLAIMED REQUEST ROUTES OVER among the `models` its claim's dialect `serves` (the +/// generation's configured names, each with what this plane keeps of it): the one it names, else +/// the only one of that dialect configured. Answers the configured name and its entry. A model of +/// another dialect is this dialect's unknown model (THE DESIGN section 2: "a model of the wrong +/// dialect gets that dialect's existing unknown-model refusal"). /// /// # Errors /// /// [`Unrouted`]: several configured and none named, a `model` that is not a string, or a name none -/// of `models` has. +/// of the dialect's models has. pub fn route_model<'m, V>( body: &[u8], models: &'m std::collections::BTreeMap, + serves: impl Fn(&V) -> bool, ) -> Result<(&'m str, &'m V), Unrouted> { match requested_model(body)? { Some(name) => models .get_key_value(name.as_ref()) + .filter(|(_, v)| serves(v)) .map(|(k, v)| (k.as_str(), v)) .ok_or_else(|| Unrouted::Unknown(name.into_owned())), None => { - let mut all = models.iter(); + let mut all = models.iter().filter(|(_, v)| serves(v)); match (all.next(), all.next()) { (Some((k, v)), None) => Ok((k.as_str(), v)), _ => Err(Unrouted::Unnamed), @@ -286,19 +324,23 @@ pub struct Arrived { pub dialect: u32, } -/// Classify an arriving request by its verb and target. `None` for anything this plane does not -/// claim, including `GET /v1/models`. +/// Classify an arriving request by its verb and target: its operation, and the dialect its claim +/// is. `None` for anything this plane does not claim, including `GET /v1/models`. #[must_use] pub fn arrive(verb: &str, target: &str) -> Option { let row = ops::row_for(verb, target)?; let op_class = tail::OP_CLASSES.iter().position(|op| *op == row.op)?; + let dialect = tail::CLAIMS + .iter() + .find(|(v, t, _)| *t == target && v.eq_ignore_ascii_case(verb))? + .2; Some(Arrived { op: row.op, op_class: u32::try_from(op_class).ok()?, // A decision is billed to someone: the provider account is the deployment's, and the unit // is the caller's. principal: PrincipalNeed::Required, - dialect: 0, + dialect, }) } @@ -337,15 +379,20 @@ pub struct FarEndRequest<'a> { pub body: &'a [u8], } -/// Answer an ATTEMPT piece: the same request on every attempt, whichever member the kernel picked. -/// jev names no provider on the wire, so the member changes nothing in the request. Named -/// `attempt_request`, not `attempt`, so it is not a cross-plane re-spelling of llm/a2a's `attempt` -/// (structure-lint plane-dup; ARCHITECT ruling 2b 2026-10-04). +/// Answer an ATTEMPT piece of a unit of `dialect`: the same request on every attempt, whichever +/// member the kernel picked, at the dialect's own path. Neither dialect names a provider on the +/// wire, so the member changes nothing in the request. Named `attempt_request`, not `attempt`, so +/// it is not a cross-plane re-spelling of llm/a2a's `attempt` (structure-lint plane-dup; ARCHITECT +/// ruling 2b 2026-10-04). #[must_use] -pub fn attempt_request(caller_body: &[u8]) -> FarEndRequest<'_> { +pub fn attempt_request(dialect: u32, caller_body: &[u8]) -> FarEndRequest<'_> { + let target = tail::CLAIMS + .iter() + .find(|(_, _, d)| *d == dialect) + .map_or(ops::PATH_SYSTEMONE, |(_, t, _)| t); FarEndRequest { verb: "POST", - target: ops::PATH_SYSTEMONE, + target, fields: [(FIELD_CONTENT_TYPE, CONTENT_TYPE_JSON)], auth: EGRESS_SCHEME, body: caller_body, @@ -386,7 +433,7 @@ enum Member { } /// What a member of the answer is to the reading: `/error`, `/usage`, one of the three usage -/// counts (by its index in [`COUNTED`]), or none of them. +/// counts (by its index in its dialect's [`Usage::counted`]), or none of them. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] enum Slot { #[default] @@ -396,11 +443,34 @@ enum Slot { Count(usize), } -/// The usage members read, `(member name under /usage, its pointer)`, in [`Counts`]' order. -const COUNTED: [(&[u8], &str); 3] = [ - (b"units", PTR_USAGE_UNITS), - (b"input_tokens", PTR_USAGE_INPUT_TOKENS), - (b"output_tokens", PTR_USAGE_OUTPUT_TOKENS), +/// Where a dialect's answer states its counts: the top-level member holding them, and the members +/// under it, `(name, pointer)`, in [`Counts`]' order (decision, input tokens, output tokens). +/// Usage pointers are dialect data (THE DESIGN section 2). +struct Usage { + parent: &'static [u8], + counted: &'static [(&'static [u8], &'static str)], + /// The decision count is required: an answer with none refuses loudly. + required: bool, +} + +/// Each dialect's [`Usage`], in [`tail::DIALECTS`]' order: `jev` reads `/usage/units` (one decision +/// when absent) and its tokens; `nanojev` reads the upstream's own billing unit, +/// `/execution/states`, and an answer with no count refuses loudly. +const USAGE: [Usage; 2] = [ + Usage { + parent: b"usage", + counted: &[ + (b"units", PTR_USAGE_UNITS), + (b"input_tokens", PTR_USAGE_INPUT_TOKENS), + (b"output_tokens", PTR_USAGE_OUTPUT_TOKENS), + ], + required: false, + }, + Usage { + parent: b"execution", + counted: &[(b"states", PTR_EXECUTION_STATES)], + required: true, + }, ]; /// The longest member name compared ("output_tokens"), with room. @@ -412,7 +482,7 @@ const SCALAR_CAP: usize = 24; /// /// Every piece is relayed to the caller unchanged, as it arrives, and read as it passes: a scan of /// the JSON structure that keeps only what the money rule reads (whether `/error` is there and not -/// `null`, and the three `/usage/*` counts), in fixed space. No byte of the answer is kept, so an +/// `null`, and the dialect's counts: `/usage/*`, or `/execution/states`), in fixed space. No byte of the answer is kept, so an /// answer of any size is read in the same few bytes (THE DESIGN section 11.2: "no allocation and no /// blocking on the hot path"), and the pieces may split the answer anywhere. #[derive(Clone, Debug, Default, PartialEq, Eq)] @@ -438,17 +508,33 @@ pub struct FarEndReading { scalar_of: Option, /// `/error`: absent (`None`), `null` (`Some(false)`), or there (`Some(true)`). error: Option, - /// The three usage counts, in [`COUNTED`]' order. + /// The counts, in the dialect's [`Usage::counted`] order. members: [Member; 3], + /// The dialect the answer is in ([`USAGE`]'s index). + dialect: u32, } impl FarEndReading { - /// A reading with nothing received. + /// A `jev` reading with nothing received. #[must_use] pub fn new() -> Self { Self::default() } + /// A reading of an answer in `dialect` ([`tail::DIALECTS`]' index), nothing received. + #[must_use] + pub fn for_dialect(dialect: u32) -> Self { + Self { + dialect, + ..Self::default() + } + } + + /// The dialect's [`Usage`]. + fn usage(&self) -> &'static Usage { + USAGE.get(self.dialect as usize).unwrap_or(&USAGE[0]) + } + /// Take one far-end piece and return the bytes to relay to the caller: the piece itself. pub fn piece<'p>(&mut self, bytes: &'p [u8]) -> &'p [u8] { for &b in bytes { @@ -513,11 +599,12 @@ impl FarEndReading { } else if self.depth == 1 { match key { b"error" => Slot::Error, - b"usage" => Slot::Usage, + k if k == self.usage().parent => Slot::Usage, _ => Slot::Other, } } else { - COUNTED + self.usage() + .counted .iter() .position(|(name, _)| *name == key) .map_or(Slot::Other, Slot::Count) @@ -622,12 +709,14 @@ impl FarEndReading { return Ok([None; 4]); } let (_, members) = self.settled(); + let usage = self.usage(); let mut whole = [None; 3]; - for (i, member) in members.iter().enumerate() { - whole[i] = match member { + for (i, (_, pointer)) in usage.counted.iter().enumerate() { + whole[i] = match members[i] { + Member::Absent if i == 0 && usage.required => return Err(Unreadable(pointer)), Member::Absent => None, - Member::Whole(n) => Some(*n), - Member::Unreadable => return Err(Unreadable(COUNTED[i].1)), + Member::Whole(n) => Some(n), + Member::Unreadable => return Err(Unreadable(pointer)), }; } let reported = |class: u32, amount: u64| Units { diff --git a/crates/busbar-plane-decisions/src/ops.rs b/crates/busbar-plane-decisions/src/ops.rs index 7fa02b295a..947b2f9045 100644 --- a/crates/busbar-plane-decisions/src/ops.rs +++ b/crates/busbar-plane-decisions/src/ops.rs @@ -17,8 +17,12 @@ pub const OP_SYSTEMONE: OpClassId = OpClassId::new("systemone"); /// catalogue synthesis: this is typesafe's list, and only this plane may serve it. pub const OP_MODELS: OpClassId = OpClassId::new("models"); +/// `POST /api/evaluate` — the decision call of the second dialect, `nanojev` (THE DESIGN section 2: +/// "`/api/evaluate` is its second exact claim"). +pub const OP_EVALUATE: OpClassId = OpClassId::new("evaluate"); + /// Every operation class this plane's units can be, in declaration order. -pub const OP_CLASSES: &[OpClassId] = &[OP_SYSTEMONE, OP_MODELS]; +pub const OP_CLASSES: &[OpClassId] = &[OP_SYSTEMONE, OP_MODELS, OP_EVALUATE]; /// The path jev's decision call is mounted on. pub const PATH_SYSTEMONE: &str = "/v1/systemone"; @@ -26,6 +30,9 @@ pub const PATH_SYSTEMONE: &str = "/v1/systemone"; /// The path jev's model catalogue is mounted on. pub const PATH_MODELS: &str = "/v1/models"; +/// The path the `nanojev` dialect's decision call is mounted on. +pub const PATH_EVALUATE: &str = "/api/evaluate"; + /// One row of the method table: which `(verb, path)` is which operation, and whether the request /// carries a body this plane must wait for. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -55,6 +62,12 @@ pub const METHODS: &[MethodRow] = &[ path: PATH_MODELS, has_request_body: false, }, + MethodRow { + op: OP_EVALUATE, + verb: "POST", + path: PATH_EVALUATE, + has_request_body: true, + }, ]; /// Which row a `(verb, path)` pair names, where one does. diff --git a/crates/busbar-plane-decisions/src/plane_door.rs b/crates/busbar-plane-decisions/src/plane_door.rs index 5281688310..c56b83f354 100644 --- a/crates/busbar-plane-decisions/src/plane_door.rs +++ b/crates/busbar-plane-decisions/src/plane_door.rs @@ -17,11 +17,12 @@ //! * The Statement: the plane's key and version, `decisions:` declared and `providers:` consumed, //! its one outbound need, and the tail [`TAIL`] (every list read off [`crate::driven::tail`]). //! * `validate`, `open`, `refresh`: the settings blob read as the `decisions:` section -//! ([`crate::config::DecisionsSection`]); a generation's snapshot claims -//! [`crate::driven::served_claims`] for its model count, lists every model the section -//! configures (the kernel filters them by scope and `/v1/models` appends them), and binds no -//! audience (the operation is served on the plain data plane to a keyed caller). `retire` drops -//! a generation. +//! ([`crate::config::DecisionsSection`]), each model's dialect resolved ([`routes`]: its own +//! `protocol`, else its provider's as the host stated it at `open`); a generation's snapshot +//! claims [`crate::driven::served_claims`] for its models' dialects, lists every model the +//! section configures (the kernel filters them by scope and `/v1/models` appends them), and +//! binds no audience (the operation is served on the plain data plane to a keyed caller). +//! `retire` drops a generation. //! * `arrive`: [`crate::driven::arrive`], routed over the model the request names //! ([`crate::driven::route_model`]); an unclaimed request is refused at 404, and one that routes //! over no model at its [`crate::driven::Unrouted`] status. @@ -129,22 +130,32 @@ const SECTIONS: &[Section] = &[ }, ]; -const DIALECTS: &[AbiStr] = &[abi_str(tail::DIALECTS[0])]; +const DIALECTS: &[AbiStr] = &[abi_str(tail::DIALECTS[0]), abi_str(tail::DIALECTS[1])]; -/// The dialect's default outbound style ([`tail::DIALECT_AUTH`]). -const DIALECT_AUTH: &[DialectAuth] = &[DialectAuth { - dialect: tail::DIALECT_AUTH[0].0, - _reserved: 0, - style: abi_str(tail::DIALECT_AUTH[0].1), - params: Blob::ABSENT, -}]; +/// Each dialect's default outbound style ([`tail::DIALECT_AUTH`]). +const DIALECT_AUTH: &[DialectAuth] = &[dialect_auth(0), dialect_auth(1)]; + +/// [`tail::DIALECT_AUTH`]'s row `i`, on the ABI. +const fn dialect_auth(i: usize) -> DialectAuth { + DialectAuth { + dialect: tail::DIALECT_AUTH[i].0, + _reserved: 0, + style: abi_str(tail::DIALECT_AUTH[i].1), + params: Blob::ABSENT, + } +} const SCOPE_KINDS: &[AbiStr] = &[abi_str(tail::SCOPE_KINDS[0])]; -const OP_CLASSES: &[OpClass] = &[OpClass { - op: abi_str(tail::OP_CLASSES[0].as_str()), - name: abi_str(tail::OP_CLASSES[0].as_str()), -}]; +const OP_CLASSES: &[OpClass] = &[op_class(0), op_class(1)]; + +/// [`tail::OP_CLASSES`]' class `i`, on the ABI. +const fn op_class(i: usize) -> OpClass { + OpClass { + op: abi_str(tail::OP_CLASSES[i].as_str()), + name: abi_str(tail::OP_CLASSES[i].as_str()), + } +} const BILLABLE_CLASSES: &[BillableClass] = &[billable(0), billable(1), billable(2), billable(3)]; @@ -295,31 +306,83 @@ pub fn read_dealt(settings: &[u8]) -> Result { } } -/// ONE GENERATION'S SNAPSHOT for a section with `models` configured models: the claims -/// [`driven::served_claims`] serves, each an exact target over the claim's transport. It binds no -/// audience: the operation is served on the plain data plane to a keyed caller, as the llm plane's -/// is, and its refusals are this dialect's. +/// ONE GENERATION'S SNAPSHOT for a section whose models are of `dialects`: the claims +/// [`driven::served_claims`] serves, each an exact target over the claim's transport, wearing its +/// own dialect for a refusal before `arrive`. It binds no audience: the operation is served on the +/// plain data plane to a keyed caller, as the llm plane's is. #[must_use] -pub fn snapshot_spec(models: usize) -> SnapshotSpec { +pub fn snapshot_spec(dialects: &[u32]) -> SnapshotSpec { SnapshotSpec { - claims: driven::served_claims(models) - .iter() - .map(|(verb, target)| ClaimSpec::new(verb, target, claims::TRANSPORT, CLAIM_EXACT)) + claims: driven::served_claims(dialects) + .into_iter() + .map(|(verb, target, dialect)| ClaimSpec { + refusal_dialect: u16::try_from(dialect).unwrap_or(0), + ..ClaimSpec::new(verb, target, claims::TRANSPORT, CLAIM_EXACT) + }) .collect(), ..SnapshotSpec::default() } } -/// ONE GENERATION'S SNAPSHOT for `section`: [`snapshot_spec`]'s claims for its model count, and -/// every model it configures LISTED (THE DESIGN section 2: all configured decisions models are -/// listed, scope-filtered by the kernel; `/v1/models` appends them), in ascending name order. +/// What a claimed arrival routes over: a model's dialect, and its `upstream_model`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Routed { + /// Its index in [`tail::DIALECTS`]. + pub dialect: u32, + /// Its `upstream_model`. + pub upstream: Option, +} + +/// EVERY MODEL'S ROUTE under `section`: its dialect, its own `protocol` override or else its +/// provider's protocol (`protocols`, the provider facts the host handed at `open`; THE DESIGN +/// section 2: "a model's dialect is its override or its provider's protocol"), `jev` for a provider +/// the host stated no protocol for. +/// +/// # Errors +/// +/// A model whose dialect is none this plane speaks, named with the dialects it does (#51: an +/// unknown dialect fails closed). +pub fn routes( + section: &DecisionsSection, + protocols: &BTreeMap, +) -> Result, String> { + section + .models + .iter() + .map(|(name, model)| { + let protocol = model + .protocol + .as_deref() + .or_else(|| protocols.get(&model.provider).map(String::as_str)) + .unwrap_or(tail::DIALECTS[0]); + let dialect = driven::dialect_of(protocol).ok_or_else(|| { + format!( + "decisions.models.{name} resolves to protocol '{protocol}', but the decisions \ + plane speaks only: {}.", + tail::DIALECTS.join(", ") + ) + })?; + Ok(( + name.clone(), + Routed { + dialect, + upstream: model.upstream_model.clone(), + }, + )) + }) + .collect() +} + +/// ONE GENERATION'S SNAPSHOT for `section`'s `routes`: [`snapshot_spec`]'s claims for its models' +/// dialects, and every model it configures LISTED (THE DESIGN section 2: all configured decisions +/// models are listed, scope-filtered by the kernel; `/v1/models` appends them), in ascending name +/// order. #[must_use] -pub fn generation_spec(section: &DecisionsSection) -> SnapshotSpec { - let mut listed: Vec = section.models.keys().cloned().collect(); - listed.sort(); +pub fn generation_spec(routes: &BTreeMap) -> SnapshotSpec { + let dialects: Vec = routes.values().map(|r| r.dialect).collect(); SnapshotSpec { - listed, - ..snapshot_spec(section.models.len()) + listed: routes.keys().cloned().collect(), + ..snapshot_spec(&dialects) } } @@ -358,24 +421,18 @@ pub struct DecisionsDoor { generations: Generations, units: Keyed, /// The models a claimed arrival routes over, directly, by name: every model the latest - /// generation configures, each with its `upstream_model`. - models: Keyed>, + /// generation configures, each with its dialect and `upstream_model`. + models: Keyed, /// How many arrivals the instance has kept: each unit's [`Unit::seq`]. arrivals: AtomicU64, + /// Each provider's protocol, as the host stated it at `open` (`PlaneOpenIn::providers`). + protocols: BTreeMap, } impl DecisionsDoor { - /// Route claimed arrivals over `section`'s models. - fn route_over(&self, section: &DecisionsSection) { - self.models.with_all(|models| { - models.clear(); - models.extend( - section - .models - .iter() - .map(|(name, model)| (name.clone(), model.upstream_model.clone())), - ); - }); + /// Route claimed arrivals over `routes`. + fn route_over(&self, routes: BTreeMap) { + self.models.with_all(|models| *models = routes); } } @@ -399,7 +456,11 @@ macro_rules! slot { slot!( /// `validate`: the settings read as the section, or refused in the grammar's words. Validate, ValidateIn, OutHead, |_, input, mut out| { - match read_dealt(input.field(|i| &i.settings).bytes()) { + // A model's own `protocol` must be a dialect this plane speaks; its provider's is judged + // by the kernel against the dialects the Statement states. + match read_dealt(input.field(|i| &i.settings).bytes()) + .and_then(|section| routes(§ion, &BTreeMap::new())) + { Ok(_) => Outcome::Ready, Err(words) => out.fail(Refusal::refused(words)), } @@ -414,14 +475,29 @@ slot!( Ok(section) => section, Err(words) => return open_failed(open, &mut out, |o| &o.open.err_len, &words), }; + let protocols: BTreeMap = input + .providers() + .iter() + .filter_map(|p| { + Some(( + p.field(|p| &p.name).as_str().ok()?.to_string(), + p.field(|p| &p.protocol).as_str().ok()?.to_string(), + )) + }) + .collect(); + let routed = match routes(§ion, &protocols) { + Ok(routed) => routed, + Err(words) => return open_failed(open, &mut out, |o| &o.open.err_len, &words), + }; let plane = DecisionsDoor { generations: Generations::new(), units: Keyed::new(), models: Keyed::new(), arrivals: AtomicU64::new(0), + protocols, }; - plane.route_over(§ion); - let spec = generation_spec(§ion); + let spec = generation_spec(&routed); + plane.route_over(routed); out.publish(|o| &o.snapshot, &plane.generations, open.generation, &spec); instance.open(plane); Outcome::Ready @@ -438,8 +514,12 @@ slot!( Ok(section) => section, Err(words) => return out.fail(Refusal::refused(words)), }; - let spec = generation_spec(§ion); - plane.route_over(§ion); + let routed = match routes(§ion, &plane.protocols) { + Ok(routed) => routed, + Err(words) => return out.fail(Refusal::refused(words)), + }; + let spec = generation_spec(&routed); + plane.route_over(routed); out.publish(|o| &o.snapshot, &plane.generations, input.generation, &spec); Outcome::Ready } @@ -526,8 +606,8 @@ slot!( if let Some(plane) = instance.get() { let body = input.field(|i| &i.body).bytes(); let routed = plane.models.with_all(|models| { - driven::route_model(body, models) - .map(|(name, upstream)| (name.to_string(), upstream.clone())) + driven::route_model(body, models, |r| r.dialect == arrived.dialect) + .map(|(name, routed)| (name.to_string(), routed.upstream.clone())) }); let upstream = match routed { Ok((model, upstream)) => { @@ -650,7 +730,8 @@ fn attempt_piece( input: Lent<'_, OnPieceIn>, out: &mut Out<'_, OnPieceOut>, ) -> Outcome { - let request = driven::attempt_request(&[]); + let dialect = input.get().dialect; + let request = driven::attempt_request(dialect, &[]); let (mut fields, units, mut arena) = (input.fields_buf(), input.units_buf(), input.arena_buf()); let verb = arena.span(request.verb.as_bytes()); let target = arena.span(request.target.as_bytes()); @@ -676,6 +757,7 @@ fn attempt_piece( ticket: unit.ticket, seq: unit.seq, owed: std::mem::take(&mut unit.owed), + reading: FarEndReading::for_dialect(dialect), ..Unit::default() }; unit.owed.clear(); diff --git a/crates/busbar-plane-decisions/src/tests/codec.rs b/crates/busbar-plane-decisions/src/tests/codec.rs index 2ea0874023..d5943b1e50 100644 --- a/crates/busbar-plane-decisions/src/tests/codec.rs +++ b/crates/busbar-plane-decisions/src/tests/codec.rs @@ -32,11 +32,15 @@ fn request_ptrs_is_the_model_alone() { ); } +/// No declared pointer passes through a `state` or `answers` member (a member merely spelled with +/// those letters, as NanoJev's `/execution/states`, is another member). #[test] fn response_ptrs_never_names_state_or_answers() { for p in RESPONSE_PTRS { - assert!(!p.contains("state")); - assert!(!p.contains("answers")); + assert!( + !p.split('/').any(|seg| seg == "state" || seg == "answers"), + "{p}" + ); } } diff --git a/crates/busbar-plane-decisions/src/tests/driven.rs b/crates/busbar-plane-decisions/src/tests/driven.rs index b85820039a..fe073ef39c 100644 --- a/crates/busbar-plane-decisions/src/tests/driven.rs +++ b/crates/busbar-plane-decisions/src/tests/driven.rs @@ -9,12 +9,16 @@ fn systemone_arrives_as_the_first_op_class_with_a_principal() { assert_eq!(tail::OP_CLASSES[a.op_class as usize], ops::OP_SYSTEMONE); assert_eq!(a.principal, PrincipalNeed::Required); assert_eq!(tail::DIALECTS[a.dialect as usize], "jev"); + let e = arrive("POST", ops::PATH_EVALUATE).expect("evaluate is claimed"); + assert_eq!(e.op, ops::OP_EVALUATE); + assert_eq!(tail::OP_CLASSES[e.op_class as usize], ops::OP_EVALUATE); + assert_eq!(tail::DIALECTS[e.dialect as usize], "nanojev"); } #[test] fn models_is_not_claimed() { assert_eq!(arrive("GET", ops::PATH_MODELS), None); - assert!(!tail::CLAIMS.iter().any(|(_, t)| *t == ops::PATH_MODELS)); + assert!(!tail::CLAIMS.iter().any(|(_, t, _)| *t == ops::PATH_MODELS)); assert!(!tail::OP_CLASSES.contains(&ops::OP_MODELS)); } @@ -26,8 +30,10 @@ fn an_unknown_request_is_not_claimed() { #[test] fn every_claim_arrives() { - for (verb, target) in tail::CLAIMS { - assert!(arrive(verb, target).is_some(), "{verb} {target}"); + for (verb, target, dialect) in tail::CLAIMS { + let a = arrive(verb, target).unwrap_or_else(|| panic!("{verb} {target}")); + assert_eq!(a.dialect, *dialect, "{verb} {target}"); + assert_eq!(attempt_request(*dialect, b"{}").target, *target); } } @@ -53,7 +59,8 @@ fn the_tail_names_the_decision_class_at_its_index() { assert_eq!(fee_family, tail::FEE_FAMILY); assert_eq!(tail::SECTION_DECLARING, crate::config::SECTION); assert_eq!(tail::NEEDS, &[(crate::claims::TRANSPORT, "bearer")]); - assert_eq!(tail::DIALECT_AUTH, &[(0, "bearer")]); + assert_eq!(tail::DIALECT_AUTH, &[(0, "bearer"), (1, "bearer")]); + assert_eq!(tail::DIALECTS, &["jev", "nanojev"]); } #[test] @@ -66,7 +73,7 @@ fn the_caller_body_is_kept_and_an_empty_one_is_refused() { #[test] fn an_attempt_forwards_the_caller_body_unchanged() { let body = br#"{"state":{"session":"s"},"context":{}}"#; - let r = attempt_request(body); + let r = attempt_request(tail::DIALECT_JEV, body); assert_eq!(r.verb, "POST"); assert_eq!(r.target, "/v1/systemone"); assert_eq!(r.fields, [("content-type", &b"application/json"[..])]); @@ -253,10 +260,15 @@ fn a_refusal_carries_the_kernel_text_in_the_dialect_shape() { /// nothing, and the kernel's unclaimed-route bytes stand. #[test] fn one_or_more_models_mount_the_claim() { - assert!(served_claims(0).is_empty()); - assert_eq!(served_claims(1), tail::CLAIMS); - assert_eq!(served_claims(2), tail::CLAIMS); - assert_eq!(served_claims(7), tail::CLAIMS); + let systemone = ("POST", ops::PATH_SYSTEMONE, tail::DIALECT_JEV); + let evaluate = ("POST", ops::PATH_EVALUATE, tail::DIALECT_NANOJEV); + assert!(served_claims(&[]).is_empty()); + assert_eq!(served_claims(&[0]), vec![systemone]); + assert_eq!(served_claims(&[0, 0]), vec![systemone]); + assert_eq!(served_claims(&[1]), vec![evaluate]); + assert_eq!(served_claims(&[1, 0, 1]), vec![systemone, evaluate]); + assert_eq!(dialect_of("nanojev"), Some(tail::DIALECT_NANOJEV)); + assert_eq!(dialect_of("anthropic"), None); } fn models(names: &[&str]) -> std::collections::BTreeMap { @@ -268,45 +280,54 @@ fn models(names: &[&str]) -> std::collections::BTreeMap { #[test] fn a_request_routes_over_the_model_it_names() { let two = models(&["a", "b"]); - assert_eq!(route_model(br#"{"model":"b"}"#, &two).map(|r| r.0), Ok("b")); assert_eq!( - route_model(br#"{"model":"\u0061"}"#, &two).map(|r| r.0), + route_model(br#"{"model":"b"}"#, &two, |_| true).map(|r| r.0), + Ok("b") + ); + assert_eq!( + route_model(br#"{"model":"\u0061"}"#, &two, |_| true).map(|r| r.0), Ok("a"), "an escaped name is the name it spells" ); assert_eq!( - route_model(br#"{"state":{}}"#, &two), + route_model(br#"{"state":{}}"#, &two, |_| true), Err(Unrouted::Unnamed) ); assert_eq!( - route_model(br#"{"model":"zz"}"#, &two), + route_model(br#"{"model":"zz"}"#, &two, |_| true), Err(Unrouted::Unknown("zz".to_string())) ); assert_eq!( - route_model(br#"{"model":null}"#, &two), + route_model(br#"{"model":null}"#, &two, |_| true), Err(Unrouted::NotAName) ); assert_eq!( - route_model(br#"{"model":["a"]}"#, &two), + route_model(br#"{"model":["a"]}"#, &two, |_| true), Err(Unrouted::NotAName) ); // A nested `model` is not the request's. assert_eq!( - route_model(br#"{"state":{"model":"a"}}"#, &two), + route_model(br#"{"state":{"model":"a"}}"#, &two, |_| true), Err(Unrouted::Unnamed) ); let one = models(&["jev"]); assert_eq!( - route_model(br#"{"state":{}}"#, &one).map(|r| r.0), + route_model(br#"{"state":{}}"#, &one, |_| true).map(|r| r.0), + Ok("jev") + ); + assert_eq!( + route_model(b"not json", &one, |_| true).map(|r| r.0), Ok("jev") ); - assert_eq!(route_model(b"not json", &one).map(|r| r.0), Ok("jev")); assert_eq!( - route_model(br#"{"model":"other"}"#, &one), + route_model(br#"{"model":"other"}"#, &one, |_| true), Err(Unrouted::Unknown("other".to_string())) ); - assert_eq!(route_model(br#"{}"#, &models(&[])), Err(Unrouted::Unnamed)); + assert_eq!( + route_model(br#"{}"#, &models(&[]), |_| true), + Err(Unrouted::Unnamed) + ); assert_eq!( (Unrouted::Unnamed.status(), Unrouted::NotAName.status()), @@ -476,3 +497,87 @@ fn a_large_answer_is_read_without_keeping_it() { Ok(Some(4)) ); } + +/// The counts of a `nanojev` answer, `(class, amount)`. +fn nanojev_billed(status: u32, body: &[u8]) -> Result, Unreadable> { + let mut reading = FarEndReading::for_dialect(tail::DIALECT_NANOJEV); + reading.piece(body); + reading.counts(Some(status)).map(|c| { + c.into_iter() + .flatten() + .map(|u| (u.class, u.amount)) + .collect() + }) +} + +/// RED (audit plane-decisions#3): THE DESIGN section 2, NanoJev "meters the upstream's own billing +/// unit, counted from the answer already read (`execution.states`; an answer with no count refuses +/// loudly". Its answer is read by its own pointer, never jev's. +#[test] +fn a_nanojev_answer_bills_its_execution_states() { + assert_eq!( + nanojev_billed(200, br#"{"execution":{"states":3},"result":{}}"#), + Ok(vec![(DECISION, 3), (FEE, 1)]) + ); + assert_eq!( + nanojev_billed( + 200, + br#"{"usage":{"units":9,"input_tokens":4},"execution":{"states":1}}"# + ), + Ok(vec![(DECISION, 1), (FEE, 1)]), + "jev's usage members are not nanojev's" + ); + for missing in [ + &br#"{"result":{}}"#[..], + br#"{"execution":{}}"#, + br#"{"usage":{"units":2}}"#, + ] { + assert_eq!( + nanojev_billed(200, missing), + Err(Unreadable(crate::codec::PTR_EXECUTION_STATES)), + "{}", + String::from_utf8_lossy(missing) + ); + } + assert_eq!( + nanojev_billed(200, br#"{"execution":{"states":2.5}}"#), + Err(Unreadable(crate::codec::PTR_EXECUTION_STATES)) + ); + assert_eq!( + nanojev_billed(500, br#"{"result":{}}"#), + Ok(vec![]), + "a failure bills nothing" + ); + // Wherever the pieces split it. + let body = br#"{"execution" : {"trace":[{"states":99}], "states" : 12}}"#; + for at in 0..=body.len() { + let mut reading = FarEndReading::for_dialect(tail::DIALECT_NANOJEV); + reading.piece(&body[..at]); + reading.piece(&body[at..]); + assert_eq!( + reading.counts(Some(200)).map(|c| c[0].map(|u| u.amount)), + Ok(Some(12)), + "split at {at}" + ); + } +} + +/// RED (audit plane-decisions#3): a request routes among its claim's dialect's models; a model of +/// the other dialect is this dialect's unknown model, and one model of the dialect is its default. +#[test] +fn a_model_of_the_other_dialect_is_this_dialects_unknown_model() { + let models: std::collections::BTreeMap = + [("j".to_string(), 0), ("n".to_string(), 1)].into(); + let jev = |d: &u32| *d == tail::DIALECT_JEV; + let nano = |d: &u32| *d == tail::DIALECT_NANOJEV; + assert_eq!( + route_model(br#"{"model":"n"}"#, &models, jev), + Err(Unrouted::Unknown("n".to_string())) + ); + assert_eq!( + route_model(br#"{"model":"n"}"#, &models, nano).map(|r| r.0), + Ok("n") + ); + assert_eq!(route_model(br#"{}"#, &models, jev).map(|r| r.0), Ok("j")); + assert_eq!(route_model(br#"{}"#, &models, nano).map(|r| r.0), Ok("n")); +} diff --git a/crates/busbar-plane-decisions/src/tests/ops.rs b/crates/busbar-plane-decisions/src/tests/ops.rs index dc9d8e2f06..d99737900a 100644 --- a/crates/busbar-plane-decisions/src/tests/ops.rs +++ b/crates/busbar-plane-decisions/src/tests/ops.rs @@ -38,3 +38,10 @@ fn op_classes_matches_the_method_table() { assert!(OP_CLASSES.contains(&row.op)); } } + +#[test] +fn row_for_matches_evaluate() { + let row = row_for("POST", PATH_EVALUATE).expect("evaluate is declared"); + assert_eq!(row.op, OP_EVALUATE); + assert!(row.has_request_body); +} diff --git a/crates/busbar-plane-decisions/src/tests/plane_door.rs b/crates/busbar-plane-decisions/src/tests/plane_door.rs index 6765e863ce..d8a77eb70d 100644 --- a/crates/busbar-plane-decisions/src/tests/plane_door.rs +++ b/crates/busbar-plane-decisions/src/tests/plane_door.rs @@ -1,6 +1,61 @@ use super::*; use crate::ops; +/// `section`'s routes with no provider facts stated. +fn routed(section: &DecisionsSection) -> BTreeMap { + routes(section, &BTreeMap::new()).expect("every model's dialect is spoken") +} + +/// RED (audit plane-decisions#3): each dialect's claim is published when a model of it is +/// configured, and wears its dialect for a refusal before `arrive`. +#[test] +fn each_dialects_claim_is_published_with_a_model_of_it() { + let jev = ClaimSpec::new("POST", ops::PATH_SYSTEMONE, claims::TRANSPORT, CLAIM_EXACT); + let evaluate = ClaimSpec { + refusal_dialect: 1, + ..ClaimSpec::new("POST", ops::PATH_EVALUATE, claims::TRANSPORT, CLAIM_EXACT) + }; + assert_eq!( + snapshot_spec(&[tail::DIALECT_NANOJEV]).claims, + vec![evaluate.clone()] + ); + assert_eq!( + snapshot_spec(&[tail::DIALECT_NANOJEV, tail::DIALECT_JEV]).claims, + vec![jev.clone(), evaluate] + ); + assert_eq!(snapshot_spec(&[tail::DIALECT_JEV]).claims, vec![jev]); +} + +/// RED (audit plane-decisions#3): THE DESIGN section 2, "a model's dialect is its override or its +/// provider's protocol"; a dialect the plane does not speak fails closed, naming those it does. +#[test] +fn a_models_dialect_is_its_override_or_its_providers_protocol() { + let section = read_settings( + br#"{"models":{"o":{"provider":"p","protocol":"nanojev"},"f":{"provider":"nano"},"j":{"provider":"p"}}}"#, + ) + .expect("the section reads"); + let facts = BTreeMap::from([ + ("nano".to_string(), "nanojev".to_string()), + ("p".to_string(), "jev".to_string()), + ]); + let r = routes(§ion, &facts).expect("every dialect is spoken"); + assert_eq!( + (r["o"].dialect, r["f"].dialect, r["j"].dialect), + ( + tail::DIALECT_NANOJEV, + tail::DIALECT_NANOJEV, + tail::DIALECT_JEV + ) + ); + let bogus = read_settings(br#"{"models":{"m":{"provider":"p","protocol":"bogus"}}}"#) + .expect("the section reads"); + let words = routes(&bogus, &facts).expect_err("an unspoken dialect fails closed"); + assert!( + words.contains("bogus") && words.contains("jev, nanojev"), + "{words}" + ); +} + #[test] fn the_statement_names_the_crate_version() { let manifest = include_str!("../../Cargo.toml"); @@ -31,7 +86,7 @@ fn a_member_the_section_does_not_declare_is_refused_in_the_grammars_words() { #[test] fn one_or_more_models_claim_systemone_and_none_claims_nothing() { - let one = snapshot_spec(1); + let one = snapshot_spec(&[tail::DIALECT_JEV]); assert_eq!( one.claims, vec![ClaimSpec::new( @@ -45,11 +100,12 @@ fn one_or_more_models_claim_systemone_and_none_claims_nothing() { // Served on the plain data plane to a keyed caller: no audience, no protected-resource document. assert!(one.audience.is_none() && one.resource_metadata.is_none()); assert!( - snapshot_spec(0).claims.is_empty(), + snapshot_spec(&[]).claims.is_empty(), "no model claims nothing" ); for models in [2, 3] { - assert_eq!(snapshot_spec(models).claims, one.claims, "{models} models"); + let jev = vec![tail::DIALECT_JEV; models]; + assert_eq!(snapshot_spec(&jev).claims, one.claims, "{models} models"); } } @@ -58,18 +114,18 @@ fn a_generation_lists_every_configured_model_in_name_order() { let two = read_settings(br#"{"models":{"b":{"provider":"typesafe"},"a":{"provider":"typesafe"}}}"#) .expect("the section reads"); - let spec = generation_spec(&two); + let spec = generation_spec(&routed(&two)); assert_eq!(spec.listed, vec!["a".to_string(), "b".to_string()]); - assert_eq!(spec.claims, snapshot_spec(2).claims); + assert_eq!(spec.claims, snapshot_spec(&[0, 0]).claims); let one = read_settings(br#"{"models":{"jev":{"provider":"typesafe"}}}"#).expect("the section reads"); - let spec = generation_spec(&one); + let spec = generation_spec(&routed(&one)); assert_eq!(spec.listed, vec!["jev".to_string()]); - assert_eq!(spec.claims, snapshot_spec(1).claims); + assert_eq!(spec.claims, snapshot_spec(&[0]).claims); let empty = read_settings(b"").expect("an empty blob reads"); - assert!(generation_spec(&empty).listed.is_empty()); + assert!(generation_spec(&routed(&empty)).listed.is_empty()); } #[test] diff --git a/crates/busbar-plane-decisions/tests/door_harness/mod.rs b/crates/busbar-plane-decisions/tests/door_harness/mod.rs index 9a378e82bb..a99d20c939 100644 --- a/crates/busbar-plane-decisions/tests/door_harness/mod.rs +++ b/crates/busbar-plane-decisions/tests/door_harness/mod.rs @@ -13,14 +13,14 @@ use busbar_contract::abi::mechanism::call::{ AbiStr, Blob, Field, InHead, OutHead, Outcome, Span, BLOB_OCTETS, }; use busbar_contract::abi::mechanism::lifecycle::{ - slot as life, CancelIn, CancelOut, OpenIn, OpenOut, OpsHead, RefreshIn, + slot as life, CancelIn, CancelOut, OpenIn, OpenOut, OpsHead, RefreshIn, ValidateIn, }; use busbar_contract::abi::mechanism::ticket::{HostCtx, HostTables, Ticket}; use busbar_contract::abi::plane::{ slot, ArriveIn, ArriveOut, OnPieceIn, OnPieceOut, Ops, OutField, PlaneCancelIn, PlaneCancelOut, - PlaneOpenIn, PlaneOpenOut, PlaneRefreshOut, PlaneSnapshot, RecordWrite, RefusalIn, RefusalOut, - UnitCount, EMIT_DONE, EMIT_TO_FAR_END, FROM_CALLER, FROM_FAR_END, FROM_KERNEL, - PIECE_HAS_STATUS, PIECE_LAST, REFUSAL_ARRIVE, REFUSAL_KERNEL, + PlaneOpenIn, PlaneOpenOut, PlaneRefreshOut, PlaneSnapshot, ProviderFacts, RecordWrite, + RefusalIn, RefusalOut, UnitCount, EMIT_DONE, EMIT_TO_FAR_END, FROM_CALLER, FROM_FAR_END, + FROM_KERNEL, PIECE_HAS_STATUS, PIECE_LAST, REFUSAL_ARRIVE, REFUSAL_KERNEL, }; use busbar_plane_decisions::plane_door::door; @@ -128,6 +128,19 @@ pub struct Door { /// Open the door over `settings` (the `decisions:` section, as written). pub fn open(settings: &str) -> Door { + open_with(settings, &[]) +} + +/// Open the door over `settings`, the host stating each referenced provider's `(name, protocol)`. +pub fn open_with(settings: &str, providers: &[(&str, &str)]) -> Door { + let facts: Vec = providers + .iter() + .map(|(name, protocol)| ProviderFacts { + name: str_of(name.as_bytes()), + protocol: str_of(protocol.as_bytes()), + error_map: Blob::ABSENT, + }) + .collect(); let mut err = [0_u8; 1024]; let mut i: PlaneOpenIn = z(); i.open = OpenIn { @@ -139,6 +152,7 @@ pub fn open(settings: &str) -> Door { err_cap: err.len(), ..z() }; + (i.providers, i.providers_len) = (facts.as_ptr(), facts.len()); let mut o: PlaneOpenOut = z(); o.open = OpenOut { head: out_head::(), @@ -161,6 +175,24 @@ pub fn open(settings: &str) -> Door { } } +/// `validate` of the dealt blob `{decisions: }`: the outcome and its words. +pub fn validate(settings: &str) -> (Outcome, String) { + let dealt = format!("{{\"decisions\":{settings}}}"); + let mut err = [0_u8; 1024]; + let mut i: ValidateIn = z(); + i.head = head::(life::VALIDATE, Ticket::NONE); + i.settings = octets(dealt.as_bytes()); + (i.err_buf, i.err_cap) = (err.as_mut_ptr(), err.len()); + let mut o: OutHead = out_head::(); + let f = lifecycle().validate.expect("validate"); + let ret = f( + std::ptr::null_mut(), + std::ptr::from_ref(&i).cast(), + std::ptr::from_mut(&mut o).cast(), + ); + (ret.outcome(), read(o.error)) +} + /// What `arrive` answered. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Arrived { @@ -206,6 +238,8 @@ pub struct Given<'a> { pub status: u32, pub attempt: u32, pub bytes: &'a [u8], + /// The dialect `arrive` answered, carried on every piece. + pub dialect: u32, } impl<'a> Given<'a> { @@ -217,6 +251,7 @@ impl<'a> Given<'a> { status: 0, attempt: n, bytes: b"", + dialect: 0, } } @@ -228,6 +263,7 @@ impl<'a> Given<'a> { status: 0, attempt: 0, bytes, + dialect: 0, } } @@ -239,6 +275,7 @@ impl<'a> Given<'a> { status, attempt: 0, bytes, + dialect: 0, } } @@ -259,6 +296,7 @@ impl<'a> Given<'a> { status, attempt: 0, bytes, + dialect: 0, } } } @@ -362,6 +400,7 @@ impl Door { i.head = head::(slot::ON_PIECE, ticket); i.unit = unit; i.from = given.from; + i.dialect = given.dialect; if first { i.flags = given.flags; i.bytes = octets(given.bytes); @@ -427,6 +466,7 @@ impl Door { i.head = head::(slot::ON_PIECE, ticket_of(unit)); i.unit = unit; i.from = given.from; + i.dialect = given.dialect; i.flags = given.flags; i.bytes = octets(given.bytes); i.status_code = given.status; diff --git a/crates/busbar-plane-decisions/tests/nanojev.rs b/crates/busbar-plane-decisions/tests/nanojev.rs new file mode 100644 index 0000000000..6f188f5905 --- /dev/null +++ b/crates/busbar-plane-decisions/tests/nanojev.rs @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! THE SECOND DIALECT, `nanojev`, DRIVEN OVER THE DOOR'S ABI (THE DESIGN section 2, the decisions +//! bullet): "NanoJev is the one, dialect `nanojev`: `/api/evaluate` is its second exact claim; a +//! model's dialect is its override or its provider's protocol; a model of the wrong dialect gets +//! that dialect's existing unknown-model refusal; it meters the upstream's own billing unit, +//! counted from the answer already read (`execution.states`; an answer with no count refuses +//! loudly ...), with no extra egress". + +#![allow(unsafe_code)] + +mod door_harness; + +use busbar_contract::abi::mechanism::call::Outcome; +use busbar_contract::abi::plane::{ROUTE_DIRECT, UNITS_REPORTED}; +use door_harness::{open, open_with, validate, Bufs, Given}; + +const SYSTEMONE: (&str, &str) = ("POST", "/v1/systemone"); +const EVALUATE: (&str, &str) = ("POST", "/api/evaluate"); + +/// One model of each dialect: `nano` by its own `protocol`, `jev` by its provider's. +const BOTH: &str = + r#"{"models":{"nano":{"provider":"np","protocol":"nanojev"},"jev":{"provider":"typesafe"}}}"#; + +fn claims(c: &[(&str, &str)]) -> Vec<(String, String)> { + c.iter() + .map(|(v, t)| ((*v).to_string(), (*t).to_string())) + .collect() +} + +/// RED (audit plane-decisions#3): `/api/evaluate` is claimed when a `nanojev` model is configured, +/// by its own `protocol` or its provider's as the host stated it at `open`, and only then. +#[test] +fn evaluate_is_claimed_with_a_nanojev_model() { + assert_eq!(open(BOTH).claims, claims(&[SYSTEMONE, EVALUATE])); + let by_provider = open_with( + r#"{"models":{"n":{"provider":"np"}}}"#, + &[("np", "nanojev")], + ); + assert_eq!(by_provider.claims, claims(&[EVALUATE])); + let jev_only = open_with( + r#"{"models":{"j":{"provider":"typesafe"}}}"#, + &[("typesafe", "jev")], + ); + assert_eq!(jev_only.claims, claims(&[SYSTEMONE])); + // A refresh keeps the provider facts `open` was handed. + assert_eq!( + by_provider.refresh(r#"{"models":{"n2":{"provider":"np"}}}"#, 2), + claims(&[EVALUATE]) + ); +} + +/// RED (audit plane-decisions#3): a request routes among its claim's dialect's models; a model of +/// the other dialect is that dialect's unknown model (404). +#[test] +fn a_model_of_the_wrong_dialect_is_an_unknown_model() { + let door = open(BOTH); + let nano = door.arrive_on(1, EVALUATE.0, EVALUATE.1, br#"{"input":{}}"#, &[]); + assert_eq!( + (nano.outcome, nano.route, nano.entry.as_str()), + (Outcome::Ready, ROUTE_DIRECT, "nano"), + "{nano:?}" + ); + let wrong = door.arrive_on(2, EVALUATE.0, EVALUATE.1, br#"{"model":"jev"}"#, &[]); + assert_eq!( + (wrong.outcome, wrong.status), + (Outcome::Refused, 404), + "{wrong:?}" + ); + let wrong = door.arrive_on(3, SYSTEMONE.0, SYSTEMONE.1, br#"{"model":"nano"}"#, &[]); + assert_eq!( + (wrong.outcome, wrong.status), + (Outcome::Refused, 404), + "{wrong:?}" + ); + let jev = door.arrive(4, br#"{"state":{}}"#); + assert_eq!( + (jev.outcome, jev.entry.as_str()), + (Outcome::Ready, "jev"), + "{jev:?}" + ); +} + +/// A `nanojev` unit served: its ATTEMPT, its body, and the far end's whole answer. +fn served(answer: &[u8]) -> (door_harness::Answered, door_harness::Answered) { + let door = open(BOTH); + let arrived = door.arrive_on(1, EVALUATE.0, EVALUATE.1, br#"{"input":{}}"#, &[]); + assert_eq!(arrived.outcome, Outcome::Ready); + let mut bufs = Bufs::new(1 << 16); + let mut attempt = Given::attempt(1); + attempt.dialect = 1; + let request = door.piece(1, attempt, &mut bufs); + let mut body = Given::caller(br#"{"input":{}}"#); + body.dialect = 1; + assert!(door.piece(1, body, &mut bufs).far); + let mut far = Given::whole(200, answer); + far.dialect = 1; + (request, door.piece(1, far, &mut bufs)) +} + +/// RED (audit plane-decisions#3): the far end is asked at the dialect's own path, and the answer's +/// `execution.states` is the decision count; an answer with none fails loudly. +#[test] +fn a_nanojev_unit_is_sent_to_evaluate_and_bills_its_states() { + let (request, answer) = served(br#"{"execution":{"states":3},"result":{}}"#); + assert_eq!(request.request, "POST /api/evaluate"); + assert_eq!(answer.outcome, Some(Outcome::Ready), "{answer:?}"); + assert_eq!(answer.units[0], (0, UNITS_REPORTED, 3), "{answer:?}"); + let (_, none) = served(br#"{"result":{}}"#); + assert_eq!( + (none.outcome, none.short), + (Some(Outcome::Failed), false), + "{none:?}" + ); + assert!(none.units.is_empty() && none.bytes.is_empty(), "{none:?}"); +} + +/// A model's own `protocol` that names no dialect the plane speaks is refused at validate (#51: +/// fail closed), naming the dialects it does. +#[test] +fn an_unspoken_protocol_is_refused_at_validate() { + let (outcome, words) = validate(r#"{"models":{"m":{"provider":"p","protocol":"bogus"}}}"#); + assert_eq!(outcome, Outcome::Refused, "{words}"); + assert!( + words.contains("bogus") && words.contains("jev, nanojev"), + "{words}" + ); + assert_eq!(validate(BOTH).0, Outcome::Ready); +} diff --git a/crates/busbar/tests/cli_validate.rs b/crates/busbar/tests/cli_validate.rs index ca6082bd0e..e54c651e63 100644 --- a/crates/busbar/tests/cli_validate.rs +++ b/crates/busbar/tests/cli_validate.rs @@ -1517,10 +1517,12 @@ models: {{}} .unwrap(); } -/// THE DECISIONS PLANE'S DIALECT, discovered black-box rather than spelled: a `decisions:` model whose -/// provider speaks an unknown protocol is refused naming the dialect(s) the plane speaks ("this plane -/// speaks only: ."), so the binary itself says which protocol the linked decisions plane reads. -/// Exactly one is required; a refusal that stops naming it fails here, loudly. +/// THE DECISIONS PLANE'S FIRST DIALECT, discovered black-box rather than spelled: a `decisions:` +/// model whose provider speaks an unknown protocol is refused naming the dialects the plane speaks +/// ("this plane speaks only: , ."), so the binary itself says which protocols the linked +/// decisions plane reads: `jev`'s spine and NanoJev's (THE DESIGN section 2, the decisions bullet: +/// "NanoJev is the one, dialect `nanojev`"). Exactly two are required; a refusal that stops naming +/// them fails here, loudly. #[cfg(feature = "plane-decisions")] fn decision_dialect() -> String { let dir = fixture_dir("decisions-dialect-probe"); @@ -1543,8 +1545,8 @@ fn decision_dialect() -> String { let dialects: Vec<&str> = spoken.split(',').map(str::trim).collect(); assert_eq!( dialects.len(), - 1, - "the decision plane speaks one dialect: {stderr}" + 2, + "the decision plane speaks two dialects: {stderr}" ); dialects[0].to_string() } @@ -1570,6 +1572,25 @@ fn validate_ok_on_a_good_decisions_config() { let _ = std::fs::remove_dir_all(&dir); } +/// THE SECOND DIALECT (THE DESIGN section 2: "NanoJev is the one, dialect `nanojev`"): a +/// `decisions:` model whose provider speaks the plane's second dialect validates clean too. +#[cfg(feature = "plane-decisions")] +#[test] +fn validate_ok_on_a_decisions_config_in_the_second_dialect() { + let dir = fixture_dir("decisions-ok-second-dialect"); + write_decisions_configs( + &dir, + "nanojev", + "decisions:\n models:\n verdicts:\n provider: mock\n", + ); + let (code, stdout, stderr) = run_busbar(&dir, &["--validate"]); + assert_eq!( + code, 0, + "a nanojev decisions config validates: stdout={stdout} stderr={stderr}" + ); + let _ = std::fs::remove_dir_all(&dir); +} + /// DEFECT 1: `decisions.models..provider` names a provider absent from `providers:`. #[cfg(feature = "plane-decisions")] #[test] diff --git a/xtask/src/conformance_record/jev.rs b/xtask/src/conformance_record/jev.rs index 2c55155a03..b3445b0568 100644 --- a/xtask/src/conformance_record/jev.rs +++ b/xtask/src/conformance_record/jev.rs @@ -59,6 +59,18 @@ //! | `models.unknown-404` | a model no entry has: `404` in jev's shape, no far-end hop | //! | `models.upstream-model` | the second model named: served, and the far end receives the caller's bytes with the top-level `model` value alone spliced to its `upstream_model` | //! +//! THE `nanojev` CHECKS (THE DESIGN §2: "NanoJev is the one, dialect `nanojev`: `/api/evaluate` is +//! its second exact claim ... it meters the upstream's own billing unit, counted from the answer +//! already read (`execution.states`; an answer with no count refuses loudly ...), with no extra +//! egress") run on a third boot whose `decisions.models` holds [`MODEL`] on the jev provider and +//! [`NANOJEV_MODEL`] on a provider whose protocol is `nanojev`: +//! +//! | check | what a correct busbar does | +//! |---|---| +//! | `nanojev.served` | `POST /api/evaluate` is served: the far end receives `POST /api/evaluate` with the caller's bytes, the caller its 200 bytes, over one dial | +//! | `nanojev.wrong-dialect-404` | the jev model named on `/api/evaluate`: `404` in jev's shape, no far-end hop | +//! | `nanojev.no-count-refused` | a success with no `execution.states`: a 5xx in jev's shape, never the far end's bytes, over one dial | +//! //! The rules, in order: battery red or empty ⇒ `fail`; no subject binary ⇒ `not-run` (nothing was //! booted); a subject that does not boot ⇒ `fail`; `POST /v1/systemone` answering the absence code //! measured on the same boot ⇒ `fail` "not served"; any served check red ⇒ `fail` naming it; every @@ -81,6 +93,17 @@ const PATH: &str = "/v1/systemone"; /// The second boot's second model, and the `upstream_model` it names. pub const UPSTREAM_MODEL: &str = "jev-2"; pub const UPSTREAM_NAME: &str = "jev-upstream-2"; +/// The `nanojev` boot's provider and model, and its dialect's one path. +pub const NANOJEV_PROVIDER: &str = "nanoprov"; +pub const NANOJEV_MODEL: &str = "nano-1"; +pub const PATH_EVALUATE: &str = "/api/evaluate"; +/// A NanoJev request, odd spacing kept, naming no model (the one `nanojev` model is its default). +pub const NANOJEV_REQUEST: &[u8] = b"{ \"input\" : {\"case\":\"conformance\"} }"; +/// A NanoJev success, its billing unit stated. +pub const NANOJEV_SUCCESS: &[u8] = + b"{\"result\":{\"decision\":\"approve\"}, \"execution\" : {\"states\":3}}"; +/// A NanoJev success with no billing unit: the plane refuses it loudly. +pub const NANOJEV_NO_COUNT: &[u8] = b"{\"result\":{\"decision\":\"approve\"}}"; /// The header outside the deny-set the caller sends and the far end must receive unchanged. const PROBE_HEADER: &str = "x-jev-conformance-probe"; @@ -594,6 +617,80 @@ pub fn jev_models_config(data: u16, admin: u16, key_file: &Path) -> String { ) } +/// The rig's providers on the far end at `port`: the jev provider. +pub fn jev_providers(port: u16) -> String { + format!( + "{PROVIDER}:\n protocol: jev\n base_url: http://127.0.0.1:{port}\n error_map: {{}}\n" + ) +} + +/// The `nanojev` boot's providers on the far end at `port`: the jev provider and a `nanojev` one. +pub fn nanojev_providers(port: u16) -> String { + format!( + "{}{NANOJEV_PROVIDER}:\n protocol: nanojev\n base_url: http://127.0.0.1:{port}\n error_map: {{}}\n", + jev_providers(port) + ) +} + +/// The `nanojev` boot's config: [`MODEL`] on the jev provider and [`NANOJEV_MODEL`] on the +/// `nanojev` one, each priced on its own card entry. +pub fn jev_nanojev_config(data: u16, admin: u16, key_file: &Path) -> String { + format!( + "listen: \"127.0.0.1:{data}\"\n\ + admin_listen: \"127.0.0.1:{admin}\"\n\ + store: {{ module: memory }}\n\ + providers:\n {PROVIDER}:\n api_key: {{ env: JEV_CONFORMANCE_PROVIDER_KEY }}\n\ + \x20 {NANOJEV_PROVIDER}:\n api_key: {{ env: JEV_CONFORMANCE_PROVIDER_KEY }}\n\ + models: {{}}\n\ + identity-providers:\n admin-tokens: {{ module: admin-tokens, token: {{ env: BUSBAR_ADMIN_TOKEN }} }}\n\ + auth:\n chain: [keys]\n admin_auth: [admin-tokens]\n signing_key: {{ file: {} }}\n\ + advanced:\n allow_destinations: [\"127.0.0.1\"]\n\ + decisions:\n models:\n {MODEL}:\n provider: {PROVIDER}\n\ + \x20 {NANOJEV_MODEL}:\n provider: {NANOJEV_PROVIDER}\n\ + \x20 rate_card:\n {MODEL}: {{ units: {{ decision: 1, input_tokens: 0, output_tokens: 0 }} }}\n\ + \x20 {NANOJEV_MODEL}: {{ units: {{ decision: 1, input_tokens: 0, output_tokens: 0 }} }}\n", + key_file.display() + ) +} + +/// `nanojev.served`: the caller got the far end's 200 bytes over one dial, and the far end was asked +/// at `/api/evaluate` with the caller's bytes. +pub fn judge_nanojev_served(call: &Call, path: &str, received: &[u8]) -> Result<(), String> { + check( + call.status == 200 && call.body == NANOJEV_SUCCESS && call.hops == 1, + || { + format!( + "a NanoJev success answered {} `{}` over {} hop(s), not the far end's 200 bytes over one", + call.status, + show(&call.body), + call.hops + ) + }, + )?; + check(path == PATH_EVALUATE && received == NANOJEV_REQUEST, || { + format!( + "the far end was asked at `{path}` with `{}`, not at {PATH_EVALUATE} with the caller's bytes", + show(received) + ) + }) +} + +/// `nanojev.no-count-refused`: a success with no billing unit ends in a 5xx in jev's shape, never +/// the far end's bytes, over exactly one dial (no extra egress). +pub fn judge_nanojev_no_count(call: &Call) -> Result<(), String> { + if !(500..=599).contains(&call.status) || call.body == NANOJEV_NO_COUNT { + return Err(format!( + "a NanoJev success with no `execution.states` answered {} `{}`, not a 5xx refusal", + call.status, + show(&call.body) + )); + } + check(call.hops == 1, || { + format!("the unit made {} far-end hop(s), not one", call.hops) + })?; + jev_refusal_code(&call.body).map(|_| ()) +} + /// A request naming `model` (none: [`REQUEST`]'s own bytes), the odd spacing kept. pub fn models_request(model: Option<&str>) -> Vec { match model { @@ -687,6 +784,7 @@ impl Runner { self.served(&bin, &dir, &mut run); if run.boot.is_ok() && !run.checks.is_empty() { self.served_models(&bin, &dir, &mut run); + self.served_nanojev(&bin, &dir, &mut run); } } Err(e) => run.build = Err(e), @@ -728,10 +826,7 @@ impl Runner { } }; let config = jev_subject_config(data, admin, &key_file); - let providers = format!( - "{PROVIDER}:\n protocol: jev\n base_url: http://127.0.0.1:{}\n error_map: {{}}\n", - far.port - ); + let providers = jev_providers(far.port); let booted = match subject::boot( bin, dir, @@ -970,18 +1065,62 @@ impl Runner { } } +/// A booted subject of one of the extra legs: its far end, its data port and a minted caller. +struct Leg { + far: FarEnd, + scratch: std::path::PathBuf, + data: u16, + bearer: String, + _booted: subject::Booted, +} + +impl Leg { + /// One call on `path` with `body`, and the far-end hops it made. + fn call(&self, path: &str, body: &[u8]) -> Result { + let before = self.far.seen().len(); + let r = curl( + &self.scratch, + "POST", + &format!("http://127.0.0.1:{}{path}", self.data), + &[ + ("content-type", "application/json"), + ("authorization", self.bearer.as_str()), + ], + Some(body), + &[], + )?; + Ok(Call { + status: r.status, + body: r.body, + hops: self.far.seen().len() - before, + }) + } + + /// The body of the last request the far end received. + fn received(&self) -> Vec { + self.far + .seen() + .last() + .map(|s| s.body.clone()) + .unwrap_or_default() + } +} + impl Runner { - /// THE MODEL-RESOLUTION BOOT: two models on the rig's far end, and the three `models.*` checks. - fn served_models(&self, bin: &Path, dir: &Path, run: &mut JevRun) { - let dir = dir.join("models"); + /// Boot an extra leg's subject under `dir/`: its config written by `config` for its + /// ports and signing key, its providers by `providers` for the far end's port, and a caller + /// minted. + fn boot_leg( + &self, + bin: &Path, + dir: &Path, + name: &str, + config: fn(u16, u16, &Path) -> String, + providers: fn(u16) -> String, + ) -> Result { + let dir = dir.join(name); let scratch = dir.join("http"); - let mut fail = |why: String| { - run.checks.push(("models.boot".to_string(), Err(why))); - }; - let far = match FarEnd::start() { - Ok(f) => f, - Err(e) => return fail(e), - }; + let far = FarEnd::start()?; let (ports, admin_token, provider_key) = match ( subject::free_ports(2), subject::random_hex(24), @@ -989,86 +1128,96 @@ impl Runner { ) { (Ok(p), Ok(a), Ok(k)) => (p, a, k), (p, a, k) => { - return fail( - [p.err(), a.err(), k.err()] - .into_iter() - .flatten() - .collect::>() - .join("; "), - ) + return Err([p.err(), a.err(), k.err()] + .into_iter() + .flatten() + .collect::>() + .join("; ")) } }; let (data, admin) = (ports[0], ports[1]); - let key_file = match subject::signing_key(bin, &dir) { - Ok(k) => k, - Err(e) => return fail(e), - }; - let providers = format!( - "{PROVIDER}:\n protocol: jev\n base_url: http://127.0.0.1:{}\n error_map: {{}}\n", - far.port - ); - let booted = match subject::boot( + let key_file = subject::signing_key(bin, &dir)?; + let booted = subject::boot( bin, &dir, - &jev_models_config(data, admin, &key_file), - &providers, + &config(data, admin, &key_file), + &providers(far.port), &[ ("BUSBAR_ADMIN_TOKEN", admin_token.clone()), ("JEV_CONFORMANCE_PROVIDER_KEY", provider_key), ], &format!("http://127.0.0.1:{data}/stats"), &[], - ) { - Ok(b) => b, - Err(e) => return fail(format!("the two-model subject did not boot: {e}")), - }; - let bearer = match subject::mint_key(&scratch, admin, &admin_token, "jev-models") { + ) + .map_err(|e| format!("the {name} subject did not boot: {e}"))?; + let bearer = match subject::mint_key(&scratch, admin, &admin_token, &format!("jev-{name}")) + { Ok((key, _)) => format!("Bearer {key}"), - Err(e) => return fail(format!("{e}: {}", booted.log_tail())), + Err(e) => return Err(format!("{e}: {}", booted.log_tail())), }; - let call = |model: Option<&str>| -> Result<(Call, Vec), String> { - let body = models_request(model); - let before = far.seen().len(); - let r = curl( - &scratch, - "POST", - &format!("http://127.0.0.1:{data}{PATH}"), - &[ - ("content-type", "application/json"), - ("authorization", bearer.as_str()), - ], - Some(body.as_slice()), - &[], - )?; - Ok(( - Call { - status: r.status, - body: r.body, - hops: far.seen().len() - before, - }, - body, - )) + Ok(Leg { + far, + scratch, + data, + bearer, + _booted: booted, + }) + } + + /// THE MODEL-RESOLUTION BOOT: two models on the rig's far end, and the three `models.*` checks. + fn served_models(&self, bin: &Path, dir: &Path, run: &mut JevRun) { + let leg = match self.boot_leg(bin, dir, "models", jev_models_config, jev_providers) { + Ok(leg) => leg, + Err(why) => return run.checks.push(("models.boot".to_string(), Err(why))), }; run.checks.push(( "models.unnamed-400".to_string(), - call(None).and_then(|(c, _)| judge_unrouted(&c, 400, "invalid_request")), + leg.call(PATH, &models_request(None)) + .and_then(|c| judge_unrouted(&c, 400, "invalid_request")), )); run.checks.push(( "models.unknown-404".to_string(), - call(Some("jev-no-such-model")) - .and_then(|(c, _)| judge_unrouted(&c, 404, "invalid_params")), + leg.call(PATH, &models_request(Some("jev-no-such-model"))) + .and_then(|c| judge_unrouted(&c, 404, "invalid_params")), )); - far.clear_replies(); - far.reply(200, SUCCESS); + leg.far.clear_replies(); + leg.far.reply(200, SUCCESS); + let sent = models_request(Some(UPSTREAM_MODEL)); run.checks.push(( "models.upstream-model".to_string(), - call(Some(UPSTREAM_MODEL)).and_then(|(c, sent)| { - let seen = far.seen(); - let received = seen.last().map(|s| s.body.clone()).unwrap_or_default(); - judge_upstream_model(&c, &sent, &received) + leg.call(PATH, &sent) + .and_then(|c| judge_upstream_model(&c, &sent, &leg.received())), + )); + } + + /// THE `nanojev` BOOT: a NanoJev model beside a jev one, and the three `nanojev.*` checks. + fn served_nanojev(&self, bin: &Path, dir: &Path, run: &mut JevRun) { + let leg = match self.boot_leg(bin, dir, "nanojev", jev_nanojev_config, nanojev_providers) { + Ok(leg) => leg, + Err(why) => return run.checks.push(("nanojev.boot".to_string(), Err(why))), + }; + leg.far.clear_replies(); + leg.far.reply(200, NANOJEV_SUCCESS); + run.checks.push(( + "nanojev.served".to_string(), + leg.call(PATH_EVALUATE, NANOJEV_REQUEST).and_then(|c| { + let seen = leg.far.seen(); + let path = seen.last().map(|s| s.path.clone()).unwrap_or_default(); + judge_nanojev_served(&c, &path, &leg.received()) }), )); - drop(booted); + run.checks.push(( + "nanojev.wrong-dialect-404".to_string(), + leg.call(PATH_EVALUATE, &models_request(Some(MODEL))) + .and_then(|c| judge_unrouted(&c, 404, "invalid_params")), + )); + leg.far.clear_replies(); + leg.far.reply(200, NANOJEV_NO_COUNT); + run.checks.push(( + "nanojev.no-count-refused".to_string(), + leg.call(PATH_EVALUATE, NANOJEV_REQUEST) + .and_then(|c| judge_nanojev_no_count(&c)), + )); } } diff --git a/xtask/src/conformance_record/mod.rs b/xtask/src/conformance_record/mod.rs index a057c27518..b3dc99427f 100644 --- a/xtask/src/conformance_record/mod.rs +++ b/xtask/src/conformance_record/mod.rs @@ -48,12 +48,14 @@ use crate::gitp; pub use a2a::{discriminates, governance_observed, NEGATIVE_PAIRS}; pub use h2::{decide_h2, junit_cases, Case, CaseResult, H2Run}; pub use jev::{ - decide_jev, is_jev_refusal, jev_models_config, jev_refusal_code, jev_subject_config, - judge_admit, judge_audit, judge_audit_window, judge_exit, judge_jev_ledger, judge_meter, + decide_jev, is_jev_refusal, jev_models_config, jev_nanojev_config, jev_providers, + jev_refusal_code, jev_subject_config, judge_admit, judge_audit, judge_audit_window, judge_exit, + judge_jev_ledger, judge_meter, judge_nanojev_no_count, judge_nanojev_served, judge_refused_before_dial, judge_route, judge_unrouted, judge_upstream_model, ledger_spend, - models_request, tests_passed, Call, JevRun, Spend, AUDIT_OP_CLASS, ELSEWHERE, H2_CHECKS, - ONE_REQUEST_GROUP, REPORTED_UNITS, SUCCESS as JEV_SUCCESS, UNAVAILABLE, UPSTREAM_MODEL, - UPSTREAM_NAME, + models_request, nanojev_providers, tests_passed, Call, JevRun, Spend, AUDIT_OP_CLASS, + ELSEWHERE, H2_CHECKS, NANOJEV_MODEL, NANOJEV_NO_COUNT, NANOJEV_PROVIDER, NANOJEV_REQUEST, + NANOJEV_SUCCESS, ONE_REQUEST_GROUP, PATH_EVALUATE, REPORTED_UNITS, SUCCESS as JEV_SUCCESS, + UNAVAILABLE, UPSTREAM_MODEL, UPSTREAM_NAME, }; pub use oidf::{ as_signing_key, base64_std, decide_oidf, es256_jwk, idp_reply, module_results, diff --git a/xtask/tests/conformance_rigs.rs b/xtask/tests/conformance_rigs.rs index e7b956cfdf..0fcae2cc76 100644 --- a/xtask/tests/conformance_rigs.rs +++ b/xtask/tests/conformance_rigs.rs @@ -304,6 +304,69 @@ fn the_upstream_model_is_spliced_into_what_the_far_end_receives() { .contains("\"model\"")); } +/// THE `nanojev` BOOT (THE DESIGN §2, the decisions bullet): a NanoJev model on a `nanojev` +/// provider beside the jev one, each priced. +#[test] +fn the_nanojev_subject_configures_a_nanojev_provider_and_model() { + use xtask::conformance_record::{ + jev_nanojev_config, nanojev_providers, NANOJEV_MODEL, NANOJEV_PROVIDER, + }; + let text = jev_nanojev_config(41000, 41001, std::path::Path::new("/tmp/signing.key")); + let doc: serde_yaml::Value = serde_yaml::from_str(&text).expect("the config is YAML"); + assert_eq!( + doc["decisions"]["models"][NANOJEV_MODEL]["provider"].as_str(), + Some(NANOJEV_PROVIDER) + ); + assert_eq!( + doc["decisions"]["models"]["jev-1"]["provider"].as_str(), + Some("typesafe") + ); + assert!(!doc["providers"][NANOJEV_PROVIDER].is_null()); + for m in ["jev-1", NANOJEV_MODEL] { + assert_eq!( + doc["decisions"]["rate_card"][m]["units"]["decision"].as_u64(), + Some(1), + "{m}" + ); + } + let providers: serde_yaml::Value = + serde_yaml::from_str(&nanojev_providers(9)).expect("the providers are YAML"); + assert_eq!( + providers[NANOJEV_PROVIDER]["protocol"].as_str(), + Some("nanojev") + ); + assert_eq!(providers["typesafe"]["protocol"].as_str(), Some("jev")); +} + +/// `nanojev.served` and `nanojev.no-count-refused`. +#[test] +fn a_nanojev_unit_is_served_at_evaluate_and_a_missing_count_refused() { + use xtask::conformance_record::{ + judge_nanojev_no_count, judge_nanojev_served, NANOJEV_NO_COUNT, NANOJEV_REQUEST, + NANOJEV_SUCCESS, PATH_EVALUATE, + }; + let ok = call(200, NANOJEV_SUCCESS, 1); + assert!(judge_nanojev_served(&ok, PATH_EVALUATE, NANOJEV_REQUEST).is_ok()); + // RED ARMS: another path, other bytes, another answer, no dial. + assert!(judge_nanojev_served(&ok, "/v1/systemone", NANOJEV_REQUEST).is_err()); + assert!(judge_nanojev_served(&ok, PATH_EVALUATE, b"{}").is_err()); + assert!(judge_nanojev_served(&call(200, b"{}", 1), PATH_EVALUATE, NANOJEV_REQUEST).is_err()); + assert!(judge_nanojev_served( + &call(200, NANOJEV_SUCCESS, 0), + PATH_EVALUATE, + NANOJEV_REQUEST + ) + .is_err()); + + let refused = br#"{"error":{"code":"internal","message":"plane fault"}}"#; + assert!(judge_nanojev_no_count(&call(502, refused, 1)).is_ok()); + // RED ARMS: the far end's bytes relayed, a success, an extra dial, another shape. + assert!(judge_nanojev_no_count(&call(200, NANOJEV_NO_COUNT, 1)).is_err()); + assert!(judge_nanojev_no_count(&call(502, NANOJEV_NO_COUNT, 1)).is_err()); + assert!(judge_nanojev_no_count(&call(502, refused, 2)).is_err()); + assert!(judge_nanojev_no_count(&call(502, b"oops", 1)).is_err()); +} + // ── a2a ─────────────────────────────────────────────────────────────────────────────────────── fn report(rows: &[(&str, &str)]) -> String { From b8a165c5695a1b60ca69bae48fdcc2410dce10e7 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:28:06 -0700 Subject: [PATCH 12/59] row 130 E2: ledgers follow the net_guard deletion - design bindings PB-68: the shared-predicate row names its surviving copy in the egress unit's suite (crates/busbar-kernel-egress/src/trust/tests/net_tests.rs, same test), not the deleted kernel suite; qa-names and design-bindings:regen-clean follow. - law0 busbar-kernel x plane 1172 -> 1166: the shim and its suites carried six plane words. - The moved NAT64 row's doc names no plane (busbar-kernel-egress x plane stays at 5). --- crates/busbar-kernel-egress/src/trust/tests/net_tests.rs | 2 +- qa/DESIGN-BINDINGS.md | 2 +- qa/design-bindings.json | 4 ++-- qa/kind-isolation.toml | 4 ++-- xtask/src/gates/design_bindings/tables.rs | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/busbar-kernel-egress/src/trust/tests/net_tests.rs b/crates/busbar-kernel-egress/src/trust/tests/net_tests.rs index 0eb81ea526..0f8cf1e1e0 100644 --- a/crates/busbar-kernel-egress/src/trust/tests/net_tests.rs +++ b/crates/busbar-kernel-egress/src/trust/tests/net_tests.rs @@ -1120,7 +1120,7 @@ fn a_metadata_name_on_a_public_address_is_refused_by_its_name_alone() { /// `nat64_embedded_ipv4_is_judged_by_the_shared_predicates`: fixing the predicate does NOT fix the /// callers that never call it. /// -/// [`ssrf_blocked_host`] is the pre-flight guard for OAuth token-endpoint URLs and for MCP +/// [`ssrf_blocked_host`] is the pre-flight guard for OAuth token-endpoint URLs and for /// tool-call ARGUMENT hosts — attacker-influenced input on the classic SSRF injection path — so a /// bypass here is reachable without any DNS answer at all. #[test] diff --git a/qa/DESIGN-BINDINGS.md b/qa/DESIGN-BINDINGS.md index 9b478ce961..1f56e698f2 100644 --- a/qa/DESIGN-BINDINGS.md +++ b/qa/DESIGN-BINDINGS.md @@ -94,7 +94,7 @@ The three words are not interchangeable: | PB-65 | egress auth wire behaviour | mapped | PASS | | test: `test_auth_headers_valid_key_emits_x_goog_api_key`
test: `egress_scheme_is_the_declared_credential_family_table`
test: `crates/busbar/src/root/tests/declared_credentials.rs::a_declared_static_header_follows_the_credential_verbatim`
test: `crates/busbar/src/root/tests/declared_credentials.rs::each_declared_scheme_presents_what_its_dialect_builder_wrote`
test: `anthropic_egress_carries_exactly_the_declared_headers`
test: `every_egress_request_declares_the_pinned_api_version`
test: `with_no_static_header_declared_the_version_header_is_absent`
oracle-cell: `egress.auth\|cred-identity\|anthropic`
oracle-cell: `egress.auth\|sigv4\|session-token`
test: `test_bedrock_sigv4_sign_request_structure`
test: `test_bedrock_sigv4_session_token`
test: `a_misconfigured_or_unsendable_signing_credential_signs_nothing` | | PB-66 | request and response headers | mapped | PASS | | test: `same_dialect_anthropic_forwards_every_client_header`
test: `test_egress_accept_matches_native_sdk`
test: `test_egress_ua_versions_are_pinned_and_present`
test: `test_ingress_stream_content_type_by_protocol`
test: `test_bedrock_ingress_success_carries_amzn_request_id`
test: `test_forward_once_bedrock_error_relays_amzn_headers`
test: `test_anthropic_ingress_success_carries_request_id_header`
test: `test_anthropic_same_proto_error_relays_upstream_request_id_verbatim_once`
test: `test_synth_anthropic_request_id_is_well_formed` | | PB-67 | per-dialect error mapping | mapped | PASS | | test: `test_cross_protocol_error_kind_mapping`
test: `test_shape_cross_protocol_error_auth_kinds`
test: `test_ingress_error_bedrock_amzn_headers`
test: `test_vendor_auth_failure_message_is_plausible_per_proto`
test: `test_bedrock_ingress_wrong_token_is_403_native_envelope`
test: `error_kind_to_bedrock_type_covers_ingress_emitted_kinds`
test: `test_openai_classify`
test: `write_error_kind_vocabulary_mapping` | -| PB-68 | network guard | mapped | PASS | | test: `test_ssrf_blocks_metadata_denylist_by_default`
test: `test_ssrf_blocked_returns_exact_host_string`
test: `test_reject_cidr_metadata_entries`
test: `test_global_allow_overrides_blocked_metadata_hosts`
test: `test_allow_all_metadata_beats_nonempty_blocked_list`
test: `test_ssrf_allows_private_and_loopback_by_default`
test: `test_validate_rejects_non_https_base_url`
test: `test_validate_token_url_ssrf_and_scheme`
test: `crates/busbar-kernel/src/tests/net_guard_tests.rs::the_shared_internal_predicate_covers_every_range_any_plane_ever_checked` | +| PB-68 | network guard | mapped | PASS | | test: `test_ssrf_blocks_metadata_denylist_by_default`
test: `test_ssrf_blocked_returns_exact_host_string`
test: `test_reject_cidr_metadata_entries`
test: `test_global_allow_overrides_blocked_metadata_hosts`
test: `test_allow_all_metadata_beats_nonempty_blocked_list`
test: `test_ssrf_allows_private_and_loopback_by_default`
test: `test_validate_rejects_non_https_base_url`
test: `test_validate_token_url_ssrf_and_scheme`
test: `crates/busbar-kernel-egress/src/trust/tests/net_tests.rs::the_shared_internal_predicate_covers_every_range_any_plane_ever_checked` | | PB-69 | ingress server posture | mapped | PASS | | test: `server_posture_matches_the_1_5_5_defaults` | | PB-70 | scrape shape | mapped | PASS | | oracle-cell: `ops.scrape\|metrics\|key`
test: `a_1_5_5_shaped_config_exposes_no_plane_series_with_every_plane_compiled_in`
test: `no_ledger_series_and_no_keyset_lines_without_data_dir`
oracle-cell: `ops.scrape\|metrics\|no-ledger-series` | | PB-71 | documented behaviour | mapped | PASS | | oracle-family: `documented` | diff --git a/qa/design-bindings.json b/qa/design-bindings.json index 9a0361607e..a51b2359fe 100644 --- a/qa/design-bindings.json +++ b/qa/design-bindings.json @@ -3322,14 +3322,14 @@ }, { "kind": "test", - "ref": "crates/busbar-kernel/src/tests/net_guard_tests.rs::the_shared_internal_predicate_covers_every_range_any_plane_ever_checked", + "ref": "crates/busbar-kernel-egress/src/trust/tests/net_tests.rs::the_shared_internal_predicate_covers_every_range_any_plane_ever_checked", "proves": "one shared predicate across planes", "source": "curated", "status": "mapped" } ], "verdict": "PASS", - "verdict_detail": "test:test_ssrf_blocks_metadata_denylist_by_default, test:test_ssrf_blocked_returns_exact_host_string, test:test_reject_cidr_metadata_entries, test:test_global_allow_overrides_blocked_metadata_hosts, test:test_allow_all_metadata_beats_nonempty_blocked_list, test:test_ssrf_allows_private_and_loopback_by_default, test:test_validate_rejects_non_https_base_url, test:test_validate_token_url_ssrf_and_scheme, test:crates/busbar-kernel/src/tests/net_guard_tests.rs::the_shared_internal_predicate_covers_every_range_any_plane_ever_checked" + "verdict_detail": "test:test_ssrf_blocks_metadata_denylist_by_default, test:test_ssrf_blocked_returns_exact_host_string, test:test_reject_cidr_metadata_entries, test:test_global_allow_overrides_blocked_metadata_hosts, test:test_allow_all_metadata_beats_nonempty_blocked_list, test:test_ssrf_allows_private_and_loopback_by_default, test:test_validate_rejects_non_https_base_url, test:test_validate_token_url_ssrf_and_scheme, test:crates/busbar-kernel-egress/src/trust/tests/net_tests.rs::the_shared_internal_predicate_covers_every_range_any_plane_ever_checked" }, { "id": "PB-69", diff --git a/qa/kind-isolation.toml b/qa/kind-isolation.toml index 046ac027cf..872fb20052 100644 --- a/qa/kind-isolation.toml +++ b/qa/kind-isolation.toml @@ -1819,8 +1819,8 @@ cite = "measured on predev f830faa691 (ARCHITECT 2026-10-07, Law 9); the drain [[law0]] crate = "busbar-kernel" axis = "plane" -count = "1172" -cite = "measured on predev f830faa691 (ARCHITECT 2026-10-07, Law 9); the drain is THE DESIGN §1 / DoD item 2: core names no instance; LOWERED 1193 -> 1175, predev 9c0a394771's measure: the drain landed without giving its row back (Law 9: the ceiling ratchets down); LOWERED 1175 -> 1173, the merge-base d7929b661d's measure, which GetBusbar/busbar#570 holds (its scrape double spells no plane alias) (Law 9: the ceiling ratchets down to today's number); LOWERED 1173 -> 1172, the gate's measure on this branch merged with predev 1617e9f019: Q128 A4's plane_driver/gated.rs drain" +count = "1166" +cite = "measured on predev f830faa691 (ARCHITECT 2026-10-07, Law 9); the drain is THE DESIGN §1 / DoD item 2: core names no instance; LOWERED 1193 -> 1175, predev 9c0a394771's measure: the drain landed without giving its row back (Law 9: the ceiling ratchets down); LOWERED 1175 -> 1173, the merge-base d7929b661d's measure, which GetBusbar/busbar#570 holds (its scrape double spells no plane alias) (Law 9: the ceiling ratchets down to today's number); LOWERED 1173 -> 1172, the gate's measure on this branch merged with predev 1617e9f019: Q128 A4's plane_driver/gated.rs drain; LOWERED 1172 -> 1166 by status row 130 E2: net_guard.rs and its two suites deleted (the gate's measure on the E2 candidate)" [[law0]] crate = "busbar-kernel" diff --git a/xtask/src/gates/design_bindings/tables.rs b/xtask/src/gates/design_bindings/tables.rs index 9ed84d1cbe..04d361d707 100644 --- a/xtask/src/gates/design_bindings/tables.rs +++ b/xtask/src/gates/design_bindings/tables.rs @@ -405,7 +405,7 @@ pub static SEED: &[(&str, &[SeededCheck])] = &[ ("test", "test_ssrf_allows_private_and_loopback_by_default", "no over-blocking, no runtime DNS check"), ("test", "test_validate_rejects_non_https_base_url", "the public-https / private-http scheme rule literals"), ("test", "test_validate_token_url_ssrf_and_scheme", "the same rules on token_url"), - ("test", "crates/busbar-kernel/src/tests/net_guard_tests.rs::the_shared_internal_predicate_covers_every_range_any_plane_ever_checked", "one shared predicate across planes"), + ("test", "crates/busbar-kernel-egress/src/trust/tests/net_tests.rs::the_shared_internal_predicate_covers_every_range_any_plane_ever_checked", "one shared predicate across planes"), ]), ("PB-73", &[ ("test", "server_timing_header_absent_by_default_present_when_enabled", "Server-Timing absent by default, on every response when enabled"), From ed735b21de0a1adb5efc5a7386b134d6f8687f05 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:09:11 -0700 Subject: [PATCH 13/59] =?UTF-8?q?cli=5Fvalidate:=20the=20new=20dialect=20c?= =?UTF-8?q?ells=20name=20the=20kind,=20not=20the=20instance=20(law0=20busb?= =?UTF-8?q?ar=C3=97plane=20back=20to=20826)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/busbar/tests/cli_validate.rs | 25 +++++++++---------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/crates/busbar/tests/cli_validate.rs b/crates/busbar/tests/cli_validate.rs index e54c651e63..a3a73d0a20 100644 --- a/crates/busbar/tests/cli_validate.rs +++ b/crates/busbar/tests/cli_validate.rs @@ -1517,12 +1517,10 @@ models: {{}} .unwrap(); } -/// THE DECISIONS PLANE'S FIRST DIALECT, discovered black-box rather than spelled: a `decisions:` -/// model whose provider speaks an unknown protocol is refused naming the dialects the plane speaks -/// ("this plane speaks only: , ."), so the binary itself says which protocols the linked -/// decisions plane reads: `jev`'s spine and NanoJev's (THE DESIGN section 2, the decisions bullet: -/// "NanoJev is the one, dialect `nanojev`"). Exactly two are required; a refusal that stops naming -/// them fails here, loudly. +/// THE PLANE'S FIRST DIALECT, discovered black-box rather than spelled: a model whose provider +/// speaks an unknown protocol is refused naming the dialects the plane speaks ("this plane speaks +/// only: , ."), so the binary itself says which protocols the linked plane reads. Exactly +/// two are required; a refusal that stops naming them fails here, loudly. #[cfg(feature = "plane-decisions")] fn decision_dialect() -> String { let dir = fixture_dir("decisions-dialect-probe"); @@ -1543,11 +1541,7 @@ fn decision_dialect() -> String { .and_then(|rest| rest.split('.').next()) .unwrap_or_else(|| panic!("the refusal names the dialects the plane speaks: {stderr}")); let dialects: Vec<&str> = spoken.split(',').map(str::trim).collect(); - assert_eq!( - dialects.len(), - 2, - "the decision plane speaks two dialects: {stderr}" - ); + assert_eq!(dialects.len(), 2, "the plane speaks two dialects: {stderr}"); dialects[0].to_string() } @@ -1572,12 +1566,11 @@ fn validate_ok_on_a_good_decisions_config() { let _ = std::fs::remove_dir_all(&dir); } -/// THE SECOND DIALECT (THE DESIGN section 2: "NanoJev is the one, dialect `nanojev`"): a -/// `decisions:` model whose provider speaks the plane's second dialect validates clean too. +/// THE SECOND DIALECT: a model whose provider speaks the plane's second dialect validates clean too. #[cfg(feature = "plane-decisions")] #[test] -fn validate_ok_on_a_decisions_config_in_the_second_dialect() { - let dir = fixture_dir("decisions-ok-second-dialect"); +fn validate_ok_on_a_config_in_the_planes_second_dialect() { + let dir = fixture_dir("plane-ok-second-dialect"); write_decisions_configs( &dir, "nanojev", @@ -1586,7 +1579,7 @@ fn validate_ok_on_a_decisions_config_in_the_second_dialect() { let (code, stdout, stderr) = run_busbar(&dir, &["--validate"]); assert_eq!( code, 0, - "a nanojev decisions config validates: stdout={stdout} stderr={stderr}" + "a config in the second dialect validates: stdout={stdout} stderr={stderr}" ); let _ = std::fs::remove_dir_all(&dir); } From f190d4e6f4ce234386ba0fb11fe88e877d2dc63b Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:05:56 -0700 Subject: [PATCH 14/59] row 130 E2: the kernel's name lookup is the connector's host I/O no_io_outside_hostio holds that no connector file but the host's I/O touches the OS, and SystemNames (the system name lookup the kernel's remaining resolve-then-pin dials ask through the egress-trust capability) called tokio::net::lookup_host from process.rs. It moves, unchanged, into hostio.rs, the host's I/O; the root installs busbar_core_connector::hostio::SystemNames. The test's allowlist is unchanged. --- crates/busbar-core-connector/Cargo.toml | 2 +- crates/busbar-core-connector/src/hostio.rs | 54 ++++++++++++++++++- crates/busbar-core-connector/src/process.rs | 51 ------------------ crates/busbar-kernel/src/egress/fixtures.rs | 2 +- .../src/plane_host/egress_trust.rs | 2 +- crates/busbar/src/root/connector.rs | 4 +- qa/kind-isolation.toml | 2 +- 7 files changed, 59 insertions(+), 58 deletions(-) diff --git a/crates/busbar-core-connector/Cargo.toml b/crates/busbar-core-connector/Cargo.toml index 1efbf67c17..a0930e86fa 100644 --- a/crates/busbar-core-connector/Cargo.toml +++ b/crates/busbar-core-connector/Cargo.toml @@ -35,7 +35,7 @@ busbar-contract = { path = "../busbar-contract" } busbar-kernel = { path = "../busbar-kernel", default-features = false } # The egress unit's one http(s) URL reader (`trust::net::split_url`, the destination judge's URL arm # and the metadata carve-outs' host), and its `Resolver` seam, which the system name lookup the -# kernel's remaining resolve-then-pin dials ask (`process::SystemNames`) implements: the kernel +# kernel's remaining resolve-then-pin dials ask (`hostio::SystemNames`) implements: the kernel # resolves no name itself (row 130 E2). One-way, like the edge above. busbar-kernel-egress = { path = "../busbar-kernel-egress" } rustls = { workspace = true } diff --git a/crates/busbar-core-connector/src/hostio.rs b/crates/busbar-core-connector/src/hostio.rs index 2125b2ffb2..daeb6a30d1 100644 --- a/crates/busbar-core-connector/src/hostio.rs +++ b/crates/busbar-core-connector/src/hostio.rs @@ -25,7 +25,7 @@ use std::collections::HashMap; use std::io::{self, Read, Write}; -use std::net::{Shutdown, SocketAddr, TcpListener, TcpStream}; +use std::net::{IpAddr, Shutdown, SocketAddr, TcpListener, TcpStream}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, PoisonError, RwLock}; use std::task::{Context, Poll, Waker}; @@ -670,6 +670,58 @@ impl IoHost for HostIo { } } +/// THE SYSTEM NAME LOOKUP the kernel's remaining resolve-then-pin dials ask through its egress-trust +/// capability (row 130 E2: the kernel opens no socket, a resolver's included, so its lookup is the +/// connector's host I/O, the one place here that touches the OS; the root installs this behind the +/// capability at boot). Returns EVERY address the +/// name answered with, de-duplicated but otherwise untouched and unsorted: filtering or re-ordering +/// would quietly decide which address the guard gets to judge, and the guard judges all of them; a +/// name answering one address under both a v4 and a v6 query is ONE fact. +/// +/// ## Why a thread of its own +/// +/// The seam is synchronous, so this runs `getaddrinfo` and waits. Not `Handle::current().block_on` +/// and not `block_in_place`: both make assumptions about the caller (that there IS a runtime, that +/// it is multi-threaded) that a synchronous seam cannot make, and its callers reach it from +/// different postures — a foreign thread already inside `Runtime::block_on`, a boxed future on a +/// shared worker, a plain `async fn`. A thread with its own current-thread runtime has no such +/// precondition and cannot panic on the caller's behalf. (Moved verbatim from the kernel's deleted +/// `net_guard::SystemResolver`.) +#[derive(Clone, Copy, Debug, Default)] +pub struct SystemNames; + +impl busbar_kernel_egress::trust::net::Resolver for SystemNames { + fn resolve(&self, host: &str) -> Result, String> { + // Port zero: this seam answers about ADDRESSES. The port belongs to the URL and is applied + // by the caller that built the pin, so asking the resolver about one would be asking a + // second question. + let target = format!("{host}:0"); + std::thread::scope(|s| { + s.spawn(|| { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| format!("name lookup: could not start a runtime: {e}"))?; + rt.block_on(async move { + let answered = tokio::net::lookup_host(target) + .await + .map_err(|e| e.to_string())?; + let mut out: Vec = Vec::new(); + for sa in answered { + let ip = sa.ip(); + if !out.contains(&ip) { + out.push(ip); + } + } + Ok(out) + }) + }) + .join() + .map_err(|_| "name lookup: the worker thread panicked".to_string())? + }) + } +} + #[cfg(test)] #[path = "tests/hostio_tests.rs"] mod tests; diff --git a/crates/busbar-core-connector/src/process.rs b/crates/busbar-core-connector/src/process.rs index 2c9cadbd9a..a9d00aa41e 100644 --- a/crates/busbar-core-connector/src/process.rs +++ b/crates/busbar-core-connector/src/process.rs @@ -354,57 +354,6 @@ pub fn own_ports(listens: &[&str]) -> Vec { .collect() } -/// THE SYSTEM NAME LOOKUP the kernel's remaining resolve-then-pin dials ask through its egress-trust -/// capability (row 130 E2: the kernel opens no socket, a resolver's included, so its lookup is the -/// connector's; the root installs this behind the capability at boot). Returns EVERY address the -/// name answered with, de-duplicated but otherwise untouched and unsorted: filtering or re-ordering -/// would quietly decide which address the guard gets to judge, and the guard judges all of them; a -/// name answering one address under both a v4 and a v6 query is ONE fact. -/// -/// ## Why a thread of its own -/// -/// The seam is synchronous, so this runs `getaddrinfo` and waits. Not `Handle::current().block_on` -/// and not `block_in_place`: both make assumptions about the caller (that there IS a runtime, that -/// it is multi-threaded) that a synchronous seam cannot make, and its callers reach it from -/// different postures — a foreign thread already inside `Runtime::block_on`, a boxed future on a -/// shared worker, a plain `async fn`. A thread with its own current-thread runtime has no such -/// precondition and cannot panic on the caller's behalf. (Moved verbatim from the kernel's deleted -/// `net_guard::SystemResolver`.) -#[derive(Clone, Copy, Debug, Default)] -pub struct SystemNames; - -impl busbar_kernel_egress::trust::net::Resolver for SystemNames { - fn resolve(&self, host: &str) -> Result, String> { - // Port zero: this seam answers about ADDRESSES. The port belongs to the URL and is applied - // by the caller that built the pin, so asking the resolver about one would be asking a - // second question. - let target = format!("{host}:0"); - std::thread::scope(|s| { - s.spawn(|| { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .map_err(|e| format!("name lookup: could not start a runtime: {e}"))?; - rt.block_on(async move { - let answered = tokio::net::lookup_host(target) - .await - .map_err(|e| e.to_string())?; - let mut out: Vec = Vec::new(); - for sa in answered { - let ip = sa.ip(); - if !out.contains(&ip) { - out.push(ip); - } - } - Ok(out) - }) - }) - .join() - .map_err(|_| "name lookup: the worker thread panicked".to_string())? - }) - } -} - #[cfg(test)] #[path = "tests/process_tests.rs"] mod tests; diff --git a/crates/busbar-kernel/src/egress/fixtures.rs b/crates/busbar-kernel/src/egress/fixtures.rs index 1d9273a582..8eeb74cb30 100644 --- a/crates/busbar-kernel/src/egress/fixtures.rs +++ b/crates/busbar-kernel/src/egress/fixtures.rs @@ -954,7 +954,7 @@ pub fn install_test_tls(layer: Arc) { /// capability is installed (`plane_host::egress_trust::EgressTrustHost::resolve_names`): every /// address `host` answers with, de-duplicated but otherwise untouched and unsorted. Test machinery /// only: a production kernel resolves nothing itself, the connector's resolver -/// (`busbar_core_connector::process::SystemNames`, the same body) is installed behind the capability. +/// (`busbar_core_connector::hostio::SystemNames`, the same body) is installed behind the capability. /// /// # Errors /// diff --git a/crates/busbar-kernel/src/plane_host/egress_trust.rs b/crates/busbar-kernel/src/plane_host/egress_trust.rs index ef357b7616..1034741283 100644 --- a/crates/busbar-kernel/src/plane_host/egress_trust.rs +++ b/crates/busbar-kernel/src/plane_host/egress_trust.rs @@ -120,7 +120,7 @@ pub trait EgressTrustHost: Send + Sync { /// THE NAME RESOLUTION a kernel dial's resolve-then-pin asks for (row 130 E2): every address /// `host` answers with, de-duplicated, unsorted, unfiltered (the guard judges all of them). The - /// lookup is the connector's (`busbar_core_connector::process::SystemNames`): the kernel opens no + /// lookup is the connector's (`busbar_core_connector::hostio::SystemNames`): the kernel opens no /// socket, a resolver's included. The pass-through has none behind it, so a production build /// that installed no guarded capability refuses every name (FAIL CLOSED); a test build resolves /// through the test fixtures' system lookup. diff --git a/crates/busbar/src/root/connector.rs b/crates/busbar/src/root/connector.rs index f0c39d25ac..8d22bbc09c 100644 --- a/crates/busbar/src/root/connector.rs +++ b/crates/busbar/src/root/connector.rs @@ -166,12 +166,12 @@ pub fn guard_for(d: &Destinations) -> Result, String> { /// TLS wrap every kernel outbound connection is secured with — the connector's /// (`busbar_core_connector::tls::engine::Layer`; THE DESIGN: TLS stays in the connector, and the /// kernel names no TLS library), and the one name lookup every kernel resolve-then-pin asks -/// (`busbar_core_connector::process::SystemNames`; row 130 E2: the kernel resolves no name itself). +/// (`busbar_core_connector::hostio::SystemNames`; row 130 E2: the kernel resolves no name itself). pub fn install_egress_trust(dest: Arc) { install_egress_trust_host(Arc::new(GuardedEgressTrust( dest, Arc::new(busbar_core_connector::tls::engine::Layer), - Arc::new(process::SystemNames), + Arc::new(busbar_core_connector::hostio::SystemNames), ))); } diff --git a/qa/kind-isolation.toml b/qa/kind-isolation.toml index ef0d7aa72e..823b6a5cba 100644 --- a/qa/kind-isolation.toml +++ b/qa/kind-isolation.toml @@ -904,7 +904,7 @@ to = "busbar-kernel-egress" half = "shipped" count = "1" verdict = "allowed" -cite = "BUSBAR-1.6.0.md:3780 (R2/#37) and ARCHITECT ruling: cleanliness crates are compiled-in with a one-way dependency on the kernel; the root links them (ARCHITECTURE_ALLOWED cleanliness -> kernel). Here it reads the egress unit's one http(s) URL reader (trust::net::split_url) and implements its Resolver seam with the system name lookup the kernel's resolve-then-pin dials ask (process::SystemNames): the kernel resolves no name itself (status row 130 E2; THE DESIGN §5: the one destination check is the connector's)." +cite = "BUSBAR-1.6.0.md:3780 (R2/#37) and ARCHITECT ruling: cleanliness crates are compiled-in with a one-way dependency on the kernel; the root links them (ARCHITECTURE_ALLOWED cleanliness -> kernel). Here it reads the egress unit's one http(s) URL reader (trust::net::split_url) and implements its Resolver seam with the system name lookup the kernel's resolve-then-pin dials ask (hostio::SystemNames): the kernel resolves no name itself (status row 130 E2; THE DESIGN §5: the one destination check is the connector's)." why = "busbar-core-connector names busbar-kernel-egress: declared normal in the shipped graph, 1 declaration(s) at this commit. The count is TODAY'S MEASUREMENT, exact in both directions — above it is the landing that grew the coupling, below it is stale slack that was not drained on the commit that drained the edge." drain = "none: BUSBAR-1.6.0.md grants this class. Draining it would be an architecture change and not a landing — what this row holds is the NUMBER, so a second declaration of a granted edge is still a landing that has to say why." From 891c394a902feacf4a6593c573f7d8cf521029b4 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:14:04 -0700 Subject: [PATCH 15/59] decisions plane: the arrival count is the SDK's per-instance keyed state, not an atomic the plane holds busbar-plane-decisions::purity the_plane_holds_no_interior_state failed on the merged tree: 4809d85794 (#806) gave DecisionsDoor an `arrivals: AtomicU64` (plane_door.rs:47 import, :427 field, :496 init, :631 fetch_add) to number each kept unit (Unit::seq) for the stale-unit reclaim. An atomic is interior state the plane holds of its own, which the purity cell forbids by name. BUSBAR-1.6.0.md l.345-346 (THE DESIGN, plugins): "A plugin holds no process-global state: what it keeps is per-instance". The SDK's Keyed is that per-instance state, locked by the SDK for a plugin that holds no lock of its own (busbar-contract abi/sdk/publish.rs, "PER-INSTANCE KEYED STATE"); the door already keeps its units and models there, and the mcp plane keeps its one driver value as Keyed<(), _> (tool_door.rs:185). The count moves into Keyed<(), u64>, taken and advanced under the SDK's lock (wrapping, as fetch_add did). No behaviour changes: each arrival still gets the next number before its unit is kept, and reclaim_stale reads it as before. The purity cell is unchanged. --- crates/busbar-plane-decisions/src/plane_door.rs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/crates/busbar-plane-decisions/src/plane_door.rs b/crates/busbar-plane-decisions/src/plane_door.rs index c56b83f354..4c4ab49c36 100644 --- a/crates/busbar-plane-decisions/src/plane_door.rs +++ b/crates/busbar-plane-decisions/src/plane_door.rs @@ -44,7 +44,6 @@ use std::collections::BTreeMap; use std::mem::size_of; use std::ptr; -use std::sync::atomic::{AtomicU64, Ordering}; use busbar_contract::abi::host::conn::connector::{ Need, DIRECTION_OUTBOUND, EGRESS_PROVIDER, KEEP_NAMED, @@ -423,8 +422,10 @@ pub struct DecisionsDoor { /// The models a claimed arrival routes over, directly, by name: every model the latest /// generation configures, each with its dialect and `upstream_model`. models: Keyed, - /// How many arrivals the instance has kept: each unit's [`Unit::seq`]. - arrivals: AtomicU64, + /// How many arrivals the instance has kept: each unit's [`Unit::seq`]. The count is the + /// SDK's per-instance keyed state, its one entry under `()`: the plane holds no interior + /// state of its own. + arrivals: Keyed<(), u64>, /// Each provider's protocol, as the host stated it at `open` (`PlaneOpenIn::providers`). protocols: BTreeMap, } @@ -493,7 +494,7 @@ slot!( generations: Generations::new(), units: Keyed::new(), models: Keyed::new(), - arrivals: AtomicU64::new(0), + arrivals: Keyed::new(), protocols, }; let spec = generation_spec(&routed); @@ -628,7 +629,12 @@ slot!( ) })); let unit = input.get().unit; - let seq = plane.arrivals.fetch_add(1, Ordering::Relaxed); + let seq = plane.arrivals.with_all(|n| { + let next = n.entry(()).or_insert(0); + let seq = *next; + *next = seq.wrapping_add(1); + seq + }); let kept = plane.units.with_all(|units| { reclaim_stale(units, unit, seq); let Some(kept) = unit_of(units, unit) else { From 9ed8689a3cd0470b100c69cef4820905ccf76455 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:53:00 -0700 Subject: [PATCH 16/59] flakes (rows 161, 162): drain-scaling test judges CPU time; neutrality/scrape-shape tests wait for admin bound and dump the node log on panic 161: drain_frames_checked_scales_linearly_in_frame_count read a wall-clock ratio (8.3x under prover load). It now reads the thread's CPU time (/proc/thread-self/schedstat, Law 8, as the xtask selftest budget of #790); the 8x bound, the 1ms floor and min-of-3 are unchanged, and quadratic still reads ~16x. 162: no_data_dir_neutrality and scrape_shape_1_5_5 fired the admin call on the first 'busbar listening' line (data worker 0), before admin is bound. They wait for both lines via the shared common::boot helper, and hold common::boot::DumpOnPanic so a panic prints the node's out.log. --- .../src/codec/tests/eventstream_tests.rs | 29 ++++++++++++++++--- crates/busbar/tests/common/boot.rs | 25 ++++++++++++++++ crates/busbar/tests/no_data_dir_neutrality.rs | 6 ++-- crates/busbar/tests/scrape_shape_1_5_5.rs | 6 ++-- 4 files changed, 58 insertions(+), 8 deletions(-) diff --git a/crates/busbar-plane-llm/src/codec/tests/eventstream_tests.rs b/crates/busbar-plane-llm/src/codec/tests/eventstream_tests.rs index efb2ad68ec..9b0bcb6018 100644 --- a/crates/busbar-plane-llm/src/codec/tests/eventstream_tests.rs +++ b/crates/busbar-plane-llm/src/codec/tests/eventstream_tests.rs @@ -867,8 +867,8 @@ fn a_malformed_prelude_after_valid_frames_still_clears_and_reports_the_prefix() /// Complexity regression: `drain_frames_checked` used `Vec::drain(..total_len)` PER FRAME, which /// memmoves the entire remaining tail on every call — O(frames × bytes) for a buffer of many /// small frames. The index-tracking rewrite does ONE buffer edit for the whole pass, O(bytes). -/// A ratio test (not an absolute wall-clock threshold) so it stays machine-independent: quadratic -/// predicts ~16× for a 4× frame-count increase; linear predicts ~4×. +/// A ratio test (not an absolute threshold) of CPU time (not wall clock, which contention +/// inflates) so it stays machine-independent: quadratic predicts ~16× for a 4× frame-count increase; linear predicts ~4×. /// /// WEAKEST TEST IN THIS FILE: if `t(1000)` is too fast to be /// above timer-granularity noise, the ratio is meaningless. The assertion checks that floor first @@ -891,6 +891,23 @@ fn drain_frames_checked_scales_linearly_in_frame_count() { /// rounding error here, and min-of-N only needs ONE uncontended trial to converge. const TRIALS: u32 = 3; + /// This thread's own CPU time, in nanoseconds: the first field of `/proc/thread-self/schedstat` + /// (time spent on a CPU). Contention makes the drain WAIT, which this clock does not count, so + /// a busy machine no longer reads as a dearer drain (Law 8: an instrument must not report by + /// noise; the same unit the xtask selftest budget moved to). `std` has no CPU clock, and a + /// plane crate takes no `libc`, so the kernel's own account is read as a file. + fn thread_cpu_ns() -> Option { + std::fs::read_to_string("/proc/thread-self/schedstat") + .ok()? + .split_whitespace() + .next()? + .parse() + .ok() + } + + /// One drain of `n` frames, as a duration: CPU time where the kernel reports it (Linux, where + /// every run that gates this tree runs), else the wall clock of the same window (macOS), which + /// is why the minimum across trials stays. fn bench(n: usize) -> std::time::Duration { let mut best: Option = None; for _ in 0..TRIALS { @@ -899,10 +916,14 @@ fn drain_frames_checked_scales_linearly_in_frame_count() { let payload = format!("{{\"i\":{i}}}"); buf.extend_from_slice(&encode_frame("contentBlockDelta", payload.as_bytes())); } - let start = std::time::Instant::now(); + let wall = std::time::Instant::now(); + let cpu = thread_cpu_ns(); let (_, status, _) = drain_frames_checked(&mut buf, None); assert_eq!(status, DrainStatus::Ok); - let elapsed = start.elapsed(); + let elapsed = match (cpu, thread_cpu_ns()) { + (Some(before), Some(after)) => std::time::Duration::from_nanos(after - before), + _ => wall.elapsed(), + }; best = Some(match best { Some(b) if b <= elapsed => b, _ => elapsed, diff --git a/crates/busbar/tests/common/boot.rs b/crates/busbar/tests/common/boot.rs index 5d7c6320d3..159657d371 100644 --- a/crates/busbar/tests/common/boot.rs +++ b/crates/busbar/tests/common/boot.rs @@ -12,6 +12,7 @@ //! `busbar --version` to completion once per test process before handing the path out: the //! first-exec cost is paid before any clock starts, and the deadline keeps its value. +use std::path::Path; use std::process::Command; use std::sync::OnceLock; @@ -107,3 +108,27 @@ pub fn try_reserve(port: u16) -> Option { .expect("the port-claim file"); file.try_lock().ok().map(|()| file) } + +/// Whether the node's log shows BOTH listeners up: one "busbar listening" line from the data side +/// (written by data worker 0) and one from the admin side (written once the control thread binds +/// admin). The FIRST line alone is not enough: it lands before admin is bound, so an admin call +/// fired on it is refused (ConnectionRefused). +pub fn log_shows_data_and_admin_listening(log: &str) -> bool { + log.matches("busbar listening").count() >= 2 +} + +/// The node's whole log, printed to the test's stderr when the test panics while this guard is +/// held, so a red shows what the node said (a refused connection names no cause on its own). +pub struct DumpOnPanic<'a>(pub &'a Path); + +impl Drop for DumpOnPanic<'_> { + fn drop(&mut self) { + if std::thread::panicking() { + eprintln!( + "--- the node's log ({}):\n{}", + self.0.display(), + std::fs::read_to_string(self.0).unwrap_or_default() + ); + } + } +} diff --git a/crates/busbar/tests/no_data_dir_neutrality.rs b/crates/busbar/tests/no_data_dir_neutrality.rs index 452ebd5475..74a31c6d6e 100644 --- a/crates/busbar/tests/no_data_dir_neutrality.rs +++ b/crates/busbar/tests/no_data_dir_neutrality.rs @@ -162,6 +162,8 @@ fn no_ledger_series_and_no_keyset_lines_without_data_dir() { .stderr(log_err) .spawn() .expect("spawn busbar"); + // Any panic from here on prints the node's whole log (its stdout and stderr) with it. + let _dump = common::boot::DumpOnPanic(&log_path); let booted = wait_for(Duration::from_secs(30), || { if let Some(status) = child.try_wait().expect("try_wait") { @@ -170,11 +172,11 @@ fn no_ledger_series_and_no_keyset_lines_without_data_dir() { read_to_string(&log_path) ); } - read_to_string(&log_path).contains("busbar listening") + common::boot::log_shows_data_and_admin_listening(&read_to_string(&log_path)) }); assert!( booted, - "busbar did not reach 'listening' within 30s; log:\n{}", + "busbar did not reach 'listening' (data and admin) within 30s; log:\n{}", read_to_string(&log_path) ); // The boot log line lands the instant ANY of the per-core SO_REUSEPORT workers starts listening, diff --git a/crates/busbar/tests/scrape_shape_1_5_5.rs b/crates/busbar/tests/scrape_shape_1_5_5.rs index fb3e411080..04d2580631 100644 --- a/crates/busbar/tests/scrape_shape_1_5_5.rs +++ b/crates/busbar/tests/scrape_shape_1_5_5.rs @@ -212,6 +212,8 @@ fn a_1_5_5_shaped_config_exposes_no_plane_series_with_every_plane_compiled_in() .stderr(log_err) .spawn() .expect("spawn busbar"); + // Any panic from here on prints the node's whole log (its stdout and stderr) with it. + let _dump = common::boot::DumpOnPanic(&log_path); let booted = wait_for(Duration::from_secs(30), || { if let Some(status) = child.try_wait().expect("try_wait") { @@ -220,11 +222,11 @@ fn a_1_5_5_shaped_config_exposes_no_plane_series_with_every_plane_compiled_in() read_to_string(&log_path) ); } - read_to_string(&log_path).contains("busbar listening") + common::boot::log_shows_data_and_admin_listening(&read_to_string(&log_path)) }); assert!( booted, - "busbar did not reach 'listening' within 30s; log:\n{}", + "busbar did not reach 'listening' (data and admin) within 30s; log:\n{}", read_to_string(&log_path) ); From c032f25d43ca8120d1e388f2b787a0571a4c5e9f Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:53:33 -0700 Subject: [PATCH 17/59] flake (row 160): ledger_identity waits for the admin door to answer before minting keys The rig waited for the data door's /healthz, then minted at once; the control thread binds admin after the data workers, so under load the mint saw connection refused (status 0: 'minting a key failed: 0'). Boot now also waits, bounded at 60s and failing with the node's log, for the admin listener to answer. The mint assertion (201) is unchanged. --- crates/busbar/tests/ledger_identity.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/crates/busbar/tests/ledger_identity.rs b/crates/busbar/tests/ledger_identity.rs index aac2eb4a02..ed4704a99e 100644 --- a/crates/busbar/tests/ledger_identity.rs +++ b/crates/busbar/tests/ledger_identity.rs @@ -687,6 +687,25 @@ impl Rig { PORTS.data, read_to_string(&rig.log_path) ); + // The data door answering says nothing about the admin door: the control thread binds it + // after the data workers. Minting before it answers fails as status 0 (connection refused) + // under load. Ready means the admin listener ANSWERS (any HTTP status), so wait for that + // rather than for a delay. + let admin_up = wait_until(Duration::from_secs(60), || { + if let Some(status) = rig.child.try_wait().expect("try_wait") { + panic!( + "busbar exited before its admin door answered (status {status:?}); log:\n{}", + read_to_string(&rig.log_path) + ); + } + get(PORTS.admin, "/api/v1/admin/keys", Some(ADMIN_TOKEN)).status != 0 + }); + assert!( + admin_up.is_some(), + "busbar's admin door did not answer on {}; log:\n{}", + PORTS.admin, + read_to_string(&rig.log_path) + ); rig.key_ok = rig.mint(r#"{"name":"identity-ok","group":"oracle"}"#); rig.key_broke = rig.mint(r#"{"name":"identity-broke","group":"broke"}"#); From 5ae1e6f087920f86350c867652b337877255da21 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:54:41 -0700 Subject: [PATCH 18/59] no-silent-skip sweep (row 114): the scrape-sink test is cfg-gated on its feature, the latency capture is #[ignore] instead of a silent pass --- .../tests/forward_pool_integration_tests.rs | 9 ++++----- crates/busbar/src/root/tests/linked.rs | 16 ++++++++++++---- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/crates/busbar-llm/src/engine/tests/forward_pool_integration_tests.rs b/crates/busbar-llm/src/engine/tests/forward_pool_integration_tests.rs index e0a8263840..8de21f3a52 100644 --- a/crates/busbar-llm/src/engine/tests/forward_pool_integration_tests.rs +++ b/crates/busbar-llm/src/engine/tests/forward_pool_integration_tests.rs @@ -49,17 +49,16 @@ use std::sync::Arc; /// Pre-release latency capture (opt-in). Drives N in-process requests through the full forward path /// against an instant mock upstream and reports the request-handling-time distribution — a /// reproducible latency regression metric that runs in the normal test build (no cert, no key, no -/// external mock). It's a no-op unless `BUSBAR_CAPTURE_METRICS` is set, so it costs nothing in CI. +/// external mock). It is `#[ignore]`d, so a normal run reports it as ignored (never as a pass it did not earn) and it +/// costs nothing in CI. /// /// For release-representative numbers, build under `--release`: -/// BUSBAR_CAPTURE_METRICS=1 cargo test --release -p busbar capture_latency_metrics -- --nocapture +/// cargo test --release -p busbar capture_latency_metrics -- --ignored --nocapture /// Emits a line the site metrics harness parses: `BUSBAR_METRICS busbar.latency.inproc_handle_us ...` #[tokio::test] +#[ignore = "pre-release latency capture: run with --ignored (release build for representative numbers)"] async fn capture_latency_metrics() { crate::testkit::install_test_seams(); - if std::env::var("BUSBAR_CAPTURE_METRICS").is_err() { - return; // opt-in only — normal test runs skip this. - } let n: usize = std::env::var("BUSBAR_CAPTURE_N") .ok() .and_then(|s| s.parse().ok()) diff --git a/crates/busbar/src/root/tests/linked.rs b/crates/busbar/src/root/tests/linked.rs index 70c62fa088..8197b17392 100644 --- a/crates/busbar/src/root/tests/linked.rs +++ b/crates/busbar/src/root/tests/linked.rs @@ -550,16 +550,24 @@ fn every_linked_section_row_is_a_linked_doors_declaring_section() { /// every family type the recorder writes (a HELP-less counter, labels with escapes, a histogram, a /// quantile summary), the sink answers exactly those bytes. Moved here from busbar-kernel's /// `export/tests/scrape_tests.rs` (R-FIX3: the kernel's own tests run on the stand-in axis's scrape -/// double). A build that links no scrape sink has no sink to prove. +/// double). Compiled only where the scrape sink is linked (`export-prometheus`): a build without it +/// has no sink to prove, and reports no vacuous pass; where it is linked, an absent module word or +/// an unresolved row is a failure. +#[cfg(feature = "export-prometheus")] #[test] fn the_linked_scrape_sink_renders_the_recorder_snapshot_byte_identically() { let module = busbar_kernel::test_support::export_axis::scrape_module(); let registry = PluginRegistry::empty() .link(linked_exports(crate::LINKED.export_doors).expect("the linked export rows")) .expect("the linked rows alone"); - if module.is_empty() || registry.resolve(module).is_none() { - return; - } + assert!( + !module.is_empty(), + "the build links the scrape sink, so the frozen scrape module word is set" + ); + assert!( + registry.resolve(module).is_some(), + "the linked export rows carry the scrape sink under `{module}`" + ); let sink = crate::root::loader::export_axis::ExportRows::new( ®istry, crate::root::dispatch::dispatcher(), From 442a2d684e290d4b78e5b08063fa19a60e145609 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:57:30 -0700 Subject: [PATCH 19/59] no-silent-skip: drop the busbar-llm edit (row 155 deletes the crate) --- .../src/engine/tests/forward_pool_integration_tests.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/busbar-llm/src/engine/tests/forward_pool_integration_tests.rs b/crates/busbar-llm/src/engine/tests/forward_pool_integration_tests.rs index 8de21f3a52..e0a8263840 100644 --- a/crates/busbar-llm/src/engine/tests/forward_pool_integration_tests.rs +++ b/crates/busbar-llm/src/engine/tests/forward_pool_integration_tests.rs @@ -49,16 +49,17 @@ use std::sync::Arc; /// Pre-release latency capture (opt-in). Drives N in-process requests through the full forward path /// against an instant mock upstream and reports the request-handling-time distribution — a /// reproducible latency regression metric that runs in the normal test build (no cert, no key, no -/// external mock). It is `#[ignore]`d, so a normal run reports it as ignored (never as a pass it did not earn) and it -/// costs nothing in CI. +/// external mock). It's a no-op unless `BUSBAR_CAPTURE_METRICS` is set, so it costs nothing in CI. /// /// For release-representative numbers, build under `--release`: -/// cargo test --release -p busbar capture_latency_metrics -- --ignored --nocapture +/// BUSBAR_CAPTURE_METRICS=1 cargo test --release -p busbar capture_latency_metrics -- --nocapture /// Emits a line the site metrics harness parses: `BUSBAR_METRICS busbar.latency.inproc_handle_us ...` #[tokio::test] -#[ignore = "pre-release latency capture: run with --ignored (release build for representative numbers)"] async fn capture_latency_metrics() { crate::testkit::install_test_seams(); + if std::env::var("BUSBAR_CAPTURE_METRICS").is_err() { + return; // opt-in only — normal test runs skip this. + } let n: usize = std::env::var("BUSBAR_CAPTURE_N") .ok() .and_then(|s| s.parse().ok()) From 74a45bd554a0d6b3ee9ebf15b25a33d7e3555299 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:20:37 -0700 Subject: [PATCH 20/59] connector: the datagram driver presents the keying-material item once, verified, and sends only where the secure layer allows (row 132, piece 2) One association's datagram framer, driven over the host's dtls engine with the datagram lane on every call (THE DESIGN l.758-766): paths are host numbers, told once, and only for addresses the secure layer knows; the rendezvous terms build the association; the exported keying material crosses once, on a call whose lane says the far end is verified, and is dropped (zeroed) after it; clear routes leave only where may_send_clear allows, secured routes are sealed to the bound path; path requests bind only a proven path. Every answer is judged by check_datagram before a byte of it is used. Sans-IO; accepting side. Tests (src/tests/datagram_tests.rs), a scripted framer over a real ring handshake against a second dtls engine: the item crosses exactly once, verified, equal to the far end's export (RED: never or every call); a spoofed source never reaches the framer (RED: the honest check does); a rebind to an unanswered path is not applied and a non-answer clear route is dropped (RED: answered, it binds); a lane-breaking answer is a FAULT. --- crates/busbar-core-connector/src/datagram.rs | 772 ++++++++++++++++++ crates/busbar-core-connector/src/dtls/mod.rs | 25 + crates/busbar-core-connector/src/framer.rs | 20 +- crates/busbar-core-connector/src/lib.rs | 4 + .../src/tests/datagram_tests.rs | 626 ++++++++++++++ 5 files changed, 1442 insertions(+), 5 deletions(-) create mode 100644 crates/busbar-core-connector/src/datagram.rs create mode 100644 crates/busbar-core-connector/src/tests/datagram_tests.rs diff --git a/crates/busbar-core-connector/src/datagram.rs b/crates/busbar-core-connector/src/datagram.rs new file mode 100644 index 0000000000..c68dec6788 --- /dev/null +++ b/crates/busbar-core-connector/src/datagram.rs @@ -0,0 +1,772 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! THE DATAGRAM DRIVER: one association's datagram framer, driven host-side over the host's port +//! and secure layer (`BUSBAR-1.6.0.md` THE DESIGN §5, "Datagram media", l.758-766). +//! +//! The port is the host's ([`crate::udp`]) and so is the secure layer ([`crate::dtls`]); a framer +//! whose tail states `FRAMING_DATAGRAM` holds what is left. This driver stands between them for ONE +//! association and carries THE DATAGRAM LANE (`busbar_contract::abi::transport::datagram`) on every +//! call it makes: +//! +//! * PATHS ARE HOST NUMBERS. A far-end address gets a nonzero number the first time the driver +//! hands up a datagram from it, and the framer is told it once, in the lane's `paths`. Only an +//! address the secure layer knows (a verified check came from it, or core's own probe proved it) +//! is ever handed up; anything else from the port stops here. +//! * THE RENDEZVOUS. The far end's description goes in on the rendezvous lane. The framer answers +//! its own description as a piece and the terms; the terms build the secure layer's association +//! (the handshake role, both ends' check credentials and the far end's certificate fingerprint). +//! * DATAGRAMS go through the secure layer's demultiplexer first ([`Association::ingest`]): what it +//! hands up reaches the framer on the clear lane, and plaintext it opened on the secured lane. +//! * THE KEYING-MATERIAL ITEM. What the secure layer exported is presented ONCE, on the first call +//! after the export, in host memory valid for that call, and then dropped, which zeroes it. It is +//! never presented before the far end is verified: the secure layer exports nothing until the far +//! end's certificate matched its description, and the lane's `verified` bit says so on the same +//! call. +//! * ROUTES. A clear route leaves only where the secure layer allows it +//! ([`Association::may_send_clear`]: the answer to a check to a path that sent one, anything else +//! to the bound path); a secured route is sealed to the bound path. Any other route is dropped +//! and counted ([`DatagramFraming::refused_routes`]). +//! * PATH REQUESTS go to the secure layer, which binds only a path it has proven by its own round +//! trip; the lane's `bound` says where it stands on every call after. +//! * Every answer is judged (`check_datagram`) before any byte of it is used. +//! +//! Sans-IO: the caller hands in what the port read and the clock, takes +//! [`DatagramFraming::poll_send`] out to the port, and calls [`DatagramFraming::timeout`] at +//! [`DatagramFraming::deadline`]. + +use std::collections::{HashMap, VecDeque}; +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Instant; + +use busbar_contract::abi::mechanism::call::{AbiStr, Outcome}; +use busbar_contract::abi::sdk::door::{blank_in, blank_out}; +use busbar_contract::abi::transport::check::check_datagram; +use busbar_contract::abi::transport::{ + BeginIn, ConnFacts, DatagramLane, DatagramPath, DatagramRoute, EmitIn, FinishIn, FramerOut, + FramingIn, IngestIn, KeyingMaterial, RendezvousTerms, CLOSE_NORMAL, EMIT_TEXT, + FINGERPRINT_BYTES, HANDSHAKE_ANSWERS, HANDSHAKE_INITIATES, HANDSHAKE_NONE, LANE_CLEAR, + LANE_RENDEZVOUS, LANE_SECURED, PATH_REQUEST_BIND, PATH_REQUEST_REBIND, SIDE_ACCEPT, +}; + +use crate::dtls::{Association, IceCredentials, Role, SessionCert, SrtpKeying, Up, Verdict}; +use crate::framer::{instant_of, ready, Buffers, Call, Crossed, FramerDoor, Refused, Yielded}; + +/// The routes one framer answer may write. +const ROUTES_CAP: usize = 64; +/// The most far-end addresses one association numbers at once. +const MAX_PATHS: usize = 64; + +const NO_TEXT: AbiStr = AbiStr { + ptr: std::ptr::null(), + len: 0, +}; + +fn text(b: &[u8]) -> AbiStr { + if b.is_empty() { + return NO_TEXT; + } + AbiStr { + ptr: b.as_ptr(), + len: b.len(), + } +} + +fn fault(why: impl Into) -> Refused { + Refused { + outcome: Outcome::Fault, + error: why.into(), + } +} + +/// The host's numbers for the far-end addresses it has handed up. +#[derive(Debug, Default)] +struct PathBook { + by_addr: HashMap, + by_path: HashMap, + /// Numbered, not yet told to the framer. + untold: Vec, + last: u32, +} + +impl PathBook { + fn path(&self, addr: SocketAddr) -> u32 { + self.by_addr.get(&addr).copied().unwrap_or(0) + } + + fn addr(&self, path: u32) -> Option { + self.by_path.get(&path).copied() + } + + /// `addr`'s number, numbering it now if it has none. At the cap the numbers of addresses the + /// secure layer no longer knows make room (`keep` says which it still knows); a number is never + /// reused. + fn number(&mut self, addr: SocketAddr, keep: impl Fn(SocketAddr) -> bool) -> Option { + if let Some(p) = self.by_addr.get(&addr) { + return Some(*p); + } + if self.by_addr.len() >= MAX_PATHS { + let gone: Vec = + self.by_addr.keys().copied().filter(|a| !keep(*a)).collect(); + for a in gone { + if let Some(p) = self.by_addr.remove(&a) { + self.by_path.remove(&p); + self.untold.retain(|u| *u != p); + } + } + if self.by_addr.len() >= MAX_PATHS { + return None; + } + } + self.last = self.last.checked_add(1)?; + self.by_addr.insert(addr, self.last); + self.by_path.insert(self.last, addr); + self.untold.push(self.last); + Some(self.last) + } +} + +/// One framer op, as this driver makes it. +#[derive(Clone, Copy)] +enum Op<'a> { + Begin, + /// Bytes on a lane (`0` = a call that only carries the host's state); `end` = the far end + /// closed. + Ingest { + lane: u32, + from: Option, + bytes: &'a [u8], + end: bool, + }, + Emit { + stream: u64, + bytes: &'a [u8], + end_of_frame: bool, + text: bool, + }, + Timer, +} + +impl Op<'_> { + /// The re-call a `YIELD_MORE` answer asks for: the same op, no new bytes. + fn again(self) -> Self { + match self { + Op::Begin | Op::Timer => Op::Timer, + Op::Ingest { .. } => Op::Ingest { + lane: 0, + from: None, + bytes: &[], + end: false, + }, + Op::Emit { stream, .. } => Op::Emit { + stream, + bytes: &[], + end_of_frame: false, + text: false, + }, + } + } +} + +/// What a framer answer asked of the host, copied out of its buffers. +#[derive(Default)] +struct Asked { + routes: Vec<(u32, u32, Vec)>, + request: (u32, u32, u32), + terms: Option, +} + +/// Rendezvous terms, owned. +struct Terms { + role: Role, + local: IceCredentials, + remote: IceCredentials, + fingerprint: [u8; 32], +} + +/// ONE ASSOCIATION'S DATAGRAM FRAMER, driven over the host's port and secure layer (module docs). +pub struct DatagramFraming { + door: Arc, + token: u64, + bufs: Buffers, + routes: Vec, + cert: Arc, + /// The host port's address, as text: the one candidate the framer advertises. + local: String, + secure: Option, + book: PathBook, + /// Exported by the secure layer, not yet presented. + keying: Option, + /// The keying-material item has been presented. + presented: bool, + /// The host state the framer was last told: the bound path and the verified bit. + told: (u32, bool), + send: VecDeque<(SocketAddr, Vec)>, + refused_routes: u64, + deadline_ns: Option, + ended: bool, +} + +impl std::fmt::Debug for DatagramFraming { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DatagramFraming") + .field("entry", &self.door.facts().name) + .field("token", &self.token) + .field("local", &self.local) + .field("presented", &self.presented) + .finish_non_exhaustive() + } +} + +impl DatagramFraming { + /// Begin an ACCEPTING association on `door`: the host's certificate `cert` (the framer gets its + /// public DER, whose fingerprint its description states) and the host port's address `local`. + /// The far end's description follows through [`Self::rendezvous`]. + /// + /// # Errors + /// + /// The entry refused to frame it, or answered outside the lane's rules. + pub fn accept( + door: Arc, + cert: Arc, + local: SocketAddr, + ) -> Result<(Self, Yielded), Refused> { + let mut f = DatagramFraming { + door, + token: 0, + bufs: Buffers::for_side(SIDE_ACCEPT), + routes: vec![DatagramRoute::default(); ROUTES_CAP], + cert, + local: local.to_string(), + secure: None, + book: PathBook::default(), + keying: None, + presented: false, + told: (0, false), + send: VecDeque::new(), + refused_routes: 0, + deadline_ns: None, + ended: false, + }; + let y = f.run(Op::Begin, Instant::now())?; + Ok((f, y)) + } + + /// The far end's description. The framer's own description comes back as a piece, and the + /// terms it settled build the secure layer's association. + /// + /// # Errors + /// + /// The framer refused the description, or answered no terms for it. + pub fn rendezvous(&mut self, description: &[u8], now: Instant) -> Result { + let y = self.run( + Op::Ingest { + lane: LANE_RENDEZVOUS, + from: None, + bytes: description, + end: false, + }, + now, + )?; + if self.secure.is_none() { + return Err(fault("the framer answered a rendezvous with no terms")); + } + Ok(y) + } + + /// One datagram the port read from `from`. Before the rendezvous settled terms nothing on the + /// port is this association's, and it is dropped. + /// + /// # Errors + /// + /// The framer failed the association, or the secure layer did. + pub fn datagram( + &mut self, + from: SocketAddr, + bytes: &[u8], + now: Instant, + ) -> Result { + let Some(secure) = self.secure.as_mut() else { + return Ok(Yielded::default()); + }; + let up = secure.ingest(from, bytes, now) == Verdict::Raw && secure.knows(from); + if !up { + return self.settle(Yielded::default(), now); + } + let known = |a: SocketAddr| secure.knows(a) || secure.bound_path() == Some(a); + if self.book.number(from, known).is_none() { + return self.settle(Yielded::default(), now); + } + self.run( + Op::Ingest { + lane: LANE_CLEAR, + from: Some(from), + bytes, + end: false, + }, + now, + ) + } + + /// One message's bytes for `stream` (`end_of_frame` sends it; `text` = a text message). + /// + /// # Errors + /// + /// The framer refused them. + pub fn emit( + &mut self, + stream: u64, + bytes: &[u8], + end_of_frame: bool, + text: bool, + now: Instant, + ) -> Result { + self.run( + Op::Emit { + stream, + bytes, + end_of_frame, + text, + }, + now, + ) + } + + /// The clock: the secure layer's timers, and the framer's deadline when it has passed. + /// + /// # Errors + /// + /// The framer failed the association, or the secure layer did. + pub fn timeout(&mut self, now: Instant) -> Result { + if let Some(secure) = self.secure.as_mut() { + secure.handle_timeout(now); + } + if self.deadline_ns.is_some_and(|ns| instant_of(ns) <= now) { + self.deadline_ns = None; + return self.run(Op::Timer, now); + } + self.settle(Yielded::default(), now) + } + + /// When [`Self::timeout`] is next owed. + #[must_use] + pub fn deadline(&self) -> Option { + let framer = self.deadline_ns.map(instant_of); + let secure = self.secure.as_ref().and_then(Association::next_timeout); + [framer, secure].into_iter().flatten().min() + } + + /// The next datagram owed to the port, and where it goes. + pub fn poll_send(&mut self) -> Option<(SocketAddr, Vec)> { + self.send.pop_front() + } + + /// How many routes the host refused to send (to a path the secure layer does not allow, or + /// secured before there was a bound path to seal to). + #[must_use] + pub fn refused_routes(&self) -> u64 { + self.refused_routes + } + + /// Whether the keying-material item has been presented to the framer. + #[must_use] + pub fn presented(&self) -> bool { + self.presented + } + + /// The path the secure layer has bound, as the far end's address. + #[must_use] + pub fn bound(&self) -> Option { + self.secure.as_ref().and_then(Association::bound_path) + } + + /// The framer said no frame follows. + #[must_use] + pub fn ended(&self) -> bool { + self.ended + } + + fn verified(&self) -> bool { + self.secure.as_ref().is_some_and(Association::peer_verified) + } + + fn bound_path(&self) -> u32 { + self.bound().map_or(0, |a| self.book.path(a)) + } + + /// `first`, its `YIELD_MORE` re-calls, then whatever the secure layer owes the framer. + fn run(&mut self, first: Op<'_>, now: Instant) -> Result { + let mut y = Yielded::default(); + self.cross(first, &mut y, now)?; + self.settle(y, now) + } + + /// Drain the secure layer: its datagrams to the port, its plaintext to the framer, and the + /// host state (bound path, verified bit, the keying-material item) the framer has not been + /// told. + fn settle(&mut self, mut y: Yielded, now: Instant) -> Result { + loop { + let mut opened = Vec::new(); + let mut closed = false; + if let Some(secure) = self.secure.as_mut() { + while let Some(d) = secure.poll_wire() { + self.send.push_back(d); + } + while let Some(up) = secure.poll_up() { + match up { + Up::Connected => {} + Up::Keyed(k) => { + if self.presented || self.keying.is_some() { + return Err(fault("the secure layer exported keying twice")); + } + self.keying = Some(k); + } + Up::Plaintext(p) => opened.push(p), + Up::Closed => closed = true, + } + } + if let Some(f) = secure.failure() { + return Err(Refused { + outcome: Outcome::Failed, + error: format!("the secure layer failed the association: {f:?}"), + }); + } + } + if self.keying.is_some() && !self.verified() { + return Err(fault("keying material with the far end not verified")); + } + let state = (self.bound_path(), self.verified()); + let owed = state != self.told || self.keying.is_some(); + if opened.is_empty() && !closed && !owed { + y.wire.clear(); + return Ok(y); + } + for p in &opened { + let from = self.bound(); + self.cross( + Op::Ingest { + lane: LANE_SECURED, + from, + bytes: p, + end: false, + }, + &mut y, + now, + )?; + } + if closed || (opened.is_empty() && owed) { + self.cross( + Op::Ingest { + lane: 0, + from: None, + bytes: &[], + end: closed, + }, + &mut y, + now, + )?; + } + } + } + + /// One op and its `YIELD_MORE` re-calls, each carrying the lane, each answer judged and + /// applied. + fn cross(&mut self, first: Op<'_>, y: &mut Yielded, now: Instant) -> Result<(), Refused> { + let mut op = first; + loop { + let o = self.cross_once(op)?; + let done = self.take(&o, y, now)?; + if done { + return Ok(()); + } + op = op.again(); + } + } + + fn cross_once(&mut self, op: Op<'_>) -> Result { + if self.token == 0 && !matches!(op, Op::Begin) { + return Err(fault("the association's framing is closed")); + } + // The lane's host state at this call. Every pointer below is into memory that outlives + // the crossing. + let untold: Vec<(u32, String)> = self + .book + .untold + .iter() + .filter_map(|p| Some((*p, self.book.addr(*p)?.to_string()))) + .collect(); + let paths: Vec = untold + .iter() + .map(|(p, a)| DatagramPath { + path: *p, + _reserved: 0, + addr: text(a.as_bytes()), + }) + .collect(); + let verified = self.verified(); + let keying = self + .keying + .as_ref() + .filter(|_| verified) + .map(|k| KeyingMaterial { + size: u32::try_from(std::mem::size_of::()).unwrap_or(u32::MAX), + profile: k.profile.code(), + bytes: k.material().as_ptr(), + len: k.material().len(), + }); + let (lane_code, from) = match op { + Op::Ingest { lane, from, .. } => (lane, from), + _ => (0, None), + }; + let from_text = from.map(|a| a.to_string()).unwrap_or_default(); + let bound = self.bound_path(); + let lane = DatagramLane { + size: u32::try_from(std::mem::size_of::()).unwrap_or(u32::MAX), + lane: lane_code, + path: from.map_or(0, |a| self.book.path(a)), + bound, + path_addr: text(from_text.as_bytes()), + local_addr: text(self.local.as_bytes()), + verified: u32::from(verified), + _reserved: 0, + keying: keying + .as_ref() + .map_or(std::ptr::null(), |k| k as *const KeyingMaterial), + routes: self.routes.as_mut_ptr(), + routes_cap: self.routes.len(), + paths: if paths.is_empty() { + std::ptr::null() + } else { + paths.as_ptr() + }, + paths_len: paths.len(), + }; + let mut o: FramerOut = blank_out(); + let c: Crossed = match op { + Op::Begin => { + let facts = ConnFacts { + size: u32::try_from(std::mem::size_of::()).unwrap_or(u32::MAX), + _reserved: 0, + offered_name: NO_TEXT, + agreed_protocol: NO_TEXT, + peer_subject: NO_TEXT, + peer_issuer: NO_TEXT, + peer_fingerprint: NO_TEXT, + claim: NO_TEXT, + local_certificate: text(self.cert.public_der()), + }; + let mut i: BeginIn = blank_in(); + i.side = SIDE_ACCEPT; + i.facts = &facts; + i.lane = &lane; + i.sink = self.bufs.sink(); + self.door.cross(Call::Begin(&mut i, &mut o)) + } + Op::Ingest { bytes, end, .. } => { + let mut i: IngestIn = blank_in(); + i.framing = self.token; + i.bytes = bytes.as_ptr(); + i.len = bytes.len(); + i.end = u32::from(end); + i.lane = &lane; + i.sink = self.bufs.sink(); + self.door.cross(Call::Ingest(&mut i, &mut o)) + } + Op::Emit { + stream, + bytes, + end_of_frame, + text, + } => { + let mut i: EmitIn = blank_in(); + i.framing = self.token; + i.stream = stream; + i.bytes = bytes.as_ptr(); + i.len = bytes.len(); + i.end_of_frame = u32::from(end_of_frame); + i.flags = if text { EMIT_TEXT } else { 0 }; + i.lane = &lane; + i.sink = self.bufs.sink(); + self.door.cross(Call::Emit(&mut i, &mut o)) + } + Op::Timer => { + let mut i: FramingIn = blank_in(); + i.framing = self.token; + i.lane = &lane; + i.sink = self.bufs.sink(); + self.door.cross(Call::Timer(&mut i, &mut o)) + } + }; + if let Err(f) = check_datagram(c.outcome, &o, &self.routes, self.routes.len() as u64) { + return Err(fault(format!( + "the framer's datagram answer breaks {:?} at {}", + f.rule, f.field + ))); + } + ready(c)?; + if matches!(op, Op::Begin) { + self.token = o.framing; + } + // What this call carried reached the framer: the paths are told, the host state is the + // one it now holds, and the keying-material item is presented — dropped here, zeroed. + self.book.untold.clear(); + self.told = (bound, verified); + if keying.is_some() { + self.keying = None; + self.presented = true; + } + Ok(o) + } + + /// Copy out and apply what one READY answer asked; `true` = it owes nothing more. + fn take(&mut self, o: &FramerOut, y: &mut Yielded, now: Instant) -> Result { + let d = &o.datagram; + let mut asked = Asked { + request: (d.request, d.request_from, d.request_to), + ..Asked::default() + }; + if d.routes_len != 0 { + let wire = self + .bufs + .wire_written(o.yielded.wire_len) + .ok_or_else(|| fault("the framer's routes are out of bounds"))?; + for r in self.routes.iter().take(d.routes_len as usize) { + let at = usize::try_from(r.offset).map_err(|_| fault("route offset"))?; + let len = usize::try_from(r.len).map_err(|_| fault("route length"))?; + let bytes = wire + .get(at..at.saturating_add(len)) + .ok_or_else(|| fault("the framer's routes are out of bounds"))?; + asked.routes.push((r.path, r.lane, bytes.to_vec())); + } + } + if d.terms.role != HANDSHAKE_NONE { + let frame = self + .bufs + .frame_written(o.yielded.frame_len) + .ok_or_else(|| fault("the framer's terms are out of bounds"))?; + asked.terms = Some(read_terms(&d.terms, frame)?); + } + let done = self.bufs.take(o, y)?; + self.ended |= y.ended; + self.deadline_ns = y.deadline_ns; + if let Some(t) = asked.terms { + if self.secure.is_some() { + return Err(fault("the framer answered terms twice")); + } + self.secure = Some(Association::new( + Arc::clone(&self.cert), + t.role, + &t.local, + &t.remote, + t.fingerprint, + )); + } + for (path, lane, bytes) in asked.routes { + self.route(path, lane, bytes); + } + self.request(asked.request, now); + Ok(done) + } + + /// Send one route where the secure layer allows it, or drop it. + fn route(&mut self, path: u32, lane: u32, bytes: Vec) { + let to = self.book.addr(path); + let sent = match (self.secure.as_mut(), to) { + (Some(secure), Some(to)) if lane == LANE_CLEAR => { + let ok = secure.may_send_clear(to, &bytes); + if ok { + self.send.push_back((to, bytes)); + } + ok + } + (Some(secure), Some(to)) if lane == LANE_SECURED => { + secure.bound_path() == Some(to) && secure.seal(&bytes).is_ok() + } + _ => false, + }; + if !sent { + self.refused_routes += 1; + } + } + + /// A path request, to the secure layer; a refused one leaves the bound path where it is. + fn request(&mut self, (request, from, to): (u32, u32, u32), now: Instant) { + let Some(secure) = self.secure.as_mut() else { + return; + }; + let Some(to) = self.book.addr(to) else { + return; + }; + match request { + PATH_REQUEST_BIND => { + let _ = secure.bind_path(to, now); + } + PATH_REQUEST_REBIND => { + if let Some(from) = self.book.addr(from) { + let _ = secure.rebind_path(from, to, now); + } + } + _ => {} + } + } +} + +/// The rendezvous terms a framer answered, read out of its frame (already judged by +/// `check_terms`). +fn read_terms(t: &RendezvousTerms, frame: &[u8]) -> Result { + let span = |s: busbar_contract::abi::transport::FrameSpan| -> Result<&[u8], Refused> { + let at = usize::try_from(s.offset).map_err(|_| fault("terms span"))?; + let len = usize::try_from(s.len).map_err(|_| fault("terms span"))?; + frame + .get(at..at.saturating_add(len)) + .ok_or_else(|| fault("the framer's terms are out of bounds")) + }; + let utf8 = |s| -> Result { + String::from_utf8(span(s)?.to_vec()) + .map_err(|_| fault("the framer's check credentials are not text")) + }; + let role = match t.role { + HANDSHAKE_INITIATES => Role::Client, + HANDSHAKE_ANSWERS => Role::Server, + _ => return Err(fault("the framer's terms name no handshake role")), + }; + let mut fingerprint = [0_u8; FINGERPRINT_BYTES as usize]; + fingerprint.copy_from_slice( + span(t.peer_fingerprint)? + .get(..fingerprint.len()) + .ok_or_else(|| fault("the far end's fingerprint is short"))?, + ); + Ok(Terms { + role, + local: IceCredentials { + ufrag: utf8(t.local_user)?, + pwd: utf8(t.local_secret)?, + }, + remote: IceCredentials { + ufrag: utf8(t.remote_user)?, + pwd: utf8(t.remote_secret)?, + }, + fingerprint, + }) +} + +impl Drop for DatagramFraming { + fn drop(&mut self) { + if self.token == 0 { + return; + } + let mut i: FinishIn = blank_in(); + i.framing = self.token; + i.reason = CLOSE_NORMAL; + i.sink = self.bufs.sink(); + let mut o: FramerOut = blank_out(); + let _ = self.door.cross(Call::Finish(&mut i, &mut o)); + self.token = 0; + } +} + +#[cfg(test)] +#[allow(unsafe_code)] +#[path = "tests/datagram_tests.rs"] +mod tests; diff --git a/crates/busbar-core-connector/src/dtls/mod.rs b/crates/busbar-core-connector/src/dtls/mod.rs index 454b2af351..9e5d038f5f 100644 --- a/crates/busbar-core-connector/src/dtls/mod.rs +++ b/crates/busbar-core-connector/src/dtls/mod.rs @@ -208,6 +208,17 @@ pub enum SrtpProfile { AeadAes256Gcm, } +impl SrtpProfile { + /// The profile's registry code (RFC 7714 §14.2), as the keying-material item names it. + #[must_use] + pub fn code(self) -> u32 { + match self { + SrtpProfile::AeadAes128Gcm => 0x0007, + SrtpProfile::AeadAes256Gcm => 0x0008, + } + } +} + /// The DTLS-SRTP exporter's output (RFC 5764 §4.2): the profile and the keying material, zeroed /// on drop. The one secret the framer holds — it protects media, like the headers a request framer /// sees. @@ -564,6 +575,20 @@ impl Association { self.bound } + /// Whether a verified check came from `path` (or core's own probe proved it) and consent has + /// not aged it out: the only paths whose datagrams the framer is handed. + #[must_use] + pub fn knows(&self, path: SocketAddr) -> bool { + self.heard.contains_key(&path) || self.proven.contains_key(&path) + } + + /// Whether the far end's certificate matched the fingerprint its description carried. Nothing + /// is exported, connected or opened before it does. + #[must_use] + pub fn peer_verified(&self) -> bool { + self.peer_verified && self.failed.is_none() + } + /// Record a verified check from `path`. At the cap the oldest heard path that is neither bound /// nor awaited makes room, so a flood of spoofed sources can neither grow the set nor lock a /// real new path out of it. diff --git a/crates/busbar-core-connector/src/framer.rs b/crates/busbar-core-connector/src/framer.rs index 646b2881f6..1bf52e1b20 100644 --- a/crates/busbar-core-connector/src/framer.rs +++ b/crates/busbar-core-connector/src/framer.rs @@ -130,7 +130,7 @@ impl std::fmt::Display for Refused { impl std::error::Error for Refused {} -fn refused(c: Crossed) -> Refused { +pub(crate) fn refused(c: Crossed) -> Refused { Refused { outcome: c.outcome, error: String::from_utf8_lossy(c.error.as_deref().unwrap_or_default()).into_owned(), @@ -374,14 +374,14 @@ impl Buffers { } /// The default buffers for a framing on `side`. - fn for_side(side: u32) -> Self { + pub(crate) fn for_side(side: u32) -> Self { Self { side, ..Self::default() } } - fn sink(&mut self) -> FramerSink { + pub(crate) fn sink(&mut self) -> FramerSink { FramerSink { wire: self.wire.as_mut_ptr(), wire_cap: self.wire.len(), @@ -396,8 +396,18 @@ impl Buffers { } } + /// The first `len` bytes the last answer wrote into the wire buffer; `None` = out of bounds. + pub(crate) fn wire_written(&self, len: u64) -> Option<&[u8]> { + self.wire.get(..usize::try_from(len).ok()?) + } + + /// The first `len` bytes the last answer wrote into the frame buffer; `None` = out of bounds. + pub(crate) fn frame_written(&self, len: u64) -> Option<&[u8]> { + self.frame.get(..usize::try_from(len).ok()?) + } + /// Take what one READY answer wrote; `false` = it owes more (`YIELD_MORE`). - fn take(&mut self, o: &FramerOut, into: &mut Yielded) -> Result { + pub(crate) fn take(&mut self, o: &FramerOut, into: &mut Yielded) -> Result { let y = &o.yielded; let bad = |what: &str| Refused { outcome: Outcome::Fault, @@ -545,7 +555,7 @@ impl std::fmt::Debug for Framing { } } -fn ready(c: Crossed) -> Result<(), Refused> { +pub(crate) fn ready(c: Crossed) -> Result<(), Refused> { if c.outcome == Outcome::Ready { Ok(()) } else { diff --git a/crates/busbar-core-connector/src/lib.rs b/crates/busbar-core-connector/src/lib.rs index 34d46a8334..1f53eb31ff 100644 --- a/crates/busbar-core-connector/src/lib.rs +++ b/crates/busbar-core-connector/src/lib.rs @@ -19,6 +19,9 @@ //! * [`dtls`] is its datagram sibling — the DTLS engine a media association runs on (the RFC 7983 //! demux, the ICE-gated bind, the SRTP exporter), on ring like [`tls`]. //! * [`udp`] is the host's datagram socket: one bound port, a peer per datagram. +//! * [`datagram`] drives one association's datagram framer over the port and the dtls engine: the +//! datagram lane on every call, the keying-material item presented once, routes and path +//! requests only where the secure layer allows them. //! //! * [`hostio`] is THE HOST'S I/O (`io.*`): the one place that holds an OS handle (a stream, a //! listener, a program's pipes) and moves bytes over it, admitting only what the destination @@ -47,6 +50,7 @@ pub mod carrier; pub mod compose; +pub mod datagram; pub mod dtls; pub mod endpoint; pub mod framed_stream; diff --git a/crates/busbar-core-connector/src/tests/datagram_tests.rs b/crates/busbar-core-connector/src/tests/datagram_tests.rs new file mode 100644 index 0000000000..0372b928a9 --- /dev/null +++ b/crates/busbar-core-connector/src/tests/datagram_tests.rs @@ -0,0 +1,626 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! THE DATAGRAM DRIVER, end to end over a real handshake: a scripted datagram framer behind the +//! [`FramerDoor`] seam (an ICE-lite agent: it answers checks, asks to bind the path a check came +//! from, and hands secured plaintext up on stream `1`), driven by [`DatagramFraming`] over the +//! host's dtls engine, against a far end that is a second dtls engine plus the test acting as its +//! full ICE agent. +//! +//! What is proven, each with its RED arm: +//! * the keying-material item reaches the framer exactly ONCE, on a call whose lane says the far end +//! is verified, and its bytes are what the far end's own exporter produced; +//! * a datagram from a source that sent no verified check never reaches the framer, and no path +//! number is told for it; +//! * a rebind to a path that never answered core's probe is not applied, and a clear route to a +//! heard but unbound path that is not a check's answer is dropped and counted; +//! * a framer answer that breaks the datagram lane's rules is a FAULT before any byte is used. + +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use busbar_contract::abi::mechanism::call::Outcome; +use busbar_contract::abi::transport::{ + DatagramLane, DatagramRoute, FramePiece, FrameSpan, FramerOut, FramerSink, RendezvousTerms, + HANDSHAKE_ANSWERS, LANE_CLEAR, LANE_RENDEZVOUS, LANE_SECURED, PATH_REQUEST_BIND, + PATH_REQUEST_REBIND, PIECE_END_OF_FRAME, ROLE_FRAMER, +}; + +use super::DatagramFraming; +use crate::dtls::{Association, IceCredentials, Role, SessionCert, SrtpKeying, Up, Verdict}; +use crate::framer::{Call, Crossed, DoorFacts, FramerDoor}; + +const HOST_UFRAG: &str = "hostfrag"; +const HOST_PWD: &str = "host-password-0123456789"; +const FAR_UFRAG: &str = "farfrag"; +const FAR_PWD: &str = "far-password-0123456789"; + +fn p(s: &str) -> SocketAddr { + s.parse().expect("addr") +} + +fn creds(ufrag: &str, pwd: &str) -> IceCredentials { + IceCredentials { + ufrag: ufrag.into(), + pwd: pwd.into(), + } +} + +// ── STUN, as a full ICE agent writes it ───────────────────────────────────────────────────────── + +/// A Binding request to the owner of `to`: USERNAME "to.ufrag:from_ufrag", MESSAGE-INTEGRITY under +/// `key` (the receiver's password when honest). +fn check(to_ufrag: &str, from_ufrag: &str, key: &str, txid: u8) -> Vec { + let username = format!("{to_ufrag}:{from_ufrag}").into_bytes(); + let padded = username.len().div_ceil(4) * 4; + let mut m = vec![0x00, 0x01, 0, 0, 0x21, 0x12, 0xa4, 0x42]; + m.extend_from_slice(&[txid; 12]); + m.extend_from_slice(&0x0006_u16.to_be_bytes()); + m.extend_from_slice(&u16::try_from(username.len()).unwrap().to_be_bytes()); + m.extend_from_slice(&username); + m.resize(20 + 4 + padded, 0); + let covered_len = u16::try_from(m.len() - 20 + 24).unwrap(); + m[2..4].copy_from_slice(&covered_len.to_be_bytes()); + sign(m, key) +} + +/// The success response to the request `req`: same transaction id, MESSAGE-INTEGRITY under `pwd` +/// (the answering agent's own password). +fn answer(req: &[u8], pwd: &str) -> Vec { + let mut m = vec![0x01, 0x01, 0, 0, 0x21, 0x12, 0xa4, 0x42]; + m.extend_from_slice(&req[8..20]); + let covered_len = u16::try_from(m.len() - 20 + 24).unwrap(); + m[2..4].copy_from_slice(&covered_len.to_be_bytes()); + sign(m, pwd) +} + +fn sign(mut m: Vec, key: &str) -> Vec { + let key = ::ring::hmac::Key::new(::ring::hmac::HMAC_SHA1_FOR_LEGACY_USE_ONLY, key.as_bytes()); + let tag = ::ring::hmac::sign(&key, &m); + m.extend_from_slice(&0x0008_u16.to_be_bytes()); + m.extend_from_slice(&20_u16.to_be_bytes()); + m.extend_from_slice(tag.as_ref()); + m +} + +fn is_request(d: &[u8]) -> bool { + d.len() >= 20 && d[0..2] == [0x00, 0x01] +} + +// ── the scripted framer ───────────────────────────────────────────────────────────────────────── + +/// One call the framer saw: the lane's host state as it arrived. +#[derive(Debug, Clone, Default)] +struct Seen { + lane: u32, + path: u32, + bound: u32, + verified: bool, + keying: Option<(u32, Vec)>, + told: Vec<(u32, String)>, +} + +#[derive(Debug, Clone, Copy, Default)] +struct Knobs { + /// Answer the rendezvous with a zero-length route. + bad_route: bool, + /// On a check from a path other than the bound one: ask to move there, and route it a clear + /// datagram that is not a check's answer. + rebind_new: bool, +} + +#[derive(Default)] +struct St { + seen: Vec, + addrs: HashMap, + remote: Option<(String, String, Vec)>, + secured: Vec>, + routes: Vec<(u32, u32, Vec)>, + pieces: Vec<(u64, Vec)>, + terms: bool, + request: (u32, u32, u32), +} + +struct Scripted { + facts: DoorFacts, + knobs: Knobs, + st: Mutex, +} + +fn scripted(knobs: Knobs) -> Arc { + Arc::new(Scripted { + facts: DoorFacts { + name: "scripted".into(), + claims: vec!["scripted"], + role: ROLE_FRAMER, + composes_over: Vec::new(), + ported: false, + status_rows: Vec::new(), + duplex: Vec::new(), + }, + knobs, + st: Mutex::new(St::default()), + }) +} + +fn raw<'a>(ptr: *const u8, len: usize) -> &'a [u8] { + if ptr.is_null() || len == 0 { + return &[]; + } + // SAFETY: host-lent bytes, valid for the call. + unsafe { std::slice::from_raw_parts(ptr, len) } +} + +/// Read the lane: what arrived on it and the host's state. +fn read_lane(st: &mut St, lane: *const DatagramLane) -> Option<&'static DatagramLane> { + if lane.is_null() { + return None; + } + // SAFETY: host-lent lane, valid for the call; the test reads it only during the call. + let l: &'static DatagramLane = unsafe { &*lane }; + let mut seen = Seen { + lane: l.lane, + path: l.path, + bound: l.bound, + verified: l.verified == 1, + ..Seen::default() + }; + if !l.keying.is_null() { + // SAFETY: as above. + let k = unsafe { &*l.keying }; + seen.keying = Some((k.profile, raw(k.bytes, k.len).to_vec())); + } + if !l.paths.is_null() { + // SAFETY: as above, `paths_len` of them. + let paths = unsafe { std::slice::from_raw_parts(l.paths, l.paths_len) }; + for path in paths { + let addr = String::from_utf8_lossy(raw(path.addr.ptr, path.addr.len)).into_owned(); + st.addrs.insert(path.path, addr.clone()); + seen.told.push((path.path, addr)); + } + } + st.seen.push(seen); + Some(l) +} + +/// Write what the framer owes into the sink and the lane's routes. +fn write_answer(st: &mut St, sink: &FramerSink, lane: Option<&DatagramLane>, o: &mut FramerOut) { + let mut wire_at = 0_usize; + let mut frame_at = 0_usize; + let mut frame = |b: &[u8]| -> FrameSpan { + assert!(frame_at + b.len() <= sink.frame_cap); + // SAFETY: the host's frame buffer, bounded above. + unsafe { std::ptr::copy_nonoverlapping(b.as_ptr(), sink.frame.add(frame_at), b.len()) }; + let s = FrameSpan { + offset: frame_at as u64, + len: b.len() as u64, + }; + frame_at += b.len(); + s + }; + if let Some(lane) = lane { + let mut n = 0_usize; + for (path, l, bytes) in st.routes.drain(..) { + assert!(n < lane.routes_cap && wire_at + bytes.len() <= sink.wire_cap); + // SAFETY: the host's wire buffer and route buffer, bounded above. + unsafe { + std::ptr::copy_nonoverlapping(bytes.as_ptr(), sink.wire.add(wire_at), bytes.len()); + lane.routes.add(n).write(DatagramRoute { + offset: wire_at as u64, + len: bytes.len() as u64, + path, + lane: l, + }); + } + wire_at += bytes.len(); + n += 1; + } + o.datagram.routes_len = n as u32; + ( + o.datagram.request, + o.datagram.request_from, + o.datagram.request_to, + ) = std::mem::take(&mut st.request); + } + if std::mem::take(&mut st.terms) { + let (ru, rp, fp) = st.remote.clone().expect("terms after a rendezvous"); + o.datagram.terms = RendezvousTerms { + role: HANDSHAKE_ANSWERS, + _reserved: 0, + local_user: frame(HOST_UFRAG.as_bytes()), + local_secret: frame(HOST_PWD.as_bytes()), + remote_user: frame(ru.as_bytes()), + remote_secret: frame(rp.as_bytes()), + peer_fingerprint: frame(&fp), + candidates: FrameSpan::default(), + }; + } + let mut n = 0_usize; + for (stream, bytes) in st.pieces.drain(..) { + let s = frame(&bytes); + assert!(n < sink.pieces_cap); + // SAFETY: the host's piece buffer, bounded above. + unsafe { + sink.pieces.add(n).write(FramePiece { + stream, + offset: s.offset, + len: s.len, + code: 0, + status_class: 0, + _reserved: 0, + flags: PIECE_END_OF_FRAME, + retry_after_secs: 0, + }); + } + n += 1; + } + o.yielded.wire_len = wire_at as u64; + o.yielded.frame_len = frame_at as u64; + o.yielded.pieces_len = n as u32; + o.yielded.flags = 0; +} + +fn unhex(s: &str) -> Vec { + (0..s.len() / 2) + .map(|i| u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).unwrap()) + .collect() +} + +impl FramerDoor for Scripted { + fn facts(&self) -> &DoorFacts { + &self.facts + } + + fn cross(&self, call: Call<'_>) -> Crossed { + let ok = Crossed { + outcome: Outcome::Ready, + error: None, + }; + let mut st = self.st.lock().unwrap(); + match call { + Call::Begin(i, o) => { + let lane = read_lane(&mut st, i.lane); + o.framing = 7; + write_answer(&mut st, &i.sink, lane, o); + } + Call::Ingest(i, o) => { + let lane = read_lane(&mut st, i.lane).expect("a datagram framer reads its lane"); + let bytes = raw(i.bytes, i.len).to_vec(); + match lane.lane { + LANE_RENDEZVOUS => { + // "ufrag pwd fingerprint-hex": the far end's terms. + let text = String::from_utf8(bytes).unwrap(); + let mut w = text.split(' '); + let (u, pw, fp) = (w.next().unwrap(), w.next().unwrap(), w.next().unwrap()); + st.remote = Some((u.into(), pw.into(), unhex(fp))); + st.terms = true; + st.pieces.push((0, b"description".to_vec())); + if self.knobs.bad_route { + st.routes.push((1, LANE_CLEAR, Vec::new())); + } + } + LANE_CLEAR if is_request(&bytes) => { + st.routes + .push((lane.path, LANE_CLEAR, answer(&bytes, HOST_PWD))); + if lane.bound == 0 { + st.request = (PATH_REQUEST_BIND, 0, lane.path); + } else if self.knobs.rebind_new && lane.path != lane.bound { + st.request = (PATH_REQUEST_REBIND, lane.bound, lane.path); + st.routes.push((lane.path, LANE_CLEAR, vec![0x80, 0, 0, 1])); + } + } + LANE_SECURED => { + st.secured.push(bytes.clone()); + st.pieces.push((1, bytes)); + } + _ => {} + } + write_answer(&mut st, &i.sink, Some(lane), o); + } + Call::Emit(i, o) => { + let lane = read_lane(&mut st, i.lane).expect("a datagram framer reads its lane"); + let bytes = raw(i.bytes, i.len).to_vec(); + if !bytes.is_empty() { + st.routes.push((lane.bound.max(1), LANE_SECURED, bytes)); + } + write_answer(&mut st, &i.sink, Some(lane), o); + } + Call::Timer(i, o) => { + let lane = read_lane(&mut st, i.lane); + write_answer(&mut st, &i.sink, lane, o); + } + Call::Finish(..) => {} + _ => { + return Crossed { + outcome: Outcome::Refused, + error: None, + } + } + } + ok + } +} + +// ── the far end ───────────────────────────────────────────────────────────────────────────────── + +struct Far { + a: Association, + addr: SocketAddr, + ups: Vec, +} + +struct Rig { + door: Arc, + host: DatagramFraming, + host_addr: SocketAddr, + far: Far, + now: Instant, + /// Pieces the host handed up, in order. + got: Vec<(u64, Vec)>, + /// Datagrams the host sent anywhere but the far end. + elsewhere: Vec<(SocketAddr, Vec)>, +} + +fn hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() +} + +fn rig(knobs: Knobs) -> Rig { + let door = scripted(knobs); + let host_cert = Arc::new(SessionCert::mint().unwrap()); + let far_cert = Arc::new(SessionCert::mint().unwrap()); + let host_addr = p("198.51.100.1:3478"); + let (host, _) = DatagramFraming::accept(door.clone(), Arc::clone(&host_cert), host_addr) + .expect("the framing begins"); + let far = Far { + a: Association::new( + Arc::clone(&far_cert), + Role::Client, + &creds(FAR_UFRAG, FAR_PWD), + &creds(HOST_UFRAG, HOST_PWD), + host_cert.fingerprint(), + ), + addr: p("203.0.113.9:50000"), + ups: Vec::new(), + }; + let mut r = Rig { + door, + host, + host_addr, + far, + now: Instant::now(), + got: Vec::new(), + elsewhere: Vec::new(), + }; + let description = format!("{FAR_UFRAG} {FAR_PWD} {}", hex(&far_cert.fingerprint())); + let y = r + .host + .rendezvous(description.as_bytes(), r.now) + .expect("the rendezvous settles terms"); + r.got + .extend(y.pieces.into_iter().map(|g| (g.stream, g.bytes))); + r +} + +impl Rig { + fn into_host(&mut self, from: SocketAddr, d: &[u8]) { + let y = self + .host + .datagram(from, d, self.now) + .expect("the host takes it"); + self.got + .extend(y.pieces.into_iter().map(|g| (g.stream, g.bytes))); + } + + /// The far end's first check, then `rounds` of the clock with every datagram delivered. + fn pump(&mut self, rounds: usize) { + for _ in 0..rounds { + self.now += Duration::from_millis(10); + self.far.a.handle_timeout(self.now); + let y = self.host.timeout(self.now).expect("the host's clock"); + self.got + .extend(y.pieces.into_iter().map(|g| (g.stream, g.bytes))); + while let Some((to, d)) = self.host.poll_send() { + if to != self.far.addr { + self.elsewhere.push((to, d)); + continue; + } + let v = self.far.a.ingest(self.host_addr, &d, self.now); + if v == Verdict::Raw && is_request(&d) { + // The far end's ICE agent answers the host's probe under its own password. + let a = answer(&d, FAR_PWD); + let from = self.far.addr; + self.into_host(from, &a); + } + } + let _ = self.far.a.bind_path(self.host_addr, self.now); + while let Some((to, d)) = self.far.a.poll_wire() { + assert_eq!(to, self.host_addr); + let from = self.far.addr; + self.into_host(from, &d); + } + while let Some(u) = self.far.a.poll_up() { + self.far.ups.push(u); + } + } + } + + fn first_check(&mut self) { + let c = check(HOST_UFRAG, FAR_UFRAG, HOST_PWD, 1); + let from = self.far.addr; + self.into_host(from, &c); + } + + fn seen(&self) -> Vec { + self.door.st.lock().unwrap().seen.clone() + } + + fn far_keying(&self) -> &SrtpKeying { + self.far + .ups + .iter() + .find_map(|u| match u { + Up::Keyed(k) => Some(k), + _ => None, + }) + .expect("the far end exported keying material") + } +} + +#[test] +fn the_keying_material_item_is_presented_once_verified_and_equal_to_the_far_ends_export() { + let mut r = rig(Knobs::default()); + assert_eq!(r.got, vec![(0, b"description".to_vec())]); + r.first_check(); + r.pump(200); + assert!(r.host.presented(), "the item was presented"); + assert_eq!( + r.host.bound(), + Some(r.far.addr), + "the far end's path is bound" + ); + + let seen = r.seen(); + let keyed: Vec<&Seen> = seen.iter().filter(|s| s.keying.is_some()).collect(); + // RED arm: a host that presents the item on every call, or never, fails here. + assert_eq!(keyed.len(), 1, "the item crosses exactly once: {seen:?}"); + let (profile, bytes) = keyed[0].keying.clone().unwrap(); + assert!( + keyed[0].verified, + "the item crosses only with the far end verified" + ); + let far = r.far_keying(); + assert_eq!( + profile, + far.profile.code(), + "the profile is the negotiated one" + ); + assert!(profile == 0x0007 || profile == 0x0008, "AEAD-AES-GCM only"); + assert_eq!( + bytes, + far.material(), + "the item is the exporter's output, both ends" + ); + // No call before the far end was verified saw the verified bit. + let first_verified = seen.iter().position(|s| s.verified).expect("verified once"); + assert!(seen[..first_verified].iter().all(|s| s.keying.is_none())); + + // Plaintext both ways over the bound path, sealed and opened by the host's secure layer. + r.far.a.seal(b"ping").unwrap(); + r.pump(5); + assert!(r.got.contains(&(1, b"ping".to_vec())), "{:?}", r.got); + r.host.emit(1, b"pong", true, false, r.now).unwrap(); + r.pump(5); + assert!(r + .far + .ups + .iter() + .any(|u| matches!(u, Up::Plaintext(p) if p == b"pong"))); + assert_eq!(r.host.refused_routes(), 0); + assert!(r.elsewhere.is_empty()); +} + +#[test] +fn a_source_that_sent_no_verified_check_never_reaches_the_framer() { + let mut r = rig(Knobs::default()); + let spoof = p("192.0.2.66:4444"); + let before = r.seen().len(); + // A check under the wrong password, and media, from a source the far end never was. + r.into_host( + spoof, + &check(HOST_UFRAG, FAR_UFRAG, "wrong-password-012345", 2), + ); + r.into_host(spoof, &[0x80, 0, 0, 1, 2, 3]); + let seen = r.seen(); + assert_eq!(seen.len(), before, "nothing crossed for the spoofed source"); + assert!(seen + .iter() + .all(|s| s.told.iter().all(|(_, a)| a != &spoof.to_string()))); + // RED arm: the honest check does cross, with its path told. + r.first_check(); + let seen = r.seen(); + assert!(seen.len() > before); + assert!(seen + .iter() + .any(|s| s.told.contains(&(1, r.far.addr.to_string())))); +} + +#[test] +fn a_rebind_to_a_path_that_never_answered_the_probe_is_not_applied() { + let mut r = rig(Knobs { + rebind_new: true, + ..Knobs::default() + }); + r.first_check(); + r.pump(200); + assert_eq!(r.host.bound(), Some(r.far.addr)); + // A verified check from a new source that never answers core's probe (the password leaked; the + // address is not the far end's). + let spoof = p("192.0.2.77:5555"); + r.into_host(spoof, &check(HOST_UFRAG, FAR_UFRAG, HOST_PWD, 3)); + r.pump(100); + assert_eq!( + r.host.bound(), + Some(r.far.addr), + "the bound path did not move" + ); + let last = r.seen().last().cloned().unwrap(); + assert_eq!(last.bound, 1, "the framer is still told the first path"); + // Core probed the new source (the round trip that would prove it), sent it the check's answer, + // and nothing else: the clear datagram that was no answer was dropped and counted. + assert!(r + .elsewhere + .iter() + .all(|(to, d)| *to == spoof && d.len() >= 20 && (d[0..2] == [0, 1] || d[0..2] == [1, 1]))); + assert!(r.elsewhere.iter().any(|(_, d)| d[0..2] == [1, 1])); + assert_eq!(r.host.refused_routes(), 1); + // RED arm: once the new path answers the probe, the move applies. + let probe = r + .elsewhere + .iter() + .rev() + .find(|(_, d)| d[0..2] == [0, 1]) + .map(|(_, d)| d.clone()) + .expect("core probed the new path"); + r.into_host(spoof, &answer(&probe, FAR_PWD)); + assert_eq!(r.host.bound(), Some(spoof), "a proven path is bound"); +} + +#[test] +fn a_framer_answer_that_breaks_the_lane_is_a_fault_before_any_byte_is_used() { + let door = scripted(Knobs { + bad_route: true, + ..Knobs::default() + }); + let cert = Arc::new(SessionCert::mint().unwrap()); + let (mut host, _) = + DatagramFraming::accept(door, cert, p("198.51.100.1:3478")).expect("begins"); + let e = host + .rendezvous(b"farfrag far-password-0123456789 00", Instant::now()) + .expect_err("a zero-length route is refused"); + assert_eq!(e.outcome, Outcome::Fault, "{e}"); + assert!(host.poll_send().is_none()); +} + +#[test] +fn nothing_on_the_port_is_the_associations_before_the_rendezvous() { + let door = scripted(Knobs::default()); + let cert = Arc::new(SessionCert::mint().unwrap()); + let (mut host, _) = + DatagramFraming::accept(door.clone(), cert, p("198.51.100.1:3478")).expect("begins"); + let before = door.st.lock().unwrap().seen.len(); + let y = host + .datagram( + p("203.0.113.9:50000"), + &check(HOST_UFRAG, FAR_UFRAG, HOST_PWD, 4), + Instant::now(), + ) + .unwrap(); + assert!(y.pieces.is_empty()); + assert_eq!(door.st.lock().unwrap().seen.len(), before); + // The begin told the framer the host port, and no secret. + let seen = door.st.lock().unwrap().seen.clone(); + assert!(seen.iter().all(|s| s.keying.is_none() && !s.verified)); +} From 480112137ededfdd92a932815183d5e7b0fdcfd7 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:23:26 -0700 Subject: [PATCH 21/59] plane money: a served unit's declared class counts reach its plane's metering row (row 123) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plane declaring a count class and a byte class reports one call and the answer's length beside its fee unit; the end meters one request on (lane, plane) carrying both counts and no fee unit, the row GET /admin/usage prices as classes..{count,cost}. #71, THE DESIGN §7 'The plane reports; the kernel writes'. --- .../src/plane_driver/tests/money_tests.rs | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/crates/busbar-kernel/src/plane_driver/tests/money_tests.rs b/crates/busbar-kernel/src/plane_driver/tests/money_tests.rs index 559000922e..27f2afb39f 100644 --- a/crates/busbar-kernel/src/plane_driver/tests/money_tests.rs +++ b/crates/busbar-kernel/src/plane_driver/tests/money_tests.rs @@ -438,6 +438,69 @@ fn a_plane_named_lane_is_the_units_ledger_lane_not_its_route_entry() { ); } +/// THE PLANE'S DECLARED CLASSES REACH ITS ROW (#71; /status row 123, the rigs' `h2-class-price`): a +/// served unit of a plane that declares a count class and a byte class, reporting one call and the +/// answer's length beside its fee unit, meters ONE request on the plane's row `(lane, plane)` and +/// that row carries both counts, which the admin usage read prices (`classes..{count,cost}`). +/// The fee unit says only whether the fee was incurred: it is never a counted class. RED: counts +/// lost on the way to the row read 0 under the declared classes and only the flat fee is charged. +#[test] +fn a_plane_units_declared_class_counts_reach_its_metering_row() { + const CALLS: u32 = 0; + const BYTES: u32 = 1; + let r = rig_with(None, 0, ExhaustionMode::FinishUnit, plane_fees()); + r.money.open( + UnitKey::new(1), + UnitMoney { + model: "tp\u{1f}srv".into(), + classes: Arc::from(vec![ + "calls".to_string(), + "bytes".to_string(), + busbar_contract::plane::PER_REQUEST.to_string(), + ]), + ..r.unit.clone() + }, + ); + let report = [ + fee_reported(1), + UnitCount { + class: CALLS, + source: UNITS_REPORTED, + amount: 1, + }, + UnitCount { + class: BYTES, + source: UNITS_REPORTED, + amount: 37, + }, + ]; + let _ = r.money.checkpoint(&ctx(1), &report); + r.money.laned(&ctx(1), "srv_read"); + r.money.settle_end(UnitKey::new(1), 200); + let rows = metering_rows(&r); + let counts: Vec<_> = rows + .iter() + .map(|row| { + ( + row.model.as_str(), + row.provider.as_str(), + row.requests, + row.usage_units.clone(), + ) + }) + .collect(); + assert_eq!( + counts, + vec![( + "srv_read", + "tp", + 1, + BTreeMap::from([("bytes".to_string(), 37), ("calls".to_string(), 1)]), + )], + "one request on the plane's row, carrying every declared class it reported and no fee unit" + ); +} + /// A unit whose plane names no lane is laned by its route entry, as before (the serving member's). #[test] fn a_unit_naming_no_lane_keeps_its_route_entry() { From ea9c2a0291b52c53b80a7e493b0f210930bc9e7f Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:38:36 -0700 Subject: [PATCH 22/59] connector: the dialling datagram association judges every candidate before a byte goes there and binds one only once proven (row 132, piece 2b) ARCHITECT ruling (row 132, condition 1): the upstream leg is a WebRTC dial, back to back, Opus passed through. A dialling association hears nothing before it sends, so the far end's advertised candidates are judged by the host's one destination judge (DialJudge, under the dialling need's egress class) before any probe; a refused candidate is never numbered, told or sent a byte. An admitted one is an Association candidate: core probes it (ICE-CONTROLLING on this side), the framer's own checks may go to it, and a bind to it applies only once core's probe is answered from that path (l.762, a spoof is a RED). DatagramFraming::dial (opening fields, judge, class); Association::admit_candidate; may_send_clear admits a check to an admitted candidate; media up from the bound path. Tests: dialling, the metadata candidate is judged and refused and receives nothing, the admitted one binds and the keying item crosses once (RED: refused candidate never told or sent); an admitted candidate never binds unanswered or answered from another source (RED arm: answered from itself, it binds); dtls unit test for the admitted candidate's terms. --- crates/busbar-core-connector/src/datagram.rs | 132 +++++++++++- crates/busbar-core-connector/src/dtls/mod.rs | 68 +++++- crates/busbar-core-connector/src/dtls/stun.rs | 25 ++- .../busbar-core-connector/src/dtls/tests.rs | 30 +++ .../src/tests/datagram_tests.rs | 195 +++++++++++++++++- 5 files changed, 415 insertions(+), 35 deletions(-) diff --git a/crates/busbar-core-connector/src/datagram.rs b/crates/busbar-core-connector/src/datagram.rs index c68dec6788..6f65f5e6af 100644 --- a/crates/busbar-core-connector/src/datagram.rs +++ b/crates/busbar-core-connector/src/datagram.rs @@ -30,6 +30,13 @@ //! * PATH REQUESTS go to the secure layer, which binds only a path it has proven by its own round //! trip; the lane's `bound` says where it stands on every call after. //! * Every answer is judged (`check_datagram`) before any byte of it is used. +//! * THE DIALLING SIDE ([`DatagramFraming::dial`]) hears nothing before it sends: the far end's +//! description advertised its candidates, and each is judged by the host's ONE destination judge +//! ([`DialJudge`], under the dialling need's egress class) BEFORE any byte goes there. A candidate +//! the judge refuses is never numbered, never told to the framer and never sent to; one it admits +//! becomes an admitted candidate of the secure layer ([`Association::admit_candidate`]), which +//! probes it and binds it only once that probe is answered from it. The accepting side learns +//! its paths from verified checks alone and admits no candidate. //! //! Sans-IO: the caller hands in what the port read and the clock, takes //! [`DatagramFraming::poll_send`] out to the port, and calls [`DatagramFraming::timeout`] at @@ -40,18 +47,19 @@ use std::net::SocketAddr; use std::sync::Arc; use std::time::Instant; -use busbar_contract::abi::mechanism::call::{AbiStr, Outcome}; +use busbar_contract::abi::mechanism::call::{AbiStr, Field, Outcome}; use busbar_contract::abi::sdk::door::{blank_in, blank_out}; use busbar_contract::abi::transport::check::check_datagram; use busbar_contract::abi::transport::{ BeginIn, ConnFacts, DatagramLane, DatagramPath, DatagramRoute, EmitIn, FinishIn, FramerOut, FramingIn, IngestIn, KeyingMaterial, RendezvousTerms, CLOSE_NORMAL, EMIT_TEXT, FINGERPRINT_BYTES, HANDSHAKE_ANSWERS, HANDSHAKE_INITIATES, HANDSHAKE_NONE, LANE_CLEAR, - LANE_RENDEZVOUS, LANE_SECURED, PATH_REQUEST_BIND, PATH_REQUEST_REBIND, SIDE_ACCEPT, + LANE_RENDEZVOUS, LANE_SECURED, PATH_REQUEST_BIND, PATH_REQUEST_REBIND, SIDE_ACCEPT, SIDE_DIAL, }; use crate::dtls::{Association, IceCredentials, Role, SessionCert, SrtpKeying, Up, Verdict}; use crate::framer::{instant_of, ready, Buffers, Call, Crossed, FramerDoor, Refused, Yielded}; +use crate::DialJudge; /// The routes one framer answer may write. const ROUTES_CAP: usize = 64; @@ -130,7 +138,8 @@ impl PathBook { /// One framer op, as this driver makes it. #[derive(Clone, Copy)] enum Op<'a> { - Begin, + /// The opening fields a dialling framing offers. + Begin(&'a [(String, Vec)]), /// Bytes on a lane (`0` = a call that only carries the host's state); `end` = the far end /// closed. Ingest { @@ -152,7 +161,7 @@ impl Op<'_> { /// The re-call a `YIELD_MORE` answer asks for: the same op, no new bytes. fn again(self) -> Self { match self { - Op::Begin | Op::Timer => Op::Timer, + Op::Begin(_) | Op::Timer => Op::Timer, Op::Ingest { .. } => Op::Ingest { lane: 0, from: None, @@ -183,6 +192,8 @@ struct Terms { local: IceCredentials, remote: IceCredentials, fingerprint: [u8; 32], + /// The far end's advertised addresses, one per line, as the framer wrote them. + candidates: Vec, } /// ONE ASSOCIATION'S DATAGRAM FRAMER, driven over the host's port and secure layer (module docs). @@ -206,6 +217,11 @@ pub struct DatagramFraming { refused_routes: u64, deadline_ns: Option, ended: bool, + /// `SIDE_ACCEPT` or `SIDE_DIAL`. + side: u32, + /// The dialling side's destination judge and the need's egress class. + judge: Option<(Arc, u32)>, + refused_candidates: u64, } impl std::fmt::Debug for DatagramFraming { @@ -231,11 +247,41 @@ impl DatagramFraming { door: Arc, cert: Arc, local: SocketAddr, + ) -> Result<(Self, Yielded), Refused> { + Self::begin(door, cert, local, SIDE_ACCEPT, None, &[]) + } + + /// Begin a DIALLING association on `door`: as [`Self::accept`], with the `opening` fields the + /// framing offers (its own description comes back as a piece) and the destination `judge` every + /// candidate the far end's answer advertises meets, under the dialling need's egress `class`, + /// before any byte goes there. + /// + /// # Errors + /// + /// The entry refused to frame it, or answered outside the lane's rules. + pub fn dial( + door: Arc, + cert: Arc, + local: SocketAddr, + opening: &[(String, Vec)], + judge: Arc, + class: u32, + ) -> Result<(Self, Yielded), Refused> { + Self::begin(door, cert, local, SIDE_DIAL, Some((judge, class)), opening) + } + + fn begin( + door: Arc, + cert: Arc, + local: SocketAddr, + side: u32, + judge: Option<(Arc, u32)>, + opening: &[(String, Vec)], ) -> Result<(Self, Yielded), Refused> { let mut f = DatagramFraming { door, token: 0, - bufs: Buffers::for_side(SIDE_ACCEPT), + bufs: Buffers::for_side(side), routes: vec![DatagramRoute::default(); ROUTES_CAP], cert, local: local.to_string(), @@ -248,8 +294,11 @@ impl DatagramFraming { refused_routes: 0, deadline_ns: None, ended: false, + side, + judge, + refused_candidates: 0, }; - let y = f.run(Op::Begin, Instant::now())?; + let y = f.run(Op::Begin(opening), Instant::now())?; Ok((f, y)) } @@ -369,6 +418,12 @@ impl DatagramFraming { self.refused_routes } + /// How many of the far end's advertised candidates the destination judge refused (dialling). + #[must_use] + pub fn refused_candidates(&self) -> u64 { + self.refused_candidates + } + /// Whether the keying-material item has been presented to the framer. #[must_use] pub fn presented(&self) -> bool { @@ -437,7 +492,7 @@ impl DatagramFraming { return Err(fault("keying material with the far end not verified")); } let state = (self.bound_path(), self.verified()); - let owed = state != self.told || self.keying.is_some(); + let owed = state != self.told || self.keying.is_some() || !self.book.untold.is_empty(); if opened.is_empty() && !closed && !owed { y.wire.clear(); return Ok(y); @@ -485,7 +540,7 @@ impl DatagramFraming { } fn cross_once(&mut self, op: Op<'_>) -> Result { - if self.token == 0 && !matches!(op, Op::Begin) { + if self.token == 0 && !matches!(op, Op::Begin(_)) { return Err(fault("the association's framing is closed")); } // The lane's host state at this call. Every pointer below is into memory that outlives @@ -544,7 +599,14 @@ impl DatagramFraming { }; let mut o: FramerOut = blank_out(); let c: Crossed = match op { - Op::Begin => { + Op::Begin(opening) => { + let fields: Vec = opening + .iter() + .map(|(n, v)| Field { + name: text(n.as_bytes()), + value: text(v), + }) + .collect(); let facts = ConnFacts { size: u32::try_from(std::mem::size_of::()).unwrap_or(u32::MAX), _reserved: 0, @@ -557,8 +619,12 @@ impl DatagramFraming { local_certificate: text(self.cert.public_der()), }; let mut i: BeginIn = blank_in(); - i.side = SIDE_ACCEPT; + i.side = self.side; i.facts = &facts; + if !fields.is_empty() { + i.fields = fields.as_ptr(); + i.fields_len = fields.len(); + } i.lane = &lane; i.sink = self.bufs.sink(); self.door.cross(Call::Begin(&mut i, &mut o)) @@ -605,7 +671,7 @@ impl DatagramFraming { ))); } ready(c)?; - if matches!(op, Op::Begin) { + if matches!(op, Op::Begin(_)) { self.token = o.framing; } // What this call carried reached the framer: the paths are told, the host state is the @@ -661,6 +727,9 @@ impl DatagramFraming { &t.remote, t.fingerprint, )); + if self.side == SIDE_DIAL { + self.admit(&t.candidates, now); + } } for (path, lane, bytes) in asked.routes { self.route(path, lane, bytes); @@ -669,6 +738,39 @@ impl DatagramFraming { Ok(done) } + /// The dialling side's candidates: each judged by the destination judge first; an admitted one + /// numbered (told to the framer on the next call) and handed to the secure layer, which probes + /// it. A refused, unparsable or still-pending one is dropped and counted. + fn admit(&mut self, candidates: &[String], now: Instant) { + let Some((judge, class)) = self.judge.clone() else { + return; + }; + for c in candidates { + let admitted = c.parse::().ok().filter(|addr| { + // A literal is judged at once; a judgement that would pend is no admission. + matches!( + judge.judge_dial(&addr.to_string(), class, Box::new(|_| {})), + Some(Ok(pinned)) if pinned == *addr + ) + }); + let Some(addr) = admitted else { + self.refused_candidates += 1; + continue; + }; + let Some(secure) = self.secure.as_mut() else { + return; + }; + if !secure.admit_candidate(addr, now) { + self.refused_candidates += 1; + continue; + } + let known = |a: SocketAddr| secure.knows(a) || secure.bound_path() == Some(a); + if self.book.number(addr, known).is_none() { + self.refused_candidates += 1; + } + } + } + /// Send one route where the secure layer allows it, or drop it. fn route(&mut self, path: u32, lane: u32, bytes: Vec) { let to = self.book.addr(path); @@ -731,6 +833,13 @@ fn read_terms(t: &RendezvousTerms, frame: &[u8]) -> Result { HANDSHAKE_ANSWERS => Role::Server, _ => return Err(fault("the framer's terms name no handshake role")), }; + let candidates = String::from_utf8(span(t.candidates)?.to_vec()) + .map_err(|_| fault("the far end's candidates are not text"))? + .lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .map(str::to_owned) + .collect(); let mut fingerprint = [0_u8; FINGERPRINT_BYTES as usize]; fingerprint.copy_from_slice( span(t.peer_fingerprint)? @@ -748,6 +857,7 @@ fn read_terms(t: &RendezvousTerms, frame: &[u8]) -> Result { pwd: utf8(t.remote_secret)?, }, fingerprint, + candidates, }) } diff --git a/crates/busbar-core-connector/src/dtls/mod.rs b/crates/busbar-core-connector/src/dtls/mod.rs index 9e5d038f5f..6c8236aa96 100644 --- a/crates/busbar-core-connector/src/dtls/mod.rs +++ b/crates/busbar-core-connector/src/dtls/mod.rs @@ -34,6 +34,16 @@ //! path is proven, and refuses it outright onto a path it never heard. The DTLS session (and so //! SCTP) follows a rebind: same keys, same association, the call is not dropped. //! +//! # The dialling side: admitted candidates +//! +//! A dialling association has heard nothing yet: the far end's description advertised where it +//! listens, and the HOST judged each address with its destination guard before anything is sent +//! there. A judged address is an ADMITTED CANDIDATE ([`Association::admit_candidate`]): core probes +//! it at once, the framer's own checks may go to it, and a bind to it is accepted on the same terms +//! as a heard path — applied only once core's probe is answered from that path. A candidate the +//! guard refused is never admitted, so nothing but the bound path and admitted candidates ever +//! receives a byte. +//! //! Consent freshness (RFC 7675 §5.1): the bound path is re-probed every [`CONSENT_INTERVAL`]; with //! no proof for [`CONSENT_TIMEOUT`] the association fails and sends nothing more. //! @@ -62,7 +72,7 @@ mod ring; pub mod stun; -use std::collections::{HashMap, VecDeque}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::net::SocketAddr; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -71,7 +81,7 @@ use ::ring::rand::{SecureRandom as _, SystemRandom}; pub use stun::IceCredentials; use zeroize::Zeroizing; -/// How many distinct heard paths one association remembers. +/// How many distinct heard paths (and, apart, admitted candidates) one association remembers. const MAX_HEARD_PATHS: usize = 16; /// How many probes may be in flight. const MAX_PROBES: usize = 32; @@ -304,6 +314,10 @@ pub struct Association { dtls: Option, /// Paths a verified check came from, and when the last one did. heard: HashMap, + /// Far-end candidates the host's destination guard admitted (the dialling side). + candidates: HashSet, + /// Core's probes say ICE-CONTROLLING (the dialling side) rather than ICE-CONTROLLED. + controlling: bool, proven: HashMap, probes: HashMap<[u8; 12], (SocketAddr, Instant)>, tie_breaker: [u8; 8], @@ -350,6 +364,8 @@ impl Association { remote_fingerprint, dtls: None, heard: HashMap::new(), + candidates: HashSet::new(), + controlling: false, proven: HashMap::new(), probes: HashMap::new(), tie_breaker, @@ -390,7 +406,9 @@ impl Association { } Verdict::Raw } - Class::Media if self.heard.contains_key(&path) => Verdict::Raw, + Class::Media if self.heard.contains_key(&path) || self.bound == Some(path) => { + Verdict::Raw + } Class::Dtls => { if self.bound == Some(path) { if let Some(dtls) = self.dtls.as_mut() { @@ -400,7 +418,9 @@ impl Association { } } self.drive(); - } else if self.heard.contains_key(&path) && self.early.len() < MAX_EARLY { + } else if (self.heard.contains_key(&path) || self.candidates.contains(&path)) + && self.early.len() < MAX_EARLY + { self.early.push((path, datagram.to_vec())); } Verdict::Kept @@ -420,7 +440,7 @@ impl Association { if self.failed.is_some() { return Err(BindRefused::Failed); } - if !self.heard.contains_key(&path) { + if !self.heard.contains_key(&path) && !self.candidates.contains(&path) { return Err(BindRefused::Unchecked); } self.wanted = Some(path); @@ -452,7 +472,7 @@ impl Association { if self.bound != Some(old) { return Err(BindRefused::NotBound); } - if !self.heard.contains_key(&new) { + if !self.heard.contains_key(&new) && !self.candidates.contains(&new) { return Err(BindRefused::Unchecked); } self.wanted = Some(new); @@ -466,7 +486,8 @@ impl Association { } /// May the framer send `datagram` (clear lane) to `path`? A STUN success response — the framer - /// answering a check — to a heard or proven path; anything else only to the bound path. + /// answering a check — to a heard or proven path; a check of its own to an admitted candidate; + /// anything else only to the bound path. #[must_use] pub fn may_send_clear(&self, path: SocketAddr, datagram: &[u8]) -> bool { if self.failed.is_some() { @@ -475,9 +496,29 @@ impl Association { if stun::is_binding_success(datagram) { return self.heard.contains_key(&path) || self.proven.contains_key(&path); } + if stun::is_binding_request(datagram) && self.candidates.contains(&path) { + return true; + } self.bound == Some(path) } + /// The host's destination guard admitted `path`, a far-end candidate the description advertised + /// (the dialling side; module docs): probe it now, and let a bind to it apply once it is proven. + /// `false` = not admitted (the association failed, or it already holds as many candidates as it + /// remembers heard paths). + pub fn admit_candidate(&mut self, path: SocketAddr, now: Instant) -> bool { + if self.failed.is_some() { + return false; + } + if !self.candidates.contains(&path) && self.candidates.len() >= MAX_HEARD_PATHS { + return false; + } + self.controlling = true; + self.candidates.insert(path); + self.probe(path, now); + true + } + /// Seal `plaintext` (SCTP, for the data channels) as DTLS application data toward the bound path. /// /// # Errors @@ -576,10 +617,13 @@ impl Association { } /// Whether a verified check came from `path` (or core's own probe proved it) and consent has - /// not aged it out: the only paths whose datagrams the framer is handed. + /// not aged it out, or the host admitted it as a candidate: the only paths whose datagrams the + /// framer is handed. #[must_use] pub fn knows(&self, path: SocketAddr) -> bool { - self.heard.contains_key(&path) || self.proven.contains_key(&path) + self.heard.contains_key(&path) + || self.proven.contains_key(&path) + || self.candidates.contains(&path) } /// Whether the far end's certificate matched the fingerprint its description carried. Nothing @@ -640,8 +684,10 @@ impl Association { return; } self.probes.insert(txid, (path, now)); - self.wire - .push_back((path, stun::probe(&self.keys, txid, self.tie_breaker))); + self.wire.push_back(( + path, + stun::probe(&self.keys, txid, self.tie_breaker, self.controlling), + )); } /// A probe to `path` was answered: apply a bind waiting on it. diff --git a/crates/busbar-core-connector/src/dtls/stun.rs b/crates/busbar-core-connector/src/dtls/stun.rs index 3d067b7bb0..fb8ffdf5b6 100644 --- a/crates/busbar-core-connector/src/dtls/stun.rs +++ b/crates/busbar-core-connector/src/dtls/stun.rs @@ -29,6 +29,8 @@ const MESSAGE_INTEGRITY: u16 = 0x0008; const PRIORITY: u16 = 0x0024; /// ICE-CONTROLLED. const ICE_CONTROLLED: u16 = 0x8029; +/// ICE-CONTROLLING. +const ICE_CONTROLLING: u16 = 0x802a; /// FINGERPRINT. const FINGERPRINT: u16 = 0x8028; /// FINGERPRINT's XOR constant (RFC 8489 §14.7). @@ -164,6 +166,11 @@ pub(crate) fn verified_probe_answer(d: &[u8], keys: &Keys) -> Option<[u8; 12]> { None } +/// Is `d` a Binding request (whoever it is for)? +pub(crate) fn is_binding_request(d: &[u8]) -> bool { + d.len() >= HEADER && d[0..2] == BINDING_REQUEST && d[4..8] == MAGIC_COOKIE +} + /// Is `d` a Binding success response (whoever it answers)? pub(crate) fn is_binding_success(d: &[u8]) -> bool { d.len() >= HEADER && d[0..2] == BINDING_SUCCESS && d[4..8] == MAGIC_COOKIE @@ -181,9 +188,14 @@ fn set_len(m: &mut [u8], body: usize) { } /// A consent probe: a Binding request to the peer under the REMOTE credentials (USERNAME -/// "remote:local", MESSAGE-INTEGRITY with the remote password), as the controlled agent, with -/// FINGERPRINT. -pub(crate) fn probe(keys: &Keys, txid: [u8; 12], tie_breaker: [u8; 8]) -> Vec { +/// "remote:local", MESSAGE-INTEGRITY with the remote password), as the controlled agent (or, on +/// the dialling side, the controlling one), with FINGERPRINT. +pub(crate) fn probe( + keys: &Keys, + txid: [u8; 12], + tie_breaker: [u8; 8], + controlling: bool, +) -> Vec { let mut m = Vec::with_capacity(128); m.extend_from_slice(&BINDING_REQUEST); m.extend_from_slice(&[0, 0]); @@ -191,7 +203,12 @@ pub(crate) fn probe(keys: &Keys, txid: [u8; 12], tie_breaker: [u8; 8]) -> Vec)>, terms: bool, request: (u32, u32, u32), + candidates: String, + txid: u8, } struct Scripted { @@ -226,15 +236,24 @@ fn write_answer(st: &mut St, sink: &FramerSink, lane: Option<&DatagramLane>, o: } if std::mem::take(&mut st.terms) { let (ru, rp, fp) = st.remote.clone().expect("terms after a rendezvous"); + let candidates = std::mem::take(&mut st.candidates); o.datagram.terms = RendezvousTerms { - role: HANDSHAKE_ANSWERS, + role: if candidates.is_empty() { + HANDSHAKE_ANSWERS + } else { + HANDSHAKE_INITIATES + }, _reserved: 0, local_user: frame(HOST_UFRAG.as_bytes()), local_secret: frame(HOST_PWD.as_bytes()), remote_user: frame(ru.as_bytes()), remote_secret: frame(rp.as_bytes()), peer_fingerprint: frame(&fp), - candidates: FrameSpan::default(), + candidates: if candidates.is_empty() { + FrameSpan::default() + } else { + frame(candidates.as_bytes()) + }, }; } let mut n = 0_usize; @@ -268,6 +287,25 @@ fn unhex(s: &str) -> Vec { .collect() } +impl Scripted { + /// Dialling: a check to every path this call told, and a bind request for the first while + /// nothing is bound. + fn check_told(&self, st: &mut St, lane: &DatagramLane) { + if !self.knobs.dial { + return; + } + let told = st.seen.last().map(|s| s.told.clone()).unwrap_or_default(); + for (path, _) in told { + st.txid = st.txid.wrapping_add(1); + let c = check(FAR_UFRAG, HOST_UFRAG, FAR_PWD, 0x40 | st.txid); + st.routes.push((path, LANE_CLEAR, c)); + if lane.bound == 0 && st.request.0 == 0 { + st.request = (PATH_REQUEST_BIND, 0, path); + } + } + } +} + impl FramerDoor for Scripted { fn facts(&self) -> &DoorFacts { &self.facts @@ -283,6 +321,9 @@ impl FramerDoor for Scripted { Call::Begin(i, o) => { let lane = read_lane(&mut st, i.lane); o.framing = 7; + if self.knobs.dial { + st.pieces.push((0, b"offer".to_vec())); + } write_answer(&mut st, &i.sink, lane, o); } Call::Ingest(i, o) => { @@ -295,6 +336,9 @@ impl FramerDoor for Scripted { let mut w = text.split(' '); let (u, pw, fp) = (w.next().unwrap(), w.next().unwrap(), w.next().unwrap()); st.remote = Some((u.into(), pw.into(), unhex(fp))); + if let Some(c) = w.next() { + st.candidates = c.split(',').map(|a| format!("{a}\n")).collect(); + } st.terms = true; st.pieces.push((0, b"description".to_vec())); if self.knobs.bad_route { @@ -317,6 +361,7 @@ impl FramerDoor for Scripted { } _ => {} } + self.check_told(&mut st, lane); write_answer(&mut st, &i.sink, Some(lane), o); } Call::Emit(i, o) => { @@ -329,6 +374,9 @@ impl FramerDoor for Scripted { } Call::Timer(i, o) => { let lane = read_lane(&mut st, i.lane); + if let Some(l) = lane { + self.check_told(&mut st, l); + } write_answer(&mut st, &i.sink, lane, o); } Call::Finish(..) => {} @@ -361,6 +409,27 @@ struct Rig { got: Vec<(u64, Vec)>, /// Datagrams the host sent anywhere but the far end. elsewhere: Vec<(SocketAddr, Vec)>, + /// The far end's ICE agent answers the requests it is sent. + answers: bool, + /// Requests the far end was sent and did not answer. + unanswered: Vec>, + /// Every destination the judge was asked about. + judged: Arc>>, +} + +const METADATA: &str = "169.254.169.254:3478"; + +/// The destination judge: the link-local metadata range refused, any other literal admitted as +/// itself. +fn judge(asked: Arc>>) -> Arc { + Arc::new(move |dest: &str, _class: u32, _done: Judged| { + asked.lock().unwrap().push(dest.to_owned()); + let addr: SocketAddr = dest.parse().ok()?; + match addr.ip() { + std::net::IpAddr::V4(v4) if v4.is_link_local() => Some(Err(7)), + _ => Some(Ok(addr)), + } + }) } fn hex(b: &[u8]) -> String { @@ -372,12 +441,33 @@ fn rig(knobs: Knobs) -> Rig { let host_cert = Arc::new(SessionCert::mint().unwrap()); let far_cert = Arc::new(SessionCert::mint().unwrap()); let host_addr = p("198.51.100.1:3478"); - let (host, _) = DatagramFraming::accept(door.clone(), Arc::clone(&host_cert), host_addr) + let judged = Arc::new(Mutex::new(Vec::new())); + let host = if knobs.dial { + let opening = [("media".to_owned(), b"audio".to_vec())]; + let (host, y) = DatagramFraming::dial( + door.clone(), + Arc::clone(&host_cert), + host_addr, + &opening, + judge(Arc::clone(&judged)), + 0, + ) .expect("the framing begins"); + assert_eq!(y.pieces.len(), 1, "the dialling framing offers at once"); + host + } else { + DatagramFraming::accept(door.clone(), Arc::clone(&host_cert), host_addr) + .expect("the framing begins") + .0 + }; let far = Far { a: Association::new( Arc::clone(&far_cert), - Role::Client, + if knobs.dial { + Role::Server + } else { + Role::Client + }, &creds(FAR_UFRAG, FAR_PWD), &creds(HOST_UFRAG, HOST_PWD), host_cert.fingerprint(), @@ -393,8 +483,14 @@ fn rig(knobs: Knobs) -> Rig { now: Instant::now(), got: Vec::new(), elsewhere: Vec::new(), + answers: true, + unanswered: Vec::new(), + judged, }; - let description = format!("{FAR_UFRAG} {FAR_PWD} {}", hex(&far_cert.fingerprint())); + let mut description = format!("{FAR_UFRAG} {FAR_PWD} {}", hex(&far_cert.fingerprint())); + if knobs.dial { + description.push_str(&format!(" {},{METADATA},not-an-address", r.far.addr)); + } let y = r .host .rendezvous(description.as_bytes(), r.now) @@ -428,7 +524,9 @@ impl Rig { continue; } let v = self.far.a.ingest(self.host_addr, &d, self.now); - if v == Verdict::Raw && is_request(&d) { + if v == Verdict::Raw && is_request(&d) && !self.answers { + self.unanswered.push(d); + } else if v == Verdict::Raw && is_request(&d) { // The far end's ICE agent answers the host's probe under its own password. let a = answer(&d, FAR_PWD); let from = self.far.addr; @@ -624,3 +722,82 @@ fn nothing_on_the_port_is_the_associations_before_the_rendezvous() { let seen = door.st.lock().unwrap().seen.clone(); assert!(seen.iter().all(|s| s.keying.is_none() && !s.verified)); } + +fn dialling() -> Knobs { + Knobs { + dial: true, + ..Knobs::default() + } +} + +#[test] +fn dialling_every_candidate_meets_the_judge_and_a_refused_one_is_never_sent_a_byte() { + let mut r = rig(dialling()); + r.pump(200); + // Every advertised literal was asked about; the unparsable one never reached the judge. + let judged = r.judged.lock().unwrap().clone(); + assert!(judged.contains(&r.far.addr.to_string()), "{judged:?}"); + assert!(judged.contains(&METADATA.to_owned()), "{judged:?}"); + assert_eq!( + r.host.refused_candidates(), + 2, + "the metadata address and the non-address" + ); + // RED: the refused candidate got no byte and was never told to the framer. + assert!( + r.elsewhere.is_empty(), + "nothing left for any other address: {:?}", + r.elsewhere + ); + assert!(r + .seen() + .iter() + .all(|s| s.told.iter().all(|(_, a)| a != METADATA))); + // The admitted one binds once proven, the handshake runs and the item crosses once, verified. + assert_eq!(r.host.bound(), Some(r.far.addr)); + assert!(r.host.presented()); + let keyed: Vec = r + .seen() + .into_iter() + .filter(|s| s.keying.is_some()) + .collect(); + assert_eq!(keyed.len(), 1); + assert!(keyed[0].verified); + assert_eq!( + keyed[0].keying.as_ref().unwrap().1, + r.far_keying().material() + ); + r.host.emit(1, b"hello", true, false, r.now).unwrap(); + r.pump(5); + assert!(r + .far + .ups + .iter() + .any(|u| matches!(u, Up::Plaintext(p) if p == b"hello"))); +} + +#[test] +fn dialling_an_admitted_candidate_never_binds_unverified() { + let mut r = rig(dialling()); + r.answers = false; + r.pump(200); + // The framer asked to bind it and the far end saw core's probes, but none was answered. + assert!(!r.unanswered.is_empty(), "core probed the candidate"); + assert_eq!(r.host.bound(), None, "an unproven candidate is not bound"); + assert!(!r.host.presented()); + // Answers to every request it was sent, from ANOTHER source, prove nothing. + let unanswered = std::mem::take(&mut r.unanswered); + for d in &unanswered { + r.into_host(p("192.0.2.88:6666"), &answer(d, FAR_PWD)); + } + assert_eq!(r.host.bound(), None, "an answer from elsewhere is no proof"); + // RED arm: answered from the candidate itself, the bind applies. + let from = r.far.addr; + for d in &unanswered { + r.into_host(from, &answer(d, FAR_PWD)); + } + r.answers = true; + r.pump(200); + assert_eq!(r.host.bound(), Some(r.far.addr)); + assert!(r.host.presented()); +} From 06e22ef853ab2bd10978446d5c5efb3c3b692564 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:05:26 -0700 Subject: [PATCH 23/59] connector: one datagram port carries many associations, each found by its check's user name then by its path; the port on the worker's reactor (row 132, piece 3a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ARCHITECT ruling (row 132 seam): DatagramPort — one port, demux by local ufrag then path, reactor readiness. Associations (datagram.rs): a connectivity check finds the association its USERNAME names (the association still verifies it); anything else finds the association by a path its secure layer knows; a dialled association is found by its admitted candidates before they speak; a datagram neither finds is dropped before any association sees it; a failed association is removed. ReadyPort (udp.rs, the datagram carrier): the bound port on the calling worker's reactor, a read waits on readiness, a send that would block drops. Tests: two associations on one port each see only their own checks and paths (RED: an unknown user name, an unclaimed path and a failed check's path reach nothing); a dialled association is found by its candidate (RED: the refused candidate finds nothing); the ready port is refused off a worker and reads on readiness. --- crates/busbar-core-connector/src/datagram.rs | 191 ++++++++++++++++++ .../src/tests/datagram_tests.rs | 144 ++++++++++++- .../src/tests/udp_tests.rs | 32 +++ crates/busbar-core-connector/src/udp.rs | 62 ++++++ 4 files changed, 425 insertions(+), 4 deletions(-) diff --git a/crates/busbar-core-connector/src/datagram.rs b/crates/busbar-core-connector/src/datagram.rs index 6f65f5e6af..6622cc8f86 100644 --- a/crates/busbar-core-connector/src/datagram.rs +++ b/crates/busbar-core-connector/src/datagram.rs @@ -222,6 +222,9 @@ pub struct DatagramFraming { /// The dialling side's destination judge and the need's egress class. judge: Option<(Arc, u32)>, refused_candidates: u64, + /// The user name the far end's checks carry first, once the terms settled it: the key the + /// port's demultiplexer finds this association by ([`Associations`]). + user: Option, } impl std::fmt::Debug for DatagramFraming { @@ -297,6 +300,7 @@ impl DatagramFraming { side, judge, refused_candidates: 0, + user: None, }; let y = f.run(Op::Begin(opening), Instant::now())?; Ok((f, y)) @@ -418,6 +422,27 @@ impl DatagramFraming { self.refused_routes } + /// The user name the far end's checks carry first, once the rendezvous settled it. + #[must_use] + pub fn user(&self) -> Option<&str> { + self.user.as_deref() + } + + /// Whether a datagram from `path` is this association's: the secure layer knows it (a verified + /// check, core's proof, an admitted candidate) or has it bound. + #[must_use] + pub fn owns(&self, path: SocketAddr) -> bool { + self.secure + .as_ref() + .is_some_and(|s| s.knows(path) || s.bound_path() == Some(path)) + } + + /// The far-end addresses this association owns ([`Self::owns`]) that it has numbered: on the + /// dialling side its admitted candidates, before any of them has sent a byte. + pub fn addresses(&self) -> impl Iterator + '_ { + self.book.by_addr.keys().copied().filter(|a| self.owns(*a)) + } + /// How many of the far end's advertised candidates the destination judge refused (dialling). #[must_use] pub fn refused_candidates(&self) -> u64 { @@ -720,6 +745,7 @@ impl DatagramFraming { if self.secure.is_some() { return Err(fault("the framer answered terms twice")); } + self.user = Some(t.local.ufrag.clone()); self.secure = Some(Association::new( Arc::clone(&self.cert), t.role, @@ -861,6 +887,171 @@ fn read_terms(t: &RendezvousTerms, frame: &[u8]) -> Result { }) } +/// THE PORT'S ASSOCIATIONS: every association one host port carries, and the demultiplexer that +/// finds the one a datagram belongs to. A connectivity check names its association by the user +/// name its first USERNAME field carries (the association's own; the association still verifies +/// the check's integrity itself); any other datagram is found by the path it came from, which an +/// association claims only once its secure layer knows it. A datagram neither finds is dropped +/// here, before any association or framer sees it. +/// +/// One port carries many ACCEPTED associations: each far end reaches it from its own address. A +/// DIALLED association takes a port of its own, because two calls to one far end would share +/// every address of the 5-tuple. +#[derive(Debug, Default)] +pub struct Associations { + framings: HashMap, + by_user: HashMap, + by_path: HashMap, + last: u64, +} + +/// Why an association was not added to a port. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum NotAdded { + /// Its rendezvous has not settled terms: nothing would find it. + NoTerms, + /// Another association on the port already answers to its user name. + UserTaken, +} + +impl Associations { + /// An empty port. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Carry `framing` on this port; its number. + /// + /// # Errors + /// + /// [`NotAdded`]: no terms yet, or its user name is another association's. + pub fn add(&mut self, framing: DatagramFraming) -> Result { + let user = framing.user().ok_or(NotAdded::NoTerms)?.to_owned(); + if self.by_user.contains_key(&user) { + return Err(NotAdded::UserTaken); + } + self.last += 1; + let id = self.last; + self.by_user.insert(user, id); + // A dialled association hears nothing first: its admitted candidates' answers find it by + // their path. A path another association already holds stays that association's (a + // dialled association that shares a far end with another is given a port of its own). + for addr in framing.addresses() { + self.by_path.entry(addr).or_insert(id); + } + self.framings.insert(id, framing); + Ok(id) + } + + /// Stop carrying association `id`; the framing, for its owner to finish. + pub fn remove(&mut self, id: u64) -> Option { + let f = self.framings.remove(&id)?; + self.by_user.retain(|_, v| *v != id); + self.by_path.retain(|_, v| *v != id); + Some(f) + } + + /// The association `id`. + pub fn get_mut(&mut self, id: u64) -> Option<&mut DatagramFraming> { + self.framings.get_mut(&id) + } + + /// How many associations the port carries. + #[must_use] + pub fn len(&self) -> usize { + self.framings.len() + } + + /// Whether the port carries none. + #[must_use] + pub fn is_empty(&self) -> bool { + self.framings.is_empty() + } + + /// One datagram the port read from `from`: the association it belongs to and what that + /// association answered; `None` = it belongs to none and was dropped. An association that + /// fails is removed (its framing dropped, which finishes it). + pub fn datagram( + &mut self, + from: SocketAddr, + bytes: &[u8], + now: Instant, + ) -> Option<(u64, Result)> { + let id = match check_user(bytes) { + Some(user) => self.by_user.get(user).copied(), + None => self.by_path.get(&from).copied(), + }?; + let f = self.framings.get_mut(&id)?; + let answer = f.datagram(from, bytes, now); + if answer.is_ok() && f.owns(from) { + self.by_path.insert(from, id); + } + if answer.is_err() { + self.remove(id); + } + Some((id, answer)) + } + + /// The clock, for every association whose deadline has passed; a failed one is removed. + pub fn timeout(&mut self, now: Instant) -> Vec<(u64, Result)> { + let due: Vec = self + .framings + .iter() + .filter(|(_, f)| f.deadline().is_some_and(|d| d <= now)) + .map(|(id, _)| *id) + .collect(); + let mut out = Vec::new(); + for id in due { + if let Some(f) = self.framings.get_mut(&id) { + let answer = f.timeout(now); + if answer.is_err() { + self.remove(id); + } + out.push((id, answer)); + } + } + out + } + + /// The earliest deadline of any association. + #[must_use] + pub fn deadline(&self) -> Option { + self.framings + .values() + .filter_map(DatagramFraming::deadline) + .min() + } + + /// The next datagram any association owes the port, and where it goes. + pub fn poll_send(&mut self) -> Option<(SocketAddr, Vec)> { + self.framings + .values_mut() + .find_map(DatagramFraming::poll_send) + } +} + +/// The user name a connectivity check names its receiver by: the part of its first USERNAME +/// field before the `:`. `None` for anything that is not a Binding request carrying one. +fn check_user(d: &[u8]) -> Option<&str> { + const HEADER: usize = 20; + if d.len() < HEADER || d[0..2] != [0x00, 0x01] || d[4..8] != [0x21, 0x12, 0xa4, 0x42] { + return None; + } + let mut at = HEADER; + while let Some(head) = d.get(at..at + 4) { + let kind = u16::from_be_bytes([head[0], head[1]]); + let len = usize::from(u16::from_be_bytes([head[2], head[3]])); + let value = d.get(at + 4..at + 4 + len)?; + if kind == 0x0006 { + let name = std::str::from_utf8(value).ok()?; + return name.split(':').next().filter(|u| !u.is_empty()); + } + at += 4 + len.div_ceil(4) * 4; + } + None +} + impl Drop for DatagramFraming { fn drop(&mut self) { if self.token == 0 { diff --git a/crates/busbar-core-connector/src/tests/datagram_tests.rs b/crates/busbar-core-connector/src/tests/datagram_tests.rs index 3ee1027291..b768bfba04 100644 --- a/crates/busbar-core-connector/src/tests/datagram_tests.rs +++ b/crates/busbar-core-connector/src/tests/datagram_tests.rs @@ -131,6 +131,8 @@ struct St { request: (u32, u32, u32), candidates: String, txid: u8, + /// This framing's own check credentials. + local: (&'static str, &'static str), } struct Scripted { @@ -140,6 +142,10 @@ struct Scripted { } fn scripted(knobs: Knobs) -> Arc { + scripted_as(knobs, (HOST_UFRAG, HOST_PWD)) +} + +fn scripted_as(knobs: Knobs, local: (&'static str, &'static str)) -> Arc { Arc::new(Scripted { facts: DoorFacts { name: "scripted".into(), @@ -151,7 +157,10 @@ fn scripted(knobs: Knobs) -> Arc { duplex: Vec::new(), }, knobs, - st: Mutex::new(St::default()), + st: Mutex::new(St { + local, + ..St::default() + }), }) } @@ -236,6 +245,7 @@ fn write_answer(st: &mut St, sink: &FramerSink, lane: Option<&DatagramLane>, o: } if std::mem::take(&mut st.terms) { let (ru, rp, fp) = st.remote.clone().expect("terms after a rendezvous"); + let (lu, lp) = st.local; let candidates = std::mem::take(&mut st.candidates); o.datagram.terms = RendezvousTerms { role: if candidates.is_empty() { @@ -244,8 +254,8 @@ fn write_answer(st: &mut St, sink: &FramerSink, lane: Option<&DatagramLane>, o: HANDSHAKE_INITIATES }, _reserved: 0, - local_user: frame(HOST_UFRAG.as_bytes()), - local_secret: frame(HOST_PWD.as_bytes()), + local_user: frame(lu.as_bytes()), + local_secret: frame(lp.as_bytes()), remote_user: frame(ru.as_bytes()), remote_secret: frame(rp.as_bytes()), peer_fingerprint: frame(&fp), @@ -347,7 +357,7 @@ impl FramerDoor for Scripted { } LANE_CLEAR if is_request(&bytes) => { st.routes - .push((lane.path, LANE_CLEAR, answer(&bytes, HOST_PWD))); + .push((lane.path, LANE_CLEAR, answer(&bytes, st.local.1))); if lane.bound == 0 { st.request = (PATH_REQUEST_BIND, 0, lane.path); } else if self.knobs.rebind_new && lane.path != lane.bound { @@ -801,3 +811,129 @@ fn dialling_an_admitted_candidate_never_binds_unverified() { assert_eq!(r.host.bound(), Some(r.far.addr)); assert!(r.host.presented()); } + +// ── the port's demultiplexer ──────────────────────────────────────────────────────────────────── + +const B_UFRAG: &str = "bfrag"; +const B_PWD: &str = "b-password-0123456789"; + +/// An accepted association on `door`, its rendezvous settled. +fn settled(door: Arc) -> DatagramFraming { + let cert = Arc::new(SessionCert::mint().unwrap()); + let (mut f, _) = DatagramFraming::accept(door, cert, p("198.51.100.1:3478")).unwrap(); + let fp = hex(&SessionCert::mint().unwrap().fingerprint()); + f.rendezvous( + format!("{FAR_UFRAG} {FAR_PWD} {fp}").as_bytes(), + Instant::now(), + ) + .unwrap(); + f +} + +fn lanes(door: &Scripted) -> Vec { + door.st + .lock() + .unwrap() + .seen + .iter() + .map(|s| s.lane) + .collect() +} + +#[test] +fn one_port_finds_each_datagram_its_association_by_user_name_then_by_path() { + let (da, db) = ( + scripted(Knobs::default()), + scripted_as(Knobs::default(), (B_UFRAG, B_PWD)), + ); + let mut port = super::Associations::new(); + let a = port.add(settled(da.clone())).unwrap(); + let b = port.add(settled(db.clone())).unwrap(); + assert_ne!(a, b); + let (pa, pb, stranger) = ( + p("203.0.113.1:1000"), + p("203.0.113.2:2000"), + p("192.0.2.3:3000"), + ); + let now = Instant::now(); + let (before_a, before_b) = (lanes(&da).len(), lanes(&db).len()); + + // Each check finds the association its user name names. + let (id, y) = port + .datagram(pa, &check(HOST_UFRAG, FAR_UFRAG, HOST_PWD, 1), now) + .unwrap(); + assert_eq!(id, a); + y.unwrap(); + let (id, y) = port + .datagram(pb, &check(B_UFRAG, FAR_UFRAG, B_PWD, 2), now) + .unwrap(); + assert_eq!(id, b); + y.unwrap(); + assert_eq!( + lanes(&da)[before_a..], + [LANE_CLEAR], + "a saw its own check only" + ); + assert_eq!( + lanes(&db)[before_b..], + [LANE_CLEAR], + "b saw its own check only" + ); + + // Anything else finds the association by the path a verified check claimed. + let (id, _) = port.datagram(pb, &[0x80, 0x6f, 0, 1], now).unwrap(); + assert_eq!(id, b); + // RED: a user name nobody holds, and a path nobody claimed, reach no association. + assert!(port + .datagram(stranger, &check("nobody", FAR_UFRAG, HOST_PWD, 3), now) + .is_none()); + assert!(port.datagram(stranger, &[0x80, 0x6f, 0, 2], now).is_none()); + // A check that names a but fails its integrity claims no path for a. + let (id, _) = port + .datagram( + stranger, + &check(HOST_UFRAG, FAR_UFRAG, "wrong-password-012345", 4), + now, + ) + .unwrap(); + assert_eq!(id, a); + assert!(port.datagram(stranger, &[0x80, 0x6f, 0, 3], now).is_none()); + + // A user name is one association's; one with no terms is refused. + assert_eq!( + port.add(settled(scripted(Knobs::default()))), + Err(super::NotAdded::UserTaken) + ); + let unsettled = DatagramFraming::accept( + scripted(Knobs::default()), + Arc::new(SessionCert::mint().unwrap()), + p("198.51.100.1:3478"), + ) + .unwrap() + .0; + assert_eq!(port.add(unsettled), Err(super::NotAdded::NoTerms)); + // Removed, its user name and paths go with it. + assert!(port.remove(b).is_some()); + assert!(port.datagram(pb, &[0x80, 0x6f, 0, 4], now).is_none()); + assert_eq!(port.len(), 1); +} + +#[test] +fn a_dialled_association_is_found_by_its_admitted_candidates_before_they_speak() { + let mut r = rig(dialling()); + let mut port = super::Associations::new(); + let host = std::mem::replace( + &mut r.host, + settled(scripted_as(Knobs::default(), (B_UFRAG, B_PWD))), + ); + let id = port.add(host).unwrap(); + // The candidate's first datagram is an answer, not a check: found by its path. + let (got, _) = port + .datagram(r.far.addr, &[0x80, 0x6f, 0, 1], r.now) + .unwrap(); + assert_eq!(got, id); + // RED: the refused candidate's address finds nothing. + assert!(port + .datagram(p(METADATA), &[0x80, 0x6f, 0, 1], r.now) + .is_none()); +} diff --git a/crates/busbar-core-connector/src/tests/udp_tests.rs b/crates/busbar-core-connector/src/tests/udp_tests.rs index befca262da..7464b33a3c 100644 --- a/crates/busbar-core-connector/src/tests/udp_tests.rs +++ b/crates/busbar-core-connector/src/tests/udp_tests.rs @@ -77,3 +77,35 @@ fn ipv6_loopback_binds_when_the_host_has_it() { assert_eq!(recv(&port), (b"v6".to_vec(), peer.local_addr())); } } + +/// RED: off a worker the port is not put on any reactor. +#[test] +fn a_ready_port_off_a_worker_is_refused() { + let e = ReadyPort::open(bind("127.0.0.1:0").unwrap()).expect_err("no worker, no reactor"); + assert_eq!(e.to_string(), crate::io::NOT_ON_A_WORKER); +} + +#[test] +fn a_ready_port_reads_on_readiness_and_sends_with_the_peer() { + crate::support::worker().block_on(async { + let port = ReadyPort::open(bind("127.0.0.1:0").unwrap()).unwrap(); + let far = bind("127.0.0.1:0").unwrap(); + let mut buf = vec![0_u8; MAX_DATAGRAM]; + let waker = std::task::Waker::noop(); + let mut cx = std::task::Context::from_waker(waker); + assert!( + port.poll_recv_from(&mut cx, &mut buf).is_pending(), + "nothing waiting: not ready, no block" + ); + far.try_send_to(b"check", port.local_addr()) + .unwrap() + .unwrap(); + let (n, peer) = futures::future::poll_fn(|cx| port.poll_recv_from(cx, &mut buf)) + .await + .unwrap(); + assert_eq!(&buf[..n], b"check"); + assert_eq!(peer, far.local_addr()); + assert!(port.send_to(b"answer", peer).unwrap()); + assert_eq!(recv(&far), (b"answer".to_vec(), port.local_addr())); + }); +} diff --git a/crates/busbar-core-connector/src/udp.rs b/crates/busbar-core-connector/src/udp.rs index f47d2e5c4c..95b9af8f63 100644 --- a/crates/busbar-core-connector/src/udp.rs +++ b/crates/busbar-core-connector/src/udp.rs @@ -106,6 +106,68 @@ impl UdpPort { } } +/// A [`UdpPort`] on the calling worker's reactor (`io.register`): the port the connector drives. +/// A read waits for the port's readiness, never a thread; a send that would block drops the +/// datagram (a datagram is never queued: the far end's own retransmission is the recovery). +#[derive(Debug)] +pub struct ReadyPort { + socket: crate::io::Registered, + local: SocketAddr, +} + +impl ReadyPort { + /// Put `port` on the calling worker's reactor. + /// + /// # Errors + /// + /// Not on a worker ([`crate::io::NOT_ON_A_WORKER`]), or the reactor refused it. + pub fn open(port: UdpPort) -> io::Result { + let local = port.local; + Ok(ReadyPort { + socket: crate::io::register(port.socket)?, + local, + }) + } + + /// The address the port is bound to. + #[must_use] + pub fn local_addr(&self) -> SocketAddr { + self.local + } + + /// The next datagram into `buf` and the peer it came from; Pending with `cx`'s waker + /// registered until one arrives. + /// + /// # Errors + /// + /// The socket or the reactor failed. + pub fn poll_recv_from( + &self, + cx: &mut std::task::Context<'_>, + buf: &mut [u8], + ) -> std::task::Poll> { + self.socket + .poll_io(crate::io::Direction::Read, cx, |s| s.recv_from(buf)) + } + + /// Send `datagram` to `to` now; `false` = the send buffer was full and it was dropped. + /// + /// # Errors + /// + /// The socket failed, or the datagram was sent short. + pub fn send_to(&self, datagram: &[u8], to: SocketAddr) -> io::Result { + match self.socket.get_ref().send_to(datagram, to) { + Ok(n) if n == datagram.len() => Ok(true), + Ok(_) => Err(io::Error::new( + io::ErrorKind::WriteZero, + "datagram sent short", + )), + Err(e) if e.kind() == io::ErrorKind::WouldBlock => Ok(false), + Err(e) => Err(e), + } + } +} + #[cfg(test)] #[path = "tests/udp_tests.rs"] mod tests; From e07e83a456bfa35837e7934bb28069bb81e93e3d Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:24:31 -0700 Subject: [PATCH 24/59] linked.rs: gate the scrape-sink test on the linked export-doors axis cfg, naming no export instance (law0 busbar x export stays 15) --- crates/busbar/src/root/tests/linked.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/busbar/src/root/tests/linked.rs b/crates/busbar/src/root/tests/linked.rs index 8197b17392..56382befbb 100644 --- a/crates/busbar/src/root/tests/linked.rs +++ b/crates/busbar/src/root/tests/linked.rs @@ -550,10 +550,10 @@ fn every_linked_section_row_is_a_linked_doors_declaring_section() { /// every family type the recorder writes (a HELP-less counter, labels with escapes, a histogram, a /// quantile summary), the sink answers exactly those bytes. Moved here from busbar-kernel's /// `export/tests/scrape_tests.rs` (R-FIX3: the kernel's own tests run on the stand-in axis's scrape -/// double). Compiled only where the scrape sink is linked (`export-prometheus`): a build without it +/// double). Compiled only where an export door is linked (`linked_axis_export_doors`): a build without one /// has no sink to prove, and reports no vacuous pass; where it is linked, an absent module word or /// an unresolved row is a failure. -#[cfg(feature = "export-prometheus")] +#[cfg(linked_axis_export_doors)] #[test] fn the_linked_scrape_sink_renders_the_recorder_snapshot_byte_identically() { let module = busbar_kernel::test_support::export_axis::scrape_module(); From da4748d6e015574b9dc3de583794e93af822c2c3 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:27:55 -0700 Subject: [PATCH 25/59] connector datagram driver: the framer's answered datagram is sent by send_answered, not a fn named route (kind-isolation:control-path) The connector is a control surface and runs no data-path step; the driver's helper only sends a datagram the framer answered where the secure layer allows it, so it no longer borrows the kernel's route-step name. --- crates/busbar-core-connector/src/datagram.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/busbar-core-connector/src/datagram.rs b/crates/busbar-core-connector/src/datagram.rs index c68dec6788..b637962d86 100644 --- a/crates/busbar-core-connector/src/datagram.rs +++ b/crates/busbar-core-connector/src/datagram.rs @@ -663,14 +663,14 @@ impl DatagramFraming { )); } for (path, lane, bytes) in asked.routes { - self.route(path, lane, bytes); + self.send_answered(path, lane, bytes); } self.request(asked.request, now); Ok(done) } /// Send one route where the secure layer allows it, or drop it. - fn route(&mut self, path: u32, lane: u32, bytes: Vec) { + fn send_answered(&mut self, path: u32, lane: u32, bytes: Vec) { let to = self.book.addr(path); let sent = match (self.secure.as_mut(), to) { (Some(secure), Some(to)) if lane == LANE_CLEAR => { From 6b3fd07d42f5b5b435e6ac8beffa2fce61381e46 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:40:28 -0700 Subject: [PATCH 26/59] approvals_tests: drop the spend-once cell; SpentTokenLedger::spend left with the engine (predev), and the root's door_steps spent_ledger proves the spend --- crates/busbar-kernel/src/plane/tests/approvals_tests.rs | 8 -------- 1 file changed, 8 deletions(-) diff --git a/crates/busbar-kernel/src/plane/tests/approvals_tests.rs b/crates/busbar-kernel/src/plane/tests/approvals_tests.rs index 8da1f6bf33..de10ce581b 100644 --- a/crates/busbar-kernel/src/plane/tests/approvals_tests.rs +++ b/crates/busbar-kernel/src/plane/tests/approvals_tests.rs @@ -48,11 +48,3 @@ fn the_kernel_holds_no_plane_named_seal_domain() { "the kernel carries a plane's seal domain: {carriers:?}" ); } - -#[test] -fn the_ledger_spends_an_approval_once() { - let ledger = super::SpentTokenLedger::new(); - let nonce = super::nonce().expect("a nonce"); - assert!(ledger.spend(&nonce, 100 + super::DEFAULT_TTL_SECS, 100)); - assert!(!ledger.spend(&nonce, 100 + super::DEFAULT_TTL_SECS, 101)); -} From 943d7dddeee94a0e8956ef4213a2072b1b57d19a Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:02:11 -0700 Subject: [PATCH 27/59] kind-isolation: the kernel names no store crate; its tests link an in-crate store double (row 88) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ship twin of kind-isolation:deps read `busbar-kernel -> busbar-store-memory` as `not-allowed`: the kernel named the pinned store plugin as `fixture-store`, an optional [dependencies] edge the `test-support` feature switched on so a test build (no composition root) had a linked store row and the `MemoryStore` every kernel test builds a `GovState` over. THE DESIGN §2 (BUSBAR-1.6.0.md l.216-221): a plugin talks to core only over its kind's memory ABI, so the kernel names no store crate under any name. - busbar-kernel: `test_support::store_double`, the R-FIX3 double (BUSBAR-1.6.0.md l.3986): the shipped RAM store at the root's pinned rev (848851e63a), its record verbs and v3 slots, behind its own Statement and canonical name (`store-double`, C1) and the key a migrated config names (`MIGRATED_STORE_MODULE`). `preflight::STAND_IN`, `fixtures::stand_in_store`, `governance::MemoryStore` and door_op_id_tests take it. The `fixture-store` edge and its workspace row are deleted; Cargo.lock drops the kernel's store dependency. - busbar (root): `linked_store_double` proves the double states what the linked store door states (kind, ABI, max_inflight, marks, needs, tail, ephemeral flag) and answers one verb script the same through its door on the store axis. RED by flipping any copied rule in the double. - qa/kind-isolation.toml: the `busbar-kernel -> busbar-store-memory` [[dep]] row is struck (the edge is gone); law0 busbar-kernel x store 4 -> 1 (the three test comments that named the plugin by package now name the double; config/sections.rs's 1.5.x citation is the one left). --- Cargo.lock | 1 - Cargo.toml | 6 +- crates/busbar-kernel/Cargo.toml | 17 +- .../src/audit/tests/journal_tests.rs | 8 +- crates/busbar-kernel/src/governance/mod.rs | 6 +- .../src/plane/tests/demotion_tests.rs | 7 +- .../src/plane_host/tests/journal_tests.rs | 6 +- crates/busbar-kernel/src/preflight.rs | 8 +- .../src/test_support/fixtures.rs | 2 +- crates/busbar-kernel/src/test_support/mod.rs | 4 + .../src/test_support/store_double/mod.rs | 926 +++++++++++++++++ .../src/test_support/store_double/v3.rs | 933 ++++++++++++++++++ .../src/tests/door_op_id_tests.rs | 2 +- crates/busbar/src/root/linked.rs | 4 + .../src/root/tests/linked_store_double.rs | 247 +++++ qa/kind-isolation.toml | 15 +- 16 files changed, 2147 insertions(+), 45 deletions(-) create mode 100644 crates/busbar-kernel/src/test_support/store_double/mod.rs create mode 100644 crates/busbar-kernel/src/test_support/store_double/v3.rs create mode 100644 crates/busbar/src/root/tests/linked_store_double.rs diff --git a/Cargo.lock b/Cargo.lock index ca11475887..c8b06161e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -733,7 +733,6 @@ dependencies = [ "busbar-kernel-scope", "busbar-kernel-wal", "busbar-plugin-loader", - "busbar-store-memory", "bytes", "crc32fast", "criterion", diff --git a/Cargo.toml b/Cargo.toml index eb15df80ac..289dea8d45 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -108,10 +108,10 @@ busbar-auth-sigv4 = { git = "https://github.com/GetBusbar/busbar-auth-sigv4", re busbar-hook-ranking = { git = "https://github.com/GetBusbar/busbar-hook-ranking", rev = "7e1285e2e3293627723ab2754b9ddbfddeba9c46" } # THE MEMORY STORE (kind: store), pulled at a pinned rev: the logic crate the root links on its store # axis in every build (`[package.metadata.busbar.linked] store-memory`) and the plugin loader's `store` -# both-ways fixture. The same package at the same rev, under the role the kernel's test build gives it -# (`fixture-store`: a kernel source names no plugin). A rev bump moves both lines. +# both-ways fixture. The kernel names it under no name: its test build links the in-crate R-FIX3 +# double (`busbar_kernel::test_support::store_double`), and the root's `linked_store_double` tests +# prove the double states what this door states. busbar-store-memory = { git = "https://github.com/GetBusbar/busbar-store-memory", rev = "848851e63ac7a454eb3d5daf9e11d739c48d738b" } -fixture-store = { package = "busbar-store-memory", git = "https://github.com/GetBusbar/busbar-store-memory", rev = "848851e63ac7a454eb3d5daf9e11d739c48d738b" } # ── A2A, from the a2aproject's OWN crates ────────────────────────────────────────────────────── # THE OBVIOUS NAME IS THE WRONG ONE. `a2a-rs` on crates.io is `emillindfors/a2a-rs` (MIT, v0.5.0) — # a different author entirely. The a2aproject family is the `a2a-*-lf` / `a2a-pb` set, all diff --git a/crates/busbar-kernel/Cargo.toml b/crates/busbar-kernel/Cargo.toml index e0aa9b862d..08c435f2df 100644 --- a/crates/busbar-kernel/Cargo.toml +++ b/crates/busbar-kernel/Cargo.toml @@ -86,14 +86,13 @@ busbar-kernel-scope = { path = "../busbar-kernel-scope" } # dump, and only under the non-default `timing` feature. The default build does not link it. libc = { workspace = true, optional = true } busbar-plugin-loader = { path = "../plugin-loader" } -# THE STAND-IN LINKED ROWS (test-only edges): the composition root links the build's default store -# and ranking hooks onto the cold-kind axis (`preflight::install_linked_rows`); a test build has no -# root, so these stand in, under `test-support` (which the dev-dependency cycle through the plane -# crates turns on for this crate's own tests). The store is a fixture, renamed to the role it plays: -# no source here names the plugin. The ranking row is no crate at all: it is the in-crate R-FIX3 -# double `test_support::ranking_double`, linked only, and the root proves it states what the real -# ranking door states. -fixture-store = { workspace = true, optional = true } +# THE STAND-IN LINKED ROWS are no crate at all: the composition root links the build's store and +# ranking hooks onto the cold-kind axis (`preflight::install_linked_rows`); a test build has no root, +# so the in-crate R-FIX3 doubles stand in, linked only — `test_support::store_double` (the store row +# and the `MemoryStore` the kernel's tests build a `GovState` over) and `test_support::ranking_double`. +# The kernel names no store crate under any name (THE DESIGN Part 2 #2: the kernel reaches a store +# only through the store kind's door), and the root proves each double states what the real door +# states. axum = { workspace = true } tower = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "macros", "net", "time", "sync", "signal", "io-util", "io-std", "process"] } @@ -242,7 +241,7 @@ loom-model = ["dep:loom"] # Exposes `test_support` to a DEPENDENT crate's tests (the bin's). Never on in a normal build; # the bin enables it from its [dev-dependencies] edge only. The contract's test double (its warn # capture) is always compiled (ARCHITECT SD-3 queue (4)), so nothing forwards there. -test-support = ["dep:reqwest", "dep:rcgen", "dep:fixture-store", "busbar-kernel-identity/stand-in", "busbar-plugin-loader/test-support"] +test-support = ["dep:reqwest", "dep:rcgen", "busbar-kernel-identity/stand-in", "busbar-plugin-loader/test-support"] # The debug-only per-method micro-timing in `src/timing` (folded from busbar-timing, OWNER Q70). # DEFAULT OFF: with this off, `timeit!` expands to `()` and the RAII guards it produces are # zero-sized no-ops, so the `drop(_guard)` calls that end a timed span are compiled in ONLY under this diff --git a/crates/busbar-kernel/src/audit/tests/journal_tests.rs b/crates/busbar-kernel/src/audit/tests/journal_tests.rs index 882366a724..4e5fb256b0 100644 --- a/crates/busbar-kernel/src/audit/tests/journal_tests.rs +++ b/crates/busbar-kernel/src/audit/tests/journal_tests.rs @@ -755,10 +755,10 @@ fn an_evicted_neutral_scopes_tampered_tail_surfaces_a_break_on_resume() { /// AUDIT-INTEGRITY FIX: `append_scoped` hardcoded every row's `PlaneRecord::ts` to `0`, which defeats /// retention entirely — `purge_plane_records_before` drops any row with `r.ts < before`, so a `ts` of /// 0 makes every row look infinitely old and the FIRST retention sweep deletes the whole class -/// regardless of how recently it was written. Proven over the REAL `busbar-store-memory` backend -/// (not this file's in-RAM `MockStore`, whose `purge_plane_records_before` is a stub that always -/// answers 0 and so cannot exercise this): a record appended just now must SURVIVE a retention sweep -/// whose cutoff is safely in the past. +/// regardless of how recently it was written. Proven over the kernel's store double, which carries +/// the shipped RAM store's retention (not this file's in-RAM `MockStore`, whose +/// `purge_plane_records_before` is a stub that always answers 0 and so cannot exercise this): a +/// record appended just now must SURVIVE a retention sweep whose cutoff is safely in the past. #[test] fn append_scoped_stamps_a_real_instant_so_a_fresh_row_survives_retention() { let store: Arc = diff --git a/crates/busbar-kernel/src/governance/mod.rs b/crates/busbar-kernel/src/governance/mod.rs index e2fc360024..935e976b0f 100644 --- a/crates/busbar-kernel/src/governance/mod.rs +++ b/crates/busbar-kernel/src/governance/mod.rs @@ -1508,10 +1508,10 @@ impl IntoStoreResult for Result { } } -// The universal test double: the build's default store, which the composition root links onto the -// store axis (the kernel names no store); a test build links it as its store fixture. +// The universal test double: the in-crate R-FIX3 store double (`test_support::store_double`), which a +// test build also links as its stand-in store row. The kernel names no store crate. #[cfg(any(test, feature = "test-support"))] -pub use fixture_store::MemoryStore; +pub use crate::test_support::store_double::MemoryStore; /// The write-behind flusher: on a fixed cadence (and once more on graceful shutdown) pushes the /// dirty in-memory budget cells and the accumulated `pending_metering` rows to the durable store off diff --git a/crates/busbar-kernel/src/plane/tests/demotion_tests.rs b/crates/busbar-kernel/src/plane/tests/demotion_tests.rs index d8d22dcdad..581dd39fb6 100644 --- a/crates/busbar-kernel/src/plane/tests/demotion_tests.rs +++ b/crates/busbar-kernel/src/plane/tests/demotion_tests.rs @@ -223,9 +223,10 @@ fn with_no_durable_sink_a_demotion_is_recorded_nowhere() { } /// AND THE RAM DEFAULT REALLY IS RAM — which is a statement about the PROCESS, not about the write. -/// Within one process `busbar-store-memory` is a real store and reads a demotion back; what it loses -/// is everything the moment the process ends, and a restart is modelled here the only way a RAM -/// restart can be, by opening a new one. That is the documented `store: memory` contract. +/// Within one process the RAM store (here its kernel double) is a real store and reads a demotion +/// back; what it loses is everything the moment the process ends, and a restart is modelled here the +/// only way a RAM restart can be, by opening a new one. That is the documented `store: memory` +/// contract. /// /// The "a write that reaches nothing reads back as nothing" control lives in the no-sink case above, /// where it belongs: the engine learns durability by READING BACK and never from a write's return diff --git a/crates/busbar-kernel/src/plane_host/tests/journal_tests.rs b/crates/busbar-kernel/src/plane_host/tests/journal_tests.rs index 7ec886b8f8..8bf1712ee3 100644 --- a/crates/busbar-kernel/src/plane_host/tests/journal_tests.rs +++ b/crates/busbar-kernel/src/plane_host/tests/journal_tests.rs @@ -258,9 +258,9 @@ extern "C-unwind" fn neutral_reframe( StatusClass::Ok } -/// A generic PERSISTING `Store` double: the shipped `busbar_store_memory` keeps NO plane records -/// (the lossy RAM default), so this delegates the required key/usage/metering surface to a real -/// memory store (so `GovState::new` sees genuine governance behaviour) and PERSISTS the neutral +/// A generic PERSISTING `Store` double: the RAM store keeps NO plane records across a restart (the +/// lossy RAM default), so this delegates the required key/usage/metering surface to the kernel's +/// store double (so `GovState::new` sees genuine governance behaviour) and PERSISTS the neutral /// plane-record verbs generically by `(kind, parent)` — exactly what a durable backend does. struct GenericPlaneStore { inner: crate::governance::MemoryStore, diff --git a/crates/busbar-kernel/src/preflight.rs b/crates/busbar-kernel/src/preflight.rs index 7b91a936e1..f28c2ec916 100644 --- a/crates/busbar-kernel/src/preflight.rs +++ b/crates/busbar-kernel/src/preflight.rs @@ -145,12 +145,12 @@ pub type HookAxisBuild = &std::sync::Arc, ) -> Result, String>; -/// A test build has no root: its store fixture and ranking double stand in for the root's entries, the -/// in-crate secret double ([`crate::test_support::secrets`]) as the secret axis, and the test axis -/// for the exports. +/// A test build has no root: its store double ([`crate::test_support::store_double`]) and ranking +/// double stand in for the root's entries, the in-crate secret double +/// ([`crate::test_support::secrets`]) as the secret axis, and the test axis for the exports. #[cfg(any(test, feature = "test-support"))] const STAND_IN: RootInstall = RootInstall { - stores: &[fixture_store::linked::STORE], + stores: &[crate::test_support::store_double::STORE], registry_build: Some(crate::test_support::registry_stand_in), plugins_fetch: Some(crate::test_support::fetch_stand_in), hook_axis: Some(crate::test_support::hook_axis_stand_in), diff --git a/crates/busbar-kernel/src/test_support/fixtures.rs b/crates/busbar-kernel/src/test_support/fixtures.rs index d447c002b6..649e1dd52b 100644 --- a/crates/busbar-kernel/src/test_support/fixtures.rs +++ b/crates/busbar-kernel/src/test_support/fixtures.rs @@ -15,7 +15,7 @@ use super::*; /// fixture config carries this one. pub fn stand_in_store() -> crate::config::StoreCfg { crate::config::StoreCfg { - module: fixture_store::linked::STORE.0.to_string(), + module: crate::test_support::store_double::STORE.0.to_string(), settings: serde_json::Map::new(), } } diff --git a/crates/busbar-kernel/src/test_support/mod.rs b/crates/busbar-kernel/src/test_support/mod.rs index 514d6c8ef5..302e83dd61 100644 --- a/crates/busbar-kernel/src/test_support/mod.rs +++ b/crates/busbar-kernel/src/test_support/mod.rs @@ -2156,6 +2156,10 @@ pub mod hook_double; /// test build. Linked only. pub mod ranking_double; +/// The store double (R-FIX3): the door that stands in for the root's linked store row in a test +/// build, and the `MemoryStore` the kernel's tests build a `GovState` over. Linked only. +pub mod store_double; + /// A [`crate::hooks::HookEnv`] whose registry holds one `kind: hook` row per alias (each stating the /// given manifest `needs`, as a dropped-in plugin's signed manifest does) and whose hook axis is the /// [`hook_double::HookAxisDouble`] answering those aliases, beside the build's linked rows. The diff --git a/crates/busbar-kernel/src/test_support/store_double/mod.rs b/crates/busbar-kernel/src/test_support/store_double/mod.rs new file mode 100644 index 0000000000..59fe83ca15 --- /dev/null +++ b/crates/busbar-kernel/src/test_support/store_double/mod.rs @@ -0,0 +1,926 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! THE STORE DOUBLE (R-FIX3): the in-memory store that stands in for the root's linked store row in +//! a test build, and the universal `RecordStore` the kernel's own tests build a `GovState` over. +//! Linked only. +//! +//! ## Why a double, and why this one (ARCHITECT R-FIX3; THE DESIGN Part 2 #2) +//! +//! The kernel reaches a store only through the store kind's door, and it names no store crate: not +//! in `[dependencies]`, not behind a feature, not under a second name. A test build has no +//! composition root, so the kernel's own tests that boot an app need a linked `kind: store` row +//! whose key is the store a migrated 1.5.x config names ([`MIGRATED_STORE_MODULE`]), and the tests +//! that build a `GovState` need a store that keeps what it is told. A behaviour double the kernel's +//! own tests need lives HERE, as a test-support module of the crate under test, LINKED only — not a +//! workspace crate, not an example plugin, and not the shipped plugin taken under another name. +//! +//! What it copies is the shipped RAM store at the rev the root pins (`848851e63a`): the same record +//! verbs and their preconditions, the same retention sweep, the same v3 slots (dedupe, caps, +//! slices, ledger chains, sessions), the same tail and `max_inflight`. Its Statement name and its +//! canonical name are its OWN ([`NAME`]), never the plugin's: the kernel spells no plugin name, not +//! even in a double (C1). That the copy holds is proven where the real door is linked: the root's +//! `the_kernel_store_double_states_what_the_linked_store_door_states` renders both Statements and +//! drives one verb sequence through both. The plugin's own behaviour is proven in its own repo. +//! +//! EPHEMERAL: every counter, key and credential is lost with the handle. Poison-recovering locks +//! (the governance surface must never panic on a request). + +use crate::config::MIGRATED_STORE_MODULE; + +use busbar_contract::records::{ + AuditRecord, CredentialMeta, CredentialSecret, MeteringDelta, MeteringRow, PlaneDisposition, + PlaneRecord, PlaneRecordRef, PlaneSelector, RecordStore as Store, + RecordStoreError as StoreError, RecordStoreResult as StoreResult, UsageDelta, UsageLedger, + VirtualKey, +}; +// The record half of the store protocol: the three verbs a `PlaneRecord` leg is run over, at the +// contract's own spelling. `StoreError` is imported under a second name because the two protocols +// each carry one and this crate answers both. +use busbar_contract::ids::RecordSchemaId; +use busbar_contract::kinds::{RecordBytes, StoreError as ContractStoreError}; +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::RwLock; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Retention ceiling for `usage`/`metering` rows, keyed by their epoch-second period-start field +/// (`window_start` / `bucket`). Mirrors `busbar::governance`'s own 31-day `max_window` sweep of its +/// in-memory rate-map cells (`crates/busbar-core/src/governance/mod.rs`): this store's ledgers are a +/// durability shadow of that engine state, so retaining them exactly as long as the engine keeps +/// its own cells is the right correspondence, not an arbitrary shorter/longer number. +const MAX_RETENTION_SECS: u64 = 31 * 86_400; + +/// Amortized sweep cadence: one `retain()` pass per this many writes. Mirrors +/// `DEFAULT_RATE_SWEEP_INTERVAL` (`crates/busbar-core/src/config/mod.rs`). +const SWEEP_INTERVAL: u64 = 256; + +/// The `records` map: `(schema, key) -> body`, in KEY ORDER because `record_scan` promises a +/// prefix walk answers in it. +type RecordMap = std::collections::BTreeMap<(String, Vec), RecordBytes>; + +fn now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// In-memory `Store`: keys by id, row-looked-up credentials by id (`(kind, public_id)` lookup and +/// the per-key listing/cascade are SCANS over that one map, not secondary indexes — the RAM backend +/// holds a fixture-sized table and a second map to keep in step with every sweep, tombstone cascade +/// and rotation is more failure surface than the scan costs), token ledgers keyed by (bucket_id, +/// window_start), metering rows keyed by (key_id, bucket, model, provider, priced_from_ms). +/// A metering row's key: (key_id, bucket, model, provider, priced_from_ms). +type MeteringKey = (String, u64, String, String, u64); + +#[derive(Default)] +pub struct MemoryStore { + keys: RwLock>, + creds: RwLock>, + usage: RwLock>, + metering: RwLock>, + /// The revocation DENYLIST: denied subject ids (1.5.0 signed-token keys). A set (the reason is + /// audit-only and not needed for the enforcement read). + denylist: RwLock>, + /// The SPENT-TOKEN ledger behind `redeem_plane_token`: `(kind, token) -> expires_at`. Presence + /// means "already redeemed", so the test-and-set is an occupied-entry check under one guard. + /// Bounded by dropping lapsed rows on every redemption (a token past its own `expires_at` can + /// never be presented again, so keeping it proves nothing). + plane_tokens: RwLock>, + /// Plane records keyed by `(kind, identity, seq)`. The identity is the record's `parent` for the + /// APPENDED child kinds (a chain is `(parent, seq)`) and its `id` for the upserted ones, which + /// take `seq` 0 — one map serves both because the child kinds never point-read by id. + plane_records: RwLock>, + /// KERNEL-HELD DURABLE RECORDS, under the contract's own three verbs, keyed by + /// `(schema, key)`. + /// + /// A SECOND map beside `plane_records` and deliberately not a re-keying of it. The eight + /// kind-tagged verbs above are the PUBLISHED store protocol the previous release's callers still + /// drive, byte for byte; these three are what `busbar_contract::abi::sdk::store::StoreSlots` declares for a + /// record leg, and they key on an opaque byte string rather than on the `(kind, id, seq)` + /// columns. Folding them onto one map would make every record leg a change to the published + /// path's key shape, which is precisely the thing that has to stay identical. + /// + /// A `BTreeMap` because `record_scan` walks a PREFIX and answers in key order: a hash map would + /// have to sort on every scan, and "the order a scan answers in" is a promise a caller reads a + /// chain by. + /// + /// NEVER AGED OUT. An active work handle is never evicted (`BUSBAR-1.6.0.md` THE DESIGN §1, + /// "Admission bounds live work; nothing evicts it"): a real bound on live work is a refusal at + /// admission, and retention bounds only SETTLED handles. Settledness is a sidecar the store is + /// handed, never a body it decodes — the published path's [`PlaneDisposition`], which + /// `purge_plane_records_before` honours (the conformance suite's + /// `assert_plane_purge_task_keeps_active_rows`). A record-leg row carries no such sidecar: its + /// key is opaque bytes and its value an opaque body, so this backend can never know a row is + /// settled, and a row it cannot prove settled is one it may not drop. Write age is not + /// settledness: an interrupted task waiting on a human is exactly the row nothing rewrites for + /// the longest, and a one-time claim (`records.claim`) dropped by age is a claim that can be + /// made twice. + /// + /// Nor is there a delete verb: `busbar_contract::abi::sdk::store::StoreSlots` declares + /// `record_put`/`record_get`/`record_scan` and nothing that removes a row, so a backend cannot + /// invent one here. The map's bound is the kernel's admission and its own settled-handle sweep, + /// not this backend's clock; and the backend is EPHEMERAL, so a restart empties it. + records: RwLock, + /// The durable admin AUDIT log, by `seq`. EPHEMERAL like every other map here — this backend is + /// RAM — but append-only and fork-detecting within the process's life, which is what the + /// engine's write-through actually asks of a store. Ordered reads come from the `BTreeMap`. + audit: RwLock>, + /// Amortized-sweep write counters for `usage`/`metering`/tombstoned `keys`/revoked `creds` + /// (see `MAX_RETENTION_SECS`). Separate per map since the maps see independent write rates. The + /// `records` map has none: it is never aged out (see its doc). + usage_sweep_ticker: AtomicU64, + metering_sweep_ticker: AtomicU64, + keys_sweep_ticker: AtomicU64, + creds_sweep_ticker: AtomicU64, + /// The store-global monotonic revision counter (see `VirtualKey::revision`). Bumped on every + /// mutation to `keys`/`creds`/the denylist. + revision: AtomicU64, + /// Test-only pinned clock for the retention sweep. `0` (the `Default`) means "use the real wall + /// clock" (`now()`); any non-zero value pins `self.now()` to that epoch-second so a test can make + /// the sweep's retention ceiling EXACTLY match the timestamp the test itself captured. This + /// removes the wall-clock race in the exact-boundary sweep tests, where a one-second tick between + /// the test's `now()` and the sweep's `now()` would otherwise shift the ceiling and evict the + /// "one second inside" row. Prod behavior is untouched: the field is only ever set from tests. + clock: AtomicU64, + /// The store v3 slots' own state: the `op_id` dedupe log, the caps, the drawn totals, the + /// slices, the ledger chains and the session directory ([`v3`]). + v3: v3::State, +} + +impl MemoryStore { + pub fn new() -> Self { + Self::default() + } + + /// Write one of a plane's kernel-held durable records. + /// + /// The verb is `busbar_contract::abi::sdk::store::StoreSlots::record_put`'s, and the three + /// below are its siblings. They are INHERENT rather than a trait implementation for one reason, + /// and it is a rule rather than a preference: the manifest allow-list refuses a store-kind crate + /// that names `busbar-kernel`, so the kernel's own record sink is not a trait this crate may + /// implement; and the contract's `StoreSlots` is the whole store table, of which this + /// backend answers the published half through [`Store`] above. What is here is the record half, + /// at the contract's own spelling, so the adapter that binds a loaded store to the kernel's sink + /// has one shape to forward to rather than two. + /// + /// The value arrives as a [`RecordBytes`], which is where the record ceiling is enforced: a body + /// over it cannot be constructed, so this method cannot be handed one. + /// + /// # Errors + /// + /// Never, for a RAM backend: there is nothing under it to be unavailable. The result is the + /// contract's shape so a durable backend can answer in the same place. + pub fn record_put( + &self, + schema: RecordSchemaId, + key: &[u8], + value: &RecordBytes, + ) -> Result<(), ContractStoreError> { + self.record_put_at(schema.as_str(), key, value); + Ok(()) + } + + /// [`Self::record_put`] under a schema named at run time (the store v3 table's `record_put`). + pub(crate) fn record_put_at(&self, schema: &str, key: &[u8], value: &RecordBytes) { + // No age sweep rides this write: a record-leg row is never evicted by age (see the + // `records` field's doc), because nothing here can tell an active work handle from a + // settled one. + self.records + .write() + .unwrap_or_else(|e| e.into_inner()) + .insert((schema.to_string(), key.to_vec()), value.clone()); + } + + /// Read one of a plane's kernel-held durable records. + /// + /// # Errors + /// + /// Never, for a RAM backend. An absent record is `Ok(None)` and not an error: a plane asking + /// for a row it has not written yet is an ordinary answer, not a fault. + pub fn record_get( + &self, + schema: RecordSchemaId, + key: &[u8], + ) -> Result, ContractStoreError> { + Ok(self.record_get_at(schema.as_str(), key)) + } + + /// [`Self::record_get`] under a schema named at run time. + pub(crate) fn record_get_at(&self, schema: &str, key: &[u8]) -> Option { + self.records + .read() + .unwrap_or_else(|e| e.into_inner()) + .get(&(schema.to_string(), key.to_vec())) + .cloned() + } + + /// Walk a plane's records under a prefix, in key order, at most `limit` of them. + /// + /// `limit` 0 means NOTHING, not everything. A caller that wants the whole prefix names a number; + /// reading zero as unbounded would make a miscomputed bound the one case that returns the entire + /// schema. + /// + /// # Errors + /// + /// Never, for a RAM backend. + pub fn record_scan( + &self, + schema: RecordSchemaId, + prefix: &[u8], + limit: u32, + ) -> Result, RecordBytes)>, ContractStoreError> { + Ok(self.record_scan_at(schema.as_str(), prefix, limit)) + } + + /// [`Self::record_scan`] under a schema named at run time. + pub(crate) fn record_scan_at( + &self, + schema: &str, + prefix: &[u8], + limit: u32, + ) -> Vec<(Vec, RecordBytes)> { + self.records + .read() + .unwrap_or_else(|e| e.into_inner()) + .iter() + .filter(|((s, k), _)| s == schema && k.starts_with(prefix)) + .take(limit as usize) + .map(|((_, k), value)| (k.clone(), value.clone())) + .collect() + } + fn keys(&self) -> std::sync::RwLockWriteGuard<'_, HashMap> { + self.keys.write().unwrap_or_else(|e| e.into_inner()) + } + fn creds(&self) -> std::sync::RwLockWriteGuard<'_, HashMap> { + self.creds.write().unwrap_or_else(|e| e.into_inner()) + } + fn usage(&self) -> std::sync::RwLockWriteGuard<'_, HashMap<(String, u64), UsageLedger>> { + self.usage.write().unwrap_or_else(|e| e.into_inner()) + } + fn metering(&self) -> std::sync::RwLockWriteGuard<'_, HashMap> { + self.metering.write().unwrap_or_else(|e| e.into_inner()) + } + // The SHARED (read) side of the same four locks, for the methods that only ever read. A pure + // read taking the exclusive side serializes every concurrent reader behind it, and the reads + // here are the expensive ones — `list_keys`/`list_credentials`/`list_metering` clone whole + // tables — so a governance `get_key` on the admit path would wait out an admin listing pass. + // Poison-recovering for the same reason the write accessors are: the governance surface must + // never panic on a request. + fn keys_read(&self) -> std::sync::RwLockReadGuard<'_, HashMap> { + self.keys.read().unwrap_or_else(|e| e.into_inner()) + } + fn creds_read(&self) -> std::sync::RwLockReadGuard<'_, HashMap> { + self.creds.read().unwrap_or_else(|e| e.into_inner()) + } + fn usage_read(&self) -> std::sync::RwLockReadGuard<'_, HashMap<(String, u64), UsageLedger>> { + self.usage.read().unwrap_or_else(|e| e.into_inner()) + } + fn metering_read(&self) -> std::sync::RwLockReadGuard<'_, HashMap> { + self.metering.read().unwrap_or_else(|e| e.into_inner()) + } + fn next_revision(&self) -> u64 { + self.revision.fetch_add(1, Ordering::Relaxed) + 1 + } + /// "Now" as the retention sweep sees it: the pinned test clock if set (`clock != 0`), else the + /// real wall clock. See the `clock` field. + fn now(&self) -> u64 { + match self.clock.load(Ordering::Relaxed) { + 0 => now(), + pinned => pinned, + } + } + fn plane_records( + &self, + ) -> std::sync::RwLockWriteGuard<'_, HashMap<(String, String, u64), PlaneRecord>> { + self.plane_records + .write() + .unwrap_or_else(|e| e.into_inner()) + } + fn plane_records_read( + &self, + ) -> std::sync::RwLockReadGuard<'_, HashMap<(String, String, u64), PlaneRecord>> { + self.plane_records.read().unwrap_or_else(|e| e.into_inner()) + } + /// A plane record's identity in the one map: its `parent` when it is an APPENDED child (a chain + /// position is `(parent, seq)`), else its own `id` at `seq` 0. + fn plane_key(record: PlaneRecordRef<'_>) -> (String, String, u64) { + let identity = record.parent.unwrap_or(record.id).to_string(); + (record.kind.to_string(), identity, record.seq) + } + /// The two credential-table preconditions, factored out of `put_credential` so the ATOMIC + /// `put_key_with_credential` can run the identical rules under its own single critical section + /// rather than a second, drifting copy of them. Takes the map (not the guard) so either caller's + /// guard serves. + fn credential_preconditions( + creds: &HashMap, + secret: &CredentialSecret, + ) -> StoreResult<()> { + // Reject an explicit slot pointed at a LIVE credential of the same (key_id, kind) — see the + // trait doc: silently clobbering a working credential mid-overlap-window is almost always an + // operator mistake, not an intended rotation. + let occupied = creds.values().any(|c| { + c.meta.id != secret.meta.id + && c.meta.key_id == secret.meta.key_id + && c.meta.kind == secret.meta.kind + && c.meta.slot == secret.meta.slot + && c.meta.revoked_at.is_none() + }); + if occupied { + return Err(StoreError(format!( + "put_credential: slot {} for key '{}' kind '{}' holds a live credential; revoke it first", + secret.meta.slot, secret.meta.key_id, secret.meta.kind + ))); + } + // UNIQUE(kind, public_id): a public_id must never resolve to two different credentials, + // even across keys (an AccessKeyId is a global lookup handle). + let public_id_taken = creds.values().any(|c| { + c.meta.id != secret.meta.id + && c.meta.kind == secret.meta.kind + && c.meta.public_id == secret.meta.public_id + }); + if public_id_taken { + return Err(StoreError(format!( + "put_credential: public_id '{}' is already in use for kind '{}'", + secret.meta.public_id, secret.meta.kind + ))); + } + Ok(()) + } +} + +impl Store for MemoryStore { + fn put_key(&self, key: &VirtualKey) -> StoreResult<()> { + let mut key = key.clone(); + let mut keys = self.keys(); + // The tombstone precondition, tested and applied under ONE guard acquisition so it is + // atomic — see the trait doc. A live-shaped write over a tombstoned row would resurrect a + // key an operator revoked, and core's caller-side `deleted_at` checks cannot close that: + // they are read-then-write, and a `delete_key` committing in the gap goes straight through. + // A write that CARRIES a tombstone clears nothing and stays allowed. + if key.deleted_at.is_none() { + if let Some(existing) = keys.get(&key.id) { + if existing.deleted_at.is_some() { + return Err(StoreError(format!( + "put_key: '{}' is tombstoned and its id is never reissued; refusing to \ + clear the tombstone", + key.id + ))); + } + } + } + key.revision = self.next_revision(); + keys.insert(key.id.clone(), key); + + // Amortized bounded eviction of stale TOMBSTONES, mirroring `add_usage`/`add_metering` + // above: `keys` tombstones survive `delete_key` forever (by design, for billing/audit + // attribution) but a repeated self-serve issue/refresh loop by one principal is a `put_key` + // hot path, so sweeping it here bounds the map the same way. NEVER prunes a live row (only + // `deleted_at.is_some()` rows are even candidates), and only past the SAME 31-day ceiling + // attribution already stops caring past. + let sweep_needed = self + .keys_sweep_ticker + .fetch_add(1, Ordering::Relaxed) + .wrapping_add(1) + .is_multiple_of(SWEEP_INTERVAL); + if sweep_needed { + let n = self.now(); + keys.retain(|_, k| match k.deleted_at { + None => true, // live rows are never pruned + Some(deleted_at) => deleted_at.saturating_add(MAX_RETENTION_SECS) > n, + }); + } + Ok(()) + } + + fn get_key(&self, id: &str) -> StoreResult> { + Ok(self.keys_read().get(id).cloned()) + } + + fn list_keys(&self) -> StoreResult> { + // Deliberately UNFILTERED — see the trait doc. Tombstones are included so both the admin + // listing caller (which filters live-only itself) and the default `list_keys_since` (which + // needs tombstones visible) are served by this one method. + let mut v: Vec = self.keys_read().values().cloned().collect(); + v.sort_by_key(|k| k.created_at); // mirror SqliteStore's ORDER BY created_at + Ok(v) + } + + fn delete_key(&self, id: &str) -> StoreResult<()> { + // TOMBSTONE (1.5.0 redesign): the key row SURVIVES, so anything that attributes by key id + // (billing/metering rows, audit records) keeps resolving forever, and the id is never + // reissued. Only the CREDENTIALS (live secret material) and the rate/budget `usage` ledger + // are actually removed — `metering` is durable billing evidence and was never cascaded here + // even before this redesign (confirmed: it has its own independent lifecycle from `usage`). + // + // ATOMICITY: hold ALL THREE write guards for the WHOLE cascade rather than taking them + // one-at-a-time, for the same reason as before this redesign — a concurrent write-behind + // `add_usage` must not be able to resurrect a ledger row in the gap. Fixed lock order + // (keys → usage → creds) so this cannot deadlock against any other method. + let mut keys = self.keys(); + let mut usage = self.usage(); + let mut creds = self.creds(); + let Some(key) = keys.get_mut(id) else { + // NOT the idempotent case. "Already tombstoned" (below) means the operator's intent is + // satisfied and the evidence is on disk; "no such id" means nothing was touched, and + // `Ok(())` here tells an operator who typo'd an id that a key was revoked when none was. + return Err(StoreError(format!("delete_key: unknown id '{id}'"))); + }; + if key.deleted_at.is_some() { + return Ok(()); // idempotent: already tombstoned + } + let rev = self.next_revision(); + key.enabled = false; + key.deleted_at = Some(self.now()); + key.revision = rev; + usage.retain(|(k, _), _| k != id); + creds.retain(|_, c| c.meta.key_id != id); + Ok(()) + } + + fn scrub_key(&self, id: &str) -> StoreResult<()> { + let mut keys = self.keys(); + let Some(key) = keys.get_mut(id) else { + return Err(StoreError(format!("scrub_key: unknown key '{id}'"))); + }; + if key.deleted_at.is_none() { + return Err(StoreError(format!( + "scrub_key: '{id}' is not tombstoned — delete it first" + ))); + } + key.name.clear(); + key.labels.clear(); + key.revision = self.next_revision(); + Ok(()) + } + + fn list_keys_since(&self, since: u64) -> StoreResult> { + Ok(self + .keys_read() + .values() + .filter(|k| k.revision > since) + .cloned() + .collect()) + } + + fn get_usage(&self, bucket_id: &str, window_start: u64) -> StoreResult { + Ok(self + .usage_read() + .get(&(bucket_id.to_string(), window_start)) + .cloned() + .unwrap_or_default()) + } + + fn put_usage( + &self, + bucket_id: &str, + window_start: u64, + ledger: &UsageLedger, + ) -> StoreResult<()> { + // Write-behind ABSOLUTE set (memory is authoritative in the engine; this is durability only). + self.usage() + .insert((bucket_id.to_string(), window_start), ledger.clone()); + Ok(()) + } + + fn add_usage(&self, bucket_id: &str, window_start: u64, delta: &UsageDelta) -> StoreResult<()> { + // ADDITIVE accumulate under the write lock (atomic within this process), floored at 0. + let mut usage = self.usage(); + let u = usage + .entry((bucket_id.to_string(), window_start)) + .or_default(); + u.apply_delta(delta); + + // Amortized bounded eviction of stale windows, on the write-behind hot path + // (`flush_budgets` calls `add_usage` on every tick). `put_usage` (the absolute-set path) is + // deliberately NOT swept: `add_usage` is the common/hot path so sweeping it alone is + // sufficient to bound growth, and skipping `put_usage` keeps this change minimal. + let sweep_needed = self + .usage_sweep_ticker + .fetch_add(1, Ordering::Relaxed) + .wrapping_add(1) + .is_multiple_of(SWEEP_INTERVAL); + if sweep_needed { + let n = self.now(); + usage + .retain(|(_, window_start), _| window_start.saturating_add(MAX_RETENTION_SECS) > n); + } + Ok(()) + } + + fn add_metering(&self, d: &MeteringDelta) -> StoreResult<()> { + let mut m = self.metering(); + let e = m + // `priced_from_ms` is part of the key, not just the row: it is the `effective_from` + // of the rate-card entry in force when these counts were accrued, so a card edit inside + // a UTC day opens a SECOND row for that day and each half keeps the card it was earned + // under (ruling #79). Folding it into one row would leave a sum earned under two + // cards with only one card to be read against. + .entry(( + d.key_id.clone(), + d.bucket, + d.model.clone(), + d.provider.clone(), + d.priced_from_ms, + )) + .or_insert_with(|| MeteringRow { + key_id: d.key_id.clone(), + model: d.model.clone(), + provider: d.provider.clone(), + tokens_input: 0, + tokens_output: 0, + tokens_cache_read: 0, + tokens_cache_write: 0, + requests: 0, + billable_requests: 0, + key_group_at_use: d.key_group_at_use.clone(), + pricing_version: d.pricing_version.clone(), + priced_from_ms: d.priced_from_ms, + usage_units: std::collections::BTreeMap::new(), + }); + e.tokens_input = e.tokens_input.saturating_add(d.tokens_input); + e.tokens_output = e.tokens_output.saturating_add(d.tokens_output); + e.tokens_cache_read = e.tokens_cache_read.saturating_add(d.tokens_cache_read); + e.tokens_cache_write = e.tokens_cache_write.saturating_add(d.tokens_cache_write); + e.requests = e.requests.saturating_add(d.requests); + e.billable_requests = e.billable_requests.saturating_add(d.billable_requests); + // Every ledgered class the token columns do not hold, additive like them. + for (class, n) in &d.usage_units { + let cur = e.usage_units.entry(class.clone()).or_insert(0); + *cur = cur.saturating_add(*n); + } + + // Amortized bounded eviction of stale buckets, mirroring `add_usage` above. + let sweep_needed = self + .metering_sweep_ticker + .fetch_add(1, Ordering::Relaxed) + .wrapping_add(1) + .is_multiple_of(SWEEP_INTERVAL); + if sweep_needed { + let n = self.now(); + m.retain(|(_, bucket, _, _, _), _| bucket.saturating_add(MAX_RETENTION_SECS) > n); + } + Ok(()) + } + + fn list_metering(&self, bucket: u64) -> StoreResult> { + Ok(self + .metering_read() + .iter() + .filter(|((_, b, _, _, _), _)| *b == bucket) + .map(|(_, row)| row.clone()) + .collect()) + } + + fn put_credential(&self, secret: &CredentialSecret) -> StoreResult<()> { + // The owning key is read under the SAME critical section as the credential write, in + // `delete_key`'s fixed lock order (keys → creds) so the two can never deadlock against each + // other. Checking the key first and writing after would let a `delete_key` commit in the + // gap: its cascade removes the credentials that exist AT THAT MOMENT, so material written + // just behind it survives the tombstone and keeps resolving — the same read-then-write hole + // `put_key`'s own tombstone precondition closes, one door over. + let keys = self.keys_read(); + let mut creds = self.creds(); + let Some(owner) = keys.get(&secret.meta.key_id) else { + return Err(StoreError(format!( + "put_credential: key '{}' does not exist; a credential must hang off a real key", + secret.meta.key_id + ))); + }; + if owner.deleted_at.is_some() { + return Err(StoreError(format!( + "put_credential: key '{}' is tombstoned; its credentials were revoked with it and \ + are never reissued", + secret.meta.key_id + ))); + } + Self::credential_preconditions(&creds, secret)?; + let mut secret = secret.clone(); + secret.meta.revision = self.next_revision(); + creds.insert(secret.meta.id.clone(), secret); + + // Amortized bounded eviction of stale REVOKED credentials, mirroring `put_key`'s tombstone + // sweep above: `creds` had NO retention sweep at all before this (the only prior shrink path + // was `delete_key`'s cascade, which never fires for a credential rotated on a LIVE key), so a + // long-lived key's occupied-slot -> revoke -> re-put rotation cycle grew this map without + // bound. A row is a candidate ONLY once `revoked_at` is set — a LIVE (unrevoked) credential is + // NEVER pruned regardless of age, exactly like a live `VirtualKey` is never a `put_key` sweep + // candidate — so this can never evict a credential a live key is still presenting. Age is + // measured from `revoked_at` (when the credential stopped being usable), not `created_at`, so + // a credential that lived (unrevoked) for years isn't punished the instant it's rotated out — + // only ages PAST the 31-day ceiling once it's actually dead. + let sweep_needed = self + .creds_sweep_ticker + .fetch_add(1, Ordering::Relaxed) + .wrapping_add(1) + .is_multiple_of(SWEEP_INTERVAL); + if sweep_needed { + let n = self.now(); + creds.retain(|_, c| match c.meta.revoked_at { + None => true, // live credentials are never pruned + Some(revoked_at) => revoked_at.saturating_add(MAX_RETENTION_SECS) > n, + }); + } + Ok(()) + } + + fn put_key_with_credential( + &self, + key: &VirtualKey, + secret: &CredentialSecret, + ) -> StoreResult<()> { + // The trait calls this mint ATOMIC, and the DEFAULT it inherits is `put_key` followed by + // `put_credential` — two independent critical sections. When the credential leg fails (a + // reused `public_id` is the ordinary way it does), the key leg has already committed, so the + // caller is told the mint failed while a bearer key with no credential is left live in the + // table: a row nobody will ever clean up, and a `put_key` the operator never asked for. Both + // rows go in under ONE acquisition of the same two guards `delete_key` takes, in the same + // fixed order, with every precondition tested before anything is written. + let mut keys = self.keys(); + let mut creds = self.creds(); + if key.deleted_at.is_none() { + if let Some(existing) = keys.get(&key.id) { + if existing.deleted_at.is_some() { + return Err(StoreError(format!( + "put_key_with_credential: '{}' is tombstoned and its id is never reissued", + key.id + ))); + } + } + } + // The credential must name the key being minted alongside it — a mint that quietly hung its + // secret material off some OTHER key is not the operation the caller asked for, and the + // tombstone/existence check `put_credential` makes cannot apply to a key that does not exist + // until this call commits. + if secret.meta.key_id != key.id { + return Err(StoreError(format!( + "put_key_with_credential: the credential names key '{}', not the key '{}' being \ + minted with it", + secret.meta.key_id, key.id + ))); + } + Self::credential_preconditions(&creds, secret)?; + + let mut key = key.clone(); + key.revision = self.next_revision(); + keys.insert(key.id.clone(), key); + let mut secret = secret.clone(); + secret.meta.revision = self.next_revision(); + creds.insert(secret.meta.id.clone(), secret); + Ok(()) + } + + fn list_credentials(&self, key_id: &str) -> StoreResult> { + Ok(self + .creds_read() + .values() + .filter(|c| c.meta.key_id == key_id) + .map(|c| c.meta.clone()) + .collect()) + } + + fn lookup_credential_secret( + &self, + kind: &str, + public_id: &str, + ) -> StoreResult> { + Ok(self + .creds_read() + .values() + .find(|c| c.meta.kind == kind && c.meta.public_id == public_id) + .cloned()) + } + + fn revoke_credential(&self, id: &str, reason: &str) -> StoreResult<()> { + let mut creds = self.creds(); + let Some(c) = creds.get_mut(id) else { + return Err(StoreError(format!("revoke_credential: unknown id '{id}'"))); + }; + if c.meta.revoked_at.is_none() { + // `self.now()` (pinned-clock-aware), not the bare free function — the `creds` sweep + // added for the retention fix ages rows off `revoked_at` against `self.now()`, so + // stamping it from any other clock would desync the sweep's boundary from what a test + // (or, in prod, a paused/adjusted clock) actually pinned. Matches `delete_key`'s + // `deleted_at = Some(self.now())` for the same reason. + c.meta.revoked_at = Some(self.now()); + c.meta.revoke_reason = Some(reason.to_string()); + c.meta.revision = self.next_revision(); + } // idempotent: already revoked + Ok(()) + } + + fn list_credentials_since(&self, since: u64) -> StoreResult> { + Ok(self + .creds_read() + .values() + .filter(|c| c.meta.revision > since) + .cloned() + .collect()) + } + + fn add_denylist(&self, sub: &str, _reason: &str) -> StoreResult<()> { + self.denylist + .write() + .unwrap_or_else(|e| e.into_inner()) + .insert(sub.to_string()); + Ok(()) + } + + fn list_denylist(&self) -> StoreResult> { + Ok(self + .denylist + .read() + .unwrap_or_else(|e| e.into_inner()) + .iter() + .cloned() + .collect()) + } + + fn append_audit(&self, entry: &AuditRecord) -> StoreResult<()> { + // Append-only, never rewriting: a second record on an occupied `seq` is EITHER the + // write-through retrying (byte-identical → Ok, the common case) or a forked/tampered chain + // (different → error). Collapsing those two is the one thing an audit store must not do. + let mut audit = self.audit.write().unwrap_or_else(|e| e.into_inner()); + match audit.get(&entry.seq) { + Some(stored) if stored == entry => Ok(()), + Some(_) => Err(StoreError(format!( + "append_audit: seq {} already holds a DIFFERENT record — the audit chain has forked", + entry.seq + ))), + None => { + audit.insert(entry.seq, entry.clone()); + Ok(()) + } + } + } + + fn list_audit(&self) -> StoreResult> { + Ok(self + .audit + .read() + .unwrap_or_else(|e| e.into_inner()) + .values() + .cloned() + .collect()) + } + + fn upsert_plane_record(&self, record: PlaneRecordRef<'_>) -> StoreResult<()> { + self.plane_records() + .insert(Self::plane_key(record), record.to_record()); + Ok(()) + } + + fn get_plane_record(&self, kind: &str, id: &str) -> StoreResult>> { + Ok(self + .plane_records_read() + .get(&(kind.to_string(), id.to_string(), 0)) + .map(|r| r.body.clone())) + } + + fn append_plane_record(&self, record: PlaneRecordRef<'_>) -> StoreResult<()> { + // Keyed by `(parent, seq)`. APPEND-ONLY, never a blind overwrite — mirrors `append_audit`'s + // own fork detection above so the two append-only paths can never disagree about what a + // fork is: a second record at an already-occupied position is EITHER the write-through + // retrying after a timeout (byte-identical → Ok, the common case) or a SECOND WRITER forking + // the chain (different → refused, never silently applied). A blind upsert here would let two + // busbar processes pointed at one durable store silently overwrite each other's + // `task_event`/`call`/`audit` rows — exactly the defect `append_audit`'s own check exists to + // catch, restored on this newer seam. + let mut records = self.plane_records(); + let key = Self::plane_key(record); + match records.get(&key) { + Some(existing) if existing.view() == record => Ok(()), + Some(_) => Err(StoreError(format!( + "append_plane_record: kind '{}' parent '{}' seq {} already holds a DIFFERENT \ + record — the chain has forked", + record.kind, + record.parent.unwrap_or(record.id), + record.seq + ))), + None => { + records.insert(key, record.to_record()); + Ok(()) + } + } + } + + fn list_plane_records( + &self, + kind: &str, + selector: &PlaneSelector<'_>, + ) -> StoreResult>> { + let records = self.plane_records_read(); + let mut rows: Vec<(u64, Vec)> = records + .iter() + .filter(|((k, _, _), r)| { + k == kind + && match selector { + PlaneSelector::All => true, + PlaneSelector::Parent(p) => r.parent.as_deref() == Some(&**p), + } + }) + .map(|(_, r)| (r.seq, r.body.clone())) + .collect(); + // Oldest-first by `seq` — the order the engine's chain verifier reads a parent's events in. + rows.sort_by_key(|(seq, _)| *seq); + Ok(rows.into_iter().map(|(_, body)| body).collect()) + } + + fn list_plane_record_parents(&self, kind: &str) -> StoreResult> { + let records = self.plane_records_read(); + let mut parents: Vec = records + .iter() + .filter(|((k, _, _), _)| k == kind) + .filter_map(|(_, r)| r.parent.clone()) + .collect(); + parents.sort(); + parents.dedup(); + Ok(parents) + } + + fn purge_plane_records_before(&self, kind: &str, before: u64) -> StoreResult { + // WHICH rows go is the kind's contract: `task` drops only TERMINAL rows (an interrupted task + // waiting on a human is exactly the row that sits still longest, and dropping it loses the + // work), every other kind drops any row older than `before`. + let mut records = self.plane_records(); + let before_len = records.len(); + records.retain(|(k, _, _), r| { + if k != kind || r.ts >= before { + return true; + } + k == "task" && r.disposition != PlaneDisposition::Terminal + }); + Ok((before_len - records.len()) as u64) + } + + fn delete_plane_record(&self, kind: &str, id: &str) -> StoreResult<()> { + // Absent is a no-op, per the trait. Every `seq` under the identity goes, so deleting a + // parent's record cannot leave part of a chain behind. + self.plane_records() + .retain(|(k, i, _), _| !(k == kind && i == id)); + Ok(()) + } + + fn redeem_plane_token( + &self, + kind: &str, + token: &str, + expires_at: u64, + now: u64, + ) -> StoreResult { + // TEST-AND-SET under ONE guard. The trait's default is `Ok(true)` — "this store keeps no + // ledger" — which on the DEFAULT backend makes every single-use approval token replayable: + // a confirm-once tool re-executes for anyone who replays the nonce, and the store reports + // each replay as the first redemption. Governance's out-of-the-box posture cannot be that. + let mut spent = self.plane_tokens.write().unwrap_or_else(|e| e.into_inner()); + // Lapsed rows go in the same call: a token past its own expiry can never be presented + // again, so retaining it only grows the map. + spent.retain(|_, exp| *exp > now); + let first = spent + .insert((kind.to_string(), token.to_string()), expires_at) + .is_none(); + Ok(first) + } + + fn plane_token_live( + &self, + kind: &str, + token: &str, + expires_at: u64, + now: u64, + ) -> StoreResult { + // MULTI-use and SPENDS NOTHING — the opposite of `redeem_plane_token`'s single-use + // test-and-set. This is a plain READ of the `(kind, token)` upsert record (an upsert kind + // lives at `seq` 0, exactly where `get_plane_record` point-reads it), live only while that + // record is present, its disposition is still `Active` (non-terminal), and `now` has not + // reached `expires_at`. Nothing is inserted, removed or mutated, so asking twice answers the + // same twice — the whole point of the verb. + // + // The trait default is `Ok(false)` precisely so a backend that keeps no records refuses these + // callbacks fail-closed. This backend DOES keep them, in the same `plane_records` map the + // upsert leg wrote to, so it answers from there — and it stays fail-closed for the same three + // reasons: an unknown `(kind, token)`, a terminal disposition, and a lapsed deadline each + // yield `Ok(false)`. + Ok(self + .plane_records_read() + .get(&(kind.to_string(), token.to_string(), 0)) + .is_some_and(|r| r.disposition == PlaneDisposition::Active && now < expires_at)) + } +} + +/// The double's own Statement name and canonical name: not the plugin's (C1). +pub const NAME: &str = "store-double"; + +/// THE STAND-IN LINKED ENTRY: `(key, ephemeral, door, canonical)`, the shape of the root's linked +/// store rows ([`crate::preflight::LinkedStore`]). Its key is the store a migrated 1.5.x config names +/// ([`MIGRATED_STORE_MODULE`]), so a fixture config's `store:` block resolves to it; it claims no +/// default (Q-STORE = (B)). +pub const STORE: crate::preflight::LinkedStore = (MIGRATED_STORE_MODULE, true, door, NAME); + +/// THE STORE DOOR (store v3, `busbar_contract::abi::store`): every slot of the store v3 table over +/// [`MemoryStore`], through the contract's store SDK (`abi::sdk::store`) — what [`STORE`] registers, +/// and what boot opens through the stand-in store axis. +pub use v3::door; + +mod v3; diff --git a/crates/busbar-kernel/src/test_support/store_double/v3.rs b/crates/busbar-kernel/src/test_support/store_double/v3.rs new file mode 100644 index 0000000000..c3b42e8e36 --- /dev/null +++ b/crates/busbar-kernel/src/test_support/store_double/v3.rs @@ -0,0 +1,933 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! The store v3 slots this backend answers beyond the 1.5.5 op set ([`StoreSlots`]): the +//! `op_id`-carrying writes, the ledger ops, the money slots and `window_caps`. +//! +//! DEDUPE (`abi::store` S1-S4): every `op_id` this store APPLIED is remembered, with the op's value +//! fields and its answer, for [`OP_ID_RETENTION_SECS`]. A replay with the same value fields applies +//! nothing and answers the original; different value fields are a conflict. The dedupe log, the +//! caps, the drawn totals and the slices live under ONE lock, and an op's dedupe check, its effect +//! and its record happen under it, so two racing calls with one `op_id` apply once. This store is +//! EPHEMERAL (its Statement tail says so): the log is lost on restart together with everything it +//! guards, so a replay after a restart re-applies onto an empty store, which is the state the +//! restart left. +//! +//! EPOCH AND SLICE LIFE (`abi::store::SLICE_TTL_MS`, the store kind's spec): this is a NODE-LOCAL +//! store, and its rule holds for a SINGLE node only. It holds one constant epoch, never answers a +//! stale one (the `epoch` a caller states is accepted as given) and grants slices that never expire +//! (`valid_until_ms = u64::MAX`): no other node can draw against its windows. A store a fleet shares +//! persists and fences the epoch and bounds every slice. A release, here as everywhere, returns at +//! most what the slice has left, and a slice with nothing left returns `0`. +//! +//! GRANT SIZE: a cell grants its whole `amount` or the reserve fails; the per-dimension test +//! is 1.5.5's, cited on `abi::store::ReserveIn`. + +use std::collections::{HashMap, VecDeque}; +use std::sync::{Mutex, MutexGuard}; + +use busbar_contract::abi::sdk::conn::Host; +use busbar_contract::abi::sdk::store::{ + Cap, CapsRefused, Cell, CellKey, Dimension, Grant, Op, OpRefused, OpResult, ReserveRefused, + Scanned, Step, StoreSlots, Tail, +}; +use busbar_contract::abi::store::{OpId, OP_ID_RETENTION_SECS}; +use busbar_contract::kinds::{Head, RecordBytes}; +use busbar_contract::records::{ + AuditRecord, CredentialMeta, CredentialSecret, MeteringDelta, MeteringRow, PlaneRecordRef, + PlaneSelector, RecordStore, RecordStoreResult, UsageDelta, UsageLedger, VirtualKey, +}; + +use super::{MemoryStore, NAME}; + +busbar_contract::store_door!(MemoryStore, NAME, env!("CARGO_PKG_VERSION"), 1024); + +/// A slot key owned by the store: `(bucket, pool, dimension, class_key, window_start)`. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct Slot { + bucket: String, + pool: Option, + dimension: u32, + class_key: String, + window_start: u64, +} + +impl Slot { + fn of(k: &CellKey<'_>) -> Self { + let (dimension, class_key) = match k.dimension { + Dimension::NanoUnits => (0, ""), + Dimension::Requests => (1, ""), + Dimension::Concurrency => (2, ""), + Dimension::Class(c) => (3, c), + }; + Self { + bucket: k.bucket.to_string(), + pool: k.pool.map(str::to_string), + dimension, + class_key: class_key.to_string(), + window_start: k.window_start, + } + } +} + +/// What an applied op answered, replayed verbatim. +#[derive(Debug, Clone)] +enum Answer { + Done, + Head(Head), + Grants(Vec), + Released(Vec), +} + +/// One remembered op: its value fields and its answer. +struct Recorded { + body: String, + answer: Answer, +} + +/// A drawn slice: the slot it drew from and what it still holds. +struct Drawn { + slot: Slot, + left: u64, +} + +#[derive(Default)] +struct Inner { + ops: HashMap, + /// `(recorded_at, op_id)`, oldest first: the retention sweep's order. + aged: VecDeque<(u64, OpId)>, + /// `slot -> (cap, config_gen)`. + caps: HashMap, + /// `slot -> drawn and not released`. + used: HashMap, + slices: HashMap, + /// The slices whose unreturned unspent reached `0`, with when: a subsequent release of one + /// returns `0`. Kept as long as an `op_id` is (`OP_ID_RETENTION_SECS`), then forgotten. + closed: HashMap, + next_slice: u64, + /// `stream -> its records`; the head's `seq` is the count. + journals: HashMap>, + /// `session -> (node, principal)`. + sessions: HashMap, +} + +/// The v3 state [`MemoryStore`] carries. +#[derive(Default)] +pub(crate) struct State { + inner: Mutex, +} + +/// Whether an op is new, a replay, or a conflict. +enum Seen { + New, + Replay(Answer), + Conflict, +} + +impl Inner { + fn seen(&self, op: OpId, body: &str) -> Seen { + match self.ops.get(&op) { + None => Seen::New, + Some(r) if r.body == body => Seen::Replay(r.answer.clone()), + Some(_) => Seen::Conflict, + } + } + + /// Remember an APPLIED op, and forget every op past its retention. + fn record(&mut self, now: u64, op: OpId, body: String, answer: Answer) { + while let Some(&(at, old)) = self.aged.front() { + if at.saturating_add(OP_ID_RETENTION_SECS) > now { + break; + } + self.aged.pop_front(); + self.ops.remove(&old); + } + self.ops.insert(op, Recorded { body, answer }); + self.aged.push_back((now, op)); + } +} + +impl State { + fn lock(&self) -> MutexGuard<'_, Inner> { + self.inner.lock().unwrap_or_else(|p| p.into_inner()) + } +} + +/// The 1.5.5 admission test for one cell (`abi::store::ReserveIn`, GRANT SIZE): whether drawing +/// `amount` onto `used` under `cap` is refused. `used + amount` is checked: an overflow refuses. +fn exhausted(dimension: u32, used: u64, amount: u64, cap: u64) -> bool { + let Some(after) = used.checked_add(amount) else { + return true; + }; + match dimension { + // DIM_CLASS: `tokens >= cap` — the draw that crosses the cap is granted whole. + 3 => used >= cap, + // DIM_NANO_UNITS: `derived >= cap || derived + fee > cap`. + 0 => used >= cap || after > cap, + // DIM_REQUESTS / DIM_CONCURRENCY: `used + amount > cap`. + _ => after > cap, + } +} + +fn failed(e: busbar_contract::records::RecordStoreError) -> OpRefused { + OpRefused::Failed(e.0) +} + +impl MemoryStore { + /// Run one `op_id`-carrying write under the v3 lock: a replay answers the original, a + /// conflict applies nothing, and a new op runs `apply` and is remembered only if it applied. + fn deduped( + &self, + op: OpId, + body: String, + apply: impl FnOnce(&Self, &mut Inner) -> OpResult, + ) -> OpResult { + let mut inner = self.v3.lock(); + match inner.seen(op, &body) { + Seen::Replay(a) => Ok(a), + Seen::Conflict => Err(OpRefused::Conflict), + Seen::New => { + let answer = apply(self, &mut inner)?; + inner.record(self.now(), op, body, answer.clone()); + Ok(answer) + } + } + } + + /// Every audit record in `entries` would append (none forks a stored `seq`, nor another in the + /// batch), checked before any is applied. + fn audit_fits(&self, entries: &[AuditRecord]) -> OpResult<()> { + let audit = self.audit.read().unwrap_or_else(|e| e.into_inner()); + let mut batch: HashMap = HashMap::new(); + for e in entries { + let prior = audit.get(&e.seq).or_else(|| batch.get(&e.seq).copied()); + if prior.is_some_and(|p| p != e) { + return Err(OpRefused::Failed(format!( + "append_audit: seq {} already holds a DIFFERENT record — the audit chain has forked", + e.seq + ))); + } + batch.insert(e.seq, e); + } + Ok(()) + } +} + +/// The store v3 slots' own bodies: each op's ONE body, which the table's slot answers inline (the +/// memory store never pends). +impl MemoryStore { + pub(crate) fn v3_add_usage_op( + &self, + op: OpId, + bucket: &str, + window_start: u64, + delta: &UsageDelta, + ) -> OpResult<()> { + let body = format!("add_usage:{bucket:?}:{window_start}:{delta:?}"); + self.deduped(op, body, |s, _| { + s.add_usage(bucket, window_start, delta).map_err(failed)?; + Ok(Answer::Done) + }) + .map(drop) + } + + pub(crate) fn v3_add_metering_op(&self, op: OpId, delta: &MeteringDelta) -> OpResult<()> { + let body = format!("add_metering:{delta:?}"); + self.deduped(op, body, |s, _| { + s.add_metering(delta).map_err(failed)?; + Ok(Answer::Done) + }) + .map(drop) + } + + pub(crate) fn v3_append_audit_op(&self, op: OpId, entry: &AuditRecord) -> OpResult<()> { + let body = format!("append_audit:{entry:?}"); + self.deduped(op, body, |s, _| { + s.append_audit(entry).map_err(failed)?; + Ok(Answer::Done) + }) + .map(drop) + } + + pub(crate) fn v3_append_plane_record_op( + &self, + op: OpId, + record: PlaneRecordRef<'_>, + ) -> OpResult<()> { + let body = format!("append_plane_record:{:?}", record.to_record()); + self.deduped(op, body, |s, _| { + s.append_plane_record(record).map_err(failed)?; + Ok(Answer::Done) + }) + .map(drop) + } + + pub(crate) fn v3_append_batch( + &self, + op: OpId, + stream: &str, + records: &[RecordBytes], + ) -> OpResult { + let body = format!("append_batch:{stream:?}:{records:?}"); + let answer = self.deduped(op, body, |_, inner| { + let rows = inner.journals.entry(stream.to_string()).or_default(); + rows.extend(records.iter().cloned()); + Ok(Answer::Head(Head { + seq: rows.len() as u64, + epoch: 0, + })) + })?; + match answer { + Answer::Head(h) => Ok(h), + _ => Err(OpRefused::Conflict), + } + } + + pub(crate) fn v3_heads(&self) -> Result, String> { + let inner = self.v3.lock(); + let mut heads: Vec<(String, Head)> = inner + .journals + .iter() + .map(|(s, rows)| { + ( + s.clone(), + Head { + seq: rows.len() as u64, + epoch: 0, + }, + ) + }) + .collect(); + heads.sort_by(|a, b| a.0.cmp(&b.0)); + Ok(heads) + } + + pub(crate) fn v3_session_put( + &self, + session: u64, + node: &str, + principal: &str, + ) -> Result<(), String> { + self.v3 + .lock() + .sessions + .insert(session, (node.to_string(), principal.to_string())); + Ok(()) + } + + pub(crate) fn v3_session_remove(&self, session: u64) -> Result<(), String> { + self.v3.lock().sessions.remove(&session); + Ok(()) + } + + pub(crate) fn v3_sessions_for(&self, principal: &str) -> Result, String> { + let inner = self.v3.lock(); + let mut rows: Vec<(u64, String)> = inner + .sessions + .iter() + .filter(|(_, (_, p))| p == principal) + .map(|(s, (n, _))| (*s, n.clone())) + .collect(); + rows.sort_unstable(); + Ok(rows) + } + + pub(crate) fn v3_record_put( + &self, + schema: &str, + key: &[u8], + value: &[u8], + ) -> Result<(), String> { + // The store keeps the record, so it copies it here (the door lends the host's bytes). + let value = RecordBytes::new(value.to_vec()) + .map_err(|n| format!("a record of {n} bytes is over the ceiling"))?; + self.record_put_at(schema, key, &value); + Ok(()) + } + + pub(crate) fn v3_record_get( + &self, + schema: &str, + key: &[u8], + ) -> Result, String> { + Ok(self.record_get_at(schema, key)) + } + + pub(crate) fn v3_record_scan( + &self, + schema: &str, + prefix: &[u8], + limit: u32, + ) -> Result, RecordBytes)>, String> { + Ok(self.record_scan_at(schema, prefix, limit)) + } + + pub(crate) fn v3_reserve<'c>( + &self, + op: OpId, + epoch: u64, + cells: impl Iterator> + Clone, + grants: &mut impl Extend, + ) -> Result<(), ReserveRefused> { + let cells: Vec> = cells.collect(); + let body = format!("reserve:{epoch}:{cells:?}"); + let mut inner = self.v3.lock(); + match inner.seen(op, &body) { + Seen::Replay(Answer::Grants(g)) => { + grants.extend(g); + return Ok(()); + } + Seen::Replay(_) | Seen::Conflict => return Err(ReserveRefused::Conflict), + Seen::New => {} + } + // The chain draw is all or nothing: test every cell against what the cells before it in + // THIS draw add, and apply only when every cell passes. + let mut drawn: HashMap = HashMap::new(); + let mut slots = Vec::with_capacity(cells.len()); + for (i, c) in cells.iter().enumerate() { + let slot = Slot::of(&c.key); + let Some(&(cap, _)) = inner.caps.get(&slot) else { + return Err(ReserveRefused::NoCap { cell: i as u32 }); + }; + let used = inner.used.get(&slot).copied().unwrap_or(0); + let used = used.saturating_add(drawn.get(&slot).copied().unwrap_or(0)); + if exhausted(slot.dimension, used, c.amount, cap) { + return Err(ReserveRefused::Exhausted { cell: i as u32 }); + } + *drawn.entry(slot.clone()).or_default() += c.amount; + slots.push(slot); + } + let mut granted = Vec::with_capacity(cells.len()); + for (c, slot) in cells.iter().zip(slots) { + inner.next_slice += 1; + let slice_id = inner.next_slice; + let used = inner.used.entry(slot.clone()).or_default(); + *used = used.saturating_add(c.amount); + inner.slices.insert( + slice_id, + Drawn { + slot, + left: c.amount, + }, + ); + granted.push(Grant { + slice_id, + granted: c.amount, + // Single node: nothing expires a slice no other node can draw against. + valid_until_ms: u64::MAX, + }); + } + grants.extend(granted.iter().copied()); + inner.record(self.now(), op, body, Answer::Grants(granted)); + Ok(()) + } + + pub(crate) fn v3_slice_release( + &self, + op: OpId, + epoch: u64, + items: impl Iterator + Clone, + released: &mut impl Extend, + ) -> OpResult<()> { + let items: Vec<(u64, u64)> = items.collect(); + let body = format!("slice_release:{epoch}:{items:?}"); + let now = self.now(); + let answer = self.deduped(op, body, |_, inner| { + if inner.closed.len() > 1024 { + inner + .closed + .retain(|_, at| at.saturating_add(OP_ID_RETENTION_SECS) > now); + } + let known = |id: &u64| inner.slices.contains_key(id) || inner.closed.contains_key(id); + if let Some((id, _)) = items.iter().find(|(id, _)| !known(id)) { + return Err(OpRefused::Failed(format!( + "slice_release: slice {id} was never granted" + ))); + } + let mut back_all = Vec::with_capacity(items.len()); + for &(id, unspent) in &items { + // A slice closed once its unreturned unspent reached 0 (by an earlier release, or an + // earlier item of this call) returns nothing more. + let Some(d) = inner.slices.get_mut(&id) else { + back_all.push(0); + continue; + }; + let back = unspent.min(d.left); + d.left -= back; + let slot = d.slot.clone(); + if d.left == 0 { + inner.slices.remove(&id); + inner.closed.insert(id, now); + } + if let Some(u) = inner.used.get_mut(&slot) { + *u = u.saturating_sub(back); + } + back_all.push(back); + } + Ok(Answer::Released(back_all)) + })?; + match answer { + Answer::Released(r) => { + released.extend(r); + Ok(()) + } + _ => Err(OpRefused::Conflict), + } + } + + pub(crate) fn v3_add_usage_batch( + &self, + op: OpId, + cells: &[(&str, u64, UsageDelta)], + ) -> OpResult<()> { + let body = format!("add_usage_batch:{cells:?}"); + self.deduped(op, body, |s, _| { + // A RAM ledger cannot fail an add, so applying in order is atomic. + for (bucket, window, delta) in cells { + s.add_usage(bucket, *window, delta).map_err(failed)?; + } + Ok(Answer::Done) + }) + .map(drop) + } + + pub(crate) fn v3_add_metering_batch(&self, op: OpId, deltas: &[MeteringDelta]) -> OpResult<()> { + let body = format!("add_metering_batch:{deltas:?}"); + self.deduped(op, body, |s, _| { + for d in deltas { + s.add_metering(d).map_err(failed)?; + } + Ok(Answer::Done) + }) + .map(drop) + } + + pub(crate) fn v3_append_audit_batch(&self, op: OpId, entries: &[AuditRecord]) -> OpResult<()> { + let body = format!("append_audit_batch:{entries:?}"); + self.deduped(op, body, |s, _| { + s.audit_fits(entries)?; + for e in entries { + s.append_audit(e).map_err(failed)?; + } + Ok(Answer::Done) + }) + .map(drop) + } + + pub(crate) fn v3_window_caps(&self, op: OpId, caps: &[Cap<'_>]) -> Result<(), CapsRefused> { + let body = format!("window_caps:{caps:?}"); + let mut inner = self.v3.lock(); + match inner.seen(op, &body) { + Seen::Replay(_) => return Ok(()), + Seen::Conflict => return Err(CapsRefused::Conflict), + Seen::New => {} + } + // Atomic per push: find the first conflict before applying any cap. + let mut pushed: HashMap = HashMap::new(); + for (index, c) in caps.iter().enumerate() { + let slot = Slot::of(&c.key); + let stored = pushed.get(&slot).or_else(|| inner.caps.get(&slot)).copied(); + match stored { + Some((cap, gen)) if gen == c.config_gen && cap != c.cap => { + return Err(CapsRefused::CapConflict { index }); + } + Some((_, gen)) if gen >= c.config_gen => {} + _ => { + pushed.insert(slot, (c.cap, c.config_gen)); + } + } + } + inner.caps.extend(pushed); + inner.record(self.now(), op, body, Answer::Done); + Ok(()) + } +} + +impl StoreSlots for MemoryStore { + const TAIL: Tail = Tail { + ephemeral: true, + durable_plane: false, + fork_refusal: true, + }; + + fn validate(_settings: &[u8]) -> Result<(), String> { + Ok(()) + } + + fn open(_settings: &[u8], _host: Option) -> Result { + Ok(Self::new()) + } + + fn add_usage_op( + &self, + _: &mut Op<'_>, + op: OpId, + bucket: &str, + window_start: u64, + delta: &UsageDelta, + ) -> Step> { + Step::Ready(self.v3_add_usage_op(op, bucket, window_start, delta)) + } + + fn add_metering_op( + &self, + _: &mut Op<'_>, + op: OpId, + delta: &MeteringDelta, + ) -> Step> { + Step::Ready(self.v3_add_metering_op(op, delta)) + } + + fn append_audit_op(&self, _: &mut Op<'_>, op: OpId, entry: &AuditRecord) -> Step> { + Step::Ready(self.v3_append_audit_op(op, entry)) + } + + fn append_plane_record_op( + &self, + _: &mut Op<'_>, + op: OpId, + record: PlaneRecordRef<'_>, + ) -> Step> { + Step::Ready(self.v3_append_plane_record_op(op, record)) + } + + fn append_batch( + &self, + _: &mut Op<'_>, + op: OpId, + stream: &str, + records: &[RecordBytes], + ) -> Step> { + Step::Ready(self.v3_append_batch(op, stream, records)) + } + + fn heads(&self, _: &mut Op<'_>) -> Step, String>> { + Step::Ready(self.v3_heads()) + } + + fn session_put( + &self, + _: &mut Op<'_>, + session: u64, + node: &str, + principal: &str, + ) -> Step> { + Step::Ready(self.v3_session_put(session, node, principal)) + } + + fn session_remove(&self, _: &mut Op<'_>, session: u64) -> Step> { + Step::Ready(self.v3_session_remove(session)) + } + + fn sessions_for( + &self, + _: &mut Op<'_>, + principal: &str, + ) -> Step, String>> { + Step::Ready(self.v3_sessions_for(principal)) + } + + fn record_put( + &self, + _: &mut Op<'_>, + schema: &str, + key: &[u8], + value: &[u8], + ) -> Step> { + Step::Ready(self.v3_record_put(schema, key, value)) + } + + fn record_get( + &self, + _: &mut Op<'_>, + schema: &str, + key: &[u8], + ) -> Step, String>> { + Step::Ready(self.v3_record_get(schema, key)) + } + + fn record_scan( + &self, + _: &mut Op<'_>, + schema: &str, + prefix: &[u8], + limit: u32, + ) -> Step> { + Step::Ready(self.v3_record_scan(schema, prefix, limit)) + } + + fn reserve<'c>( + &self, + _: &mut Op<'_>, + op: OpId, + epoch: u64, + cells: impl Iterator> + Clone, + grants: &mut impl Extend, + ) -> Step> { + Step::Ready(self.v3_reserve(op, epoch, cells, grants)) + } + + fn slice_release( + &self, + _: &mut Op<'_>, + op: OpId, + epoch: u64, + items: impl Iterator + Clone, + released: &mut impl Extend, + ) -> Step> { + Step::Ready(self.v3_slice_release(op, epoch, items, released)) + } + + fn add_usage_batch( + &self, + _: &mut Op<'_>, + op: OpId, + cells: &[(&str, u64, UsageDelta)], + ) -> Step> { + Step::Ready(self.v3_add_usage_batch(op, cells)) + } + + fn add_metering_batch( + &self, + _: &mut Op<'_>, + op: OpId, + deltas: &[MeteringDelta], + ) -> Step> { + Step::Ready(self.v3_add_metering_batch(op, deltas)) + } + + fn append_audit_batch( + &self, + _: &mut Op<'_>, + op: OpId, + entries: &[AuditRecord], + ) -> Step> { + Step::Ready(self.v3_append_audit_batch(op, entries)) + } + + fn window_caps( + &self, + _: &mut Op<'_>, + op: OpId, + caps: &[Cap<'_>], + ) -> Step> { + Step::Ready(self.v3_window_caps(op, caps)) + } + + fn put_key(&self, _: &mut Op<'_>, key: &VirtualKey) -> Step> { + Step::Ready(RecordStore::put_key(self, key)) + } + + fn get_key(&self, _: &mut Op<'_>, id: &str) -> Step>> { + Step::Ready(RecordStore::get_key(self, id)) + } + + fn list_keys(&self, _: &mut Op<'_>) -> Step>> { + Step::Ready(RecordStore::list_keys(self)) + } + + fn delete_key(&self, _: &mut Op<'_>, id: &str) -> Step> { + Step::Ready(RecordStore::delete_key(self, id)) + } + + fn scrub_key(&self, _: &mut Op<'_>, id: &str) -> Step> { + Step::Ready(RecordStore::scrub_key(self, id)) + } + + fn list_keys_since( + &self, + _: &mut Op<'_>, + since: u64, + ) -> Step>> { + Step::Ready(RecordStore::list_keys_since(self, since)) + } + + fn get_usage( + &self, + _: &mut Op<'_>, + bucket_id: &str, + window_start: u64, + ) -> Step> { + Step::Ready(RecordStore::get_usage(self, bucket_id, window_start)) + } + + fn put_usage( + &self, + _: &mut Op<'_>, + bucket_id: &str, + window_start: u64, + ledger: &UsageLedger, + ) -> Step> { + Step::Ready(RecordStore::put_usage( + self, + bucket_id, + window_start, + ledger, + )) + } + + fn list_metering( + &self, + _: &mut Op<'_>, + bucket: u64, + ) -> Step>> { + Step::Ready(RecordStore::list_metering(self, bucket)) + } + + fn purge_windows_before(&self, _: &mut Op<'_>, before: u64) -> Step> { + Step::Ready(RecordStore::purge_windows_before(self, before)) + } + + fn purge_metering_before(&self, _: &mut Op<'_>, bucket: &str) -> Step> { + Step::Ready(RecordStore::purge_metering_before(self, bucket)) + } + + fn put_credential( + &self, + _: &mut Op<'_>, + secret: &CredentialSecret, + ) -> Step> { + Step::Ready(RecordStore::put_credential(self, secret)) + } + + fn put_key_with_credential( + &self, + _: &mut Op<'_>, + key: &VirtualKey, + secret: &CredentialSecret, + ) -> Step> { + Step::Ready(RecordStore::put_key_with_credential(self, key, secret)) + } + + fn list_credentials( + &self, + _: &mut Op<'_>, + key_id: &str, + ) -> Step>> { + Step::Ready(RecordStore::list_credentials(self, key_id)) + } + + fn lookup_credential_secret( + &self, + _: &mut Op<'_>, + kind: &str, + public_id: &str, + ) -> Step>> { + Step::Ready(RecordStore::lookup_credential_secret(self, kind, public_id)) + } + + fn revoke_credential( + &self, + _: &mut Op<'_>, + id: &str, + reason: &str, + ) -> Step> { + Step::Ready(RecordStore::revoke_credential(self, id, reason)) + } + + fn list_credentials_since( + &self, + _: &mut Op<'_>, + since: u64, + ) -> Step>> { + Step::Ready(RecordStore::list_credentials_since(self, since)) + } + + fn list_audit(&self, _: &mut Op<'_>) -> Step>> { + Step::Ready(RecordStore::list_audit(self)) + } + + fn add_denylist(&self, _: &mut Op<'_>, sub: &str, reason: &str) -> Step> { + Step::Ready(RecordStore::add_denylist(self, sub, reason)) + } + + fn list_denylist(&self, _: &mut Op<'_>) -> Step>> { + Step::Ready(RecordStore::list_denylist(self)) + } + + fn list_audit_tail( + &self, + _: &mut Op<'_>, + limit: u64, + ) -> Step>> { + Step::Ready(RecordStore::list_audit_tail(self, limit)) + } + + fn upsert_plane_record( + &self, + _: &mut Op<'_>, + record: PlaneRecordRef<'_>, + ) -> Step> { + Step::Ready(RecordStore::upsert_plane_record(self, record)) + } + + fn get_plane_record( + &self, + _: &mut Op<'_>, + kind: &str, + id: &str, + ) -> Step>>> { + Step::Ready(RecordStore::get_plane_record(self, kind, id)) + } + + fn list_plane_records( + &self, + _: &mut Op<'_>, + kind: &str, + selector: &PlaneSelector<'_>, + ) -> Step>>> { + Step::Ready(RecordStore::list_plane_records(self, kind, selector)) + } + + fn list_plane_record_parents( + &self, + _: &mut Op<'_>, + kind: &str, + ) -> Step>> { + Step::Ready(RecordStore::list_plane_record_parents(self, kind)) + } + + fn purge_plane_records_before( + &self, + _: &mut Op<'_>, + kind: &str, + before: u64, + ) -> Step> { + Step::Ready(RecordStore::purge_plane_records_before(self, kind, before)) + } + + fn delete_plane_record( + &self, + _: &mut Op<'_>, + kind: &str, + id: &str, + ) -> Step> { + Step::Ready(RecordStore::delete_plane_record(self, kind, id)) + } + + fn redeem_plane_token( + &self, + _: &mut Op<'_>, + kind: &str, + token: &str, + expires_at: u64, + now: u64, + ) -> Step> { + Step::Ready(RecordStore::redeem_plane_token( + self, kind, token, expires_at, now, + )) + } + + fn plane_token_live( + &self, + _: &mut Op<'_>, + kind: &str, + token: &str, + expires_at: u64, + now: u64, + ) -> Step> { + Step::Ready(RecordStore::plane_token_live( + self, kind, token, expires_at, now, + )) + } +} diff --git a/crates/busbar-kernel/src/tests/door_op_id_tests.rs b/crates/busbar-kernel/src/tests/door_op_id_tests.rs index 0c03490291..357abad6f3 100644 --- a/crates/busbar-kernel/src/tests/door_op_id_tests.rs +++ b/crates/busbar-kernel/src/tests/door_op_id_tests.rs @@ -5,9 +5,9 @@ //! new boot is never answered as the replay of an earlier boot's write. use super::*; +use crate::test_support::store_double::MemoryStore; use busbar_contract::abi::sdk::store::{Op, Step, StoreSlots}; use busbar_contract::records::{RecordStore, UsageDelta}; -use fixture_store::MemoryStore; fn cells() -> [(&'static str, u64, UsageDelta); 1] { [( diff --git a/crates/busbar/src/root/linked.rs b/crates/busbar/src/root/linked.rs index d16f9808b9..0a11788b11 100644 --- a/crates/busbar/src/root/linked.rs +++ b/crates/busbar/src/root/linked.rs @@ -1502,3 +1502,7 @@ mod linked_protocols; #[cfg(all(test, linked_axis_hooks))] #[path = "tests/linked_hook_ranking.rs"] mod linked_hook_ranking; + +#[cfg(test)] +#[path = "tests/linked_store_double.rs"] +mod linked_store_double; diff --git a/crates/busbar/src/root/tests/linked_store_double.rs b/crates/busbar/src/root/tests/linked_store_double.rs new file mode 100644 index 0000000000..838070eece --- /dev/null +++ b/crates/busbar/src/root/tests/linked_store_double.rs @@ -0,0 +1,247 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! THE LINKED STORE DOOR, PROVEN WHERE IT IS LINKED (R-FIX3). The kernel's test build takes no store +//! crate under any name (THE DESIGN Part 2 #2: the kernel reaches a store only through the store +//! kind's door): it links its in-crate double (`busbar_kernel::test_support::store_double`), and +//! its own tests run against that. These prove the double copies the real door the root links +//! (`crate::LINKED.stores`): the same Statement, and the same answers to one verb script, both +//! stores opened through their doors on the store axis exactly as boot opens the configured store. + +use std::sync::Arc; + +use busbar_contract::abi::mechanism::door::DoorFn; +use busbar_contract::abi::mechanism::rendering; +use busbar_contract::records::{ + AuditRecord, PlaneDisposition, PlaneRecord, PlaneSelector, RecordStore, UsageDelta, VirtualKey, +}; +use busbar_contract::store_calls::{StoreAxis as _, StoreDoor}; +use busbar_kernel::test_support::store_double; + +fn read(door: DoorFn) -> rendering::Read { + let stated = crate::root::loader::dispatch::rendering_of(door).expect("the door states"); + rendering::read(&stated).expect("the rendering reads") +} + +/// The root's linked store row the double stands in for: the one under the double's key. +fn real_row() -> &'static busbar_kernel::preflight::LinkedStore { + crate::LINKED + .stores + .iter() + .find(|s| s.0 == store_double::STORE.0) + .expect("the root links a store under the key the double answers") +} + +/// `door`, opened through the store axis on a dispatcher of its own, as boot opens a store. +fn open(door: DoorFn, label: &str) -> Arc { + let axis = crate::root::loader::store_v3::DoorStoreAxis { + dispatcher: Arc::new(crate::root::loader::dispatch::Dispatcher::new( + crate::root::loader::dispatch::DispatchConfig::default(), + )), + logs: crate::root::boot::plugin_logs().clone(), + // Neither store declares a need: each serves with no connection table. + conns: crate::root::loader::dispatch::ConnTable::NoNeeds, + mint: busbar_kernel::door::op_id, + }; + axis.open(StoreDoor::Linked(door), label, b"{}") + .expect("the store opens through its door") + .records +} + +fn audit(seq: u64, hash: &str) -> AuditRecord { + AuditRecord { + seq, + ts: 5, + action: "key.create".into(), + resource: "key:k1".into(), + outcome: "applied".into(), + principal: "root".into(), + prev_hash: String::new(), + hash: hash.into(), + } +} + +fn plane(kind: &str, id: &str, parent: Option<&str>, seq: u64) -> PlaneRecord { + PlaneRecord { + kind: kind.into(), + id: id.into(), + parent: parent.map(str::to_string), + seq, + ts: 5, + disposition: PlaneDisposition::Active, + body: format!("{id}@{seq}").into_bytes(), + } +} + +/// One verb script, every answer written down: keys (the tombstone and scrub refusals), usage, the +/// audit chain (its fork refusal), plane records (upsert, appended chain, parent listing), the +/// spent-token test-and-set, the live-token read and the denylist. No answer reads the wall clock. +fn transcript(s: &dyn RecordStore) -> Vec { + let mut t = Vec::new(); + let key = VirtualKey { + id: "k1".into(), + name: "one".into(), + enabled: true, + created_at: 10, + ..Default::default() + }; + t.push(format!("put_key {:?}", s.put_key(&key).map_err(|e| e.0))); + t.push(format!( + "put_key again {:?}", + s.put_key(&key).map_err(|e| e.0) + )); + let got = s + .get_key("k1") + .map(|k| k.map(|k| (k.id, k.name, k.enabled, k.revision, k.deleted_at))); + t.push(format!("get_key {:?}", got.map_err(|e| e.0))); + let since = s.list_keys_since(0).map(|v| { + v.into_iter() + .map(|k| (k.id, k.revision)) + .collect::>() + }); + t.push(format!("list_keys_since {:?}", since.map_err(|e| e.0))); + t.push(format!( + "delete_key unknown {:?}", + s.delete_key("nope").map_err(|e| e.0) + )); + t.push(format!( + "scrub_key live {:?}", + s.scrub_key("k1").map_err(|e| e.0) + )); + // The tombstone: written (its instant is the wall clock's, so only its presence is read), never + // cleared by a live-shaped write, and scrubbable once it stands. + t.push(format!( + "delete_key {:?}", + s.delete_key("k1").map_err(|e| e.0) + )); + t.push(format!( + "put_key over tombstone {:?}", + s.put_key(&key).map_err(|e| e.0) + )); + let dead = s + .get_key("k1") + .map(|k| k.map(|k| (k.enabled, k.deleted_at.is_some(), k.revision))); + t.push(format!("get_key tombstoned {:?}", dead.map_err(|e| e.0))); + t.push(format!( + "scrub_key tombstoned {:?}", + s.scrub_key("k1").map_err(|e| e.0) + )); + + let delta = UsageDelta { + requests: 2, + billable_requests: 1, + ..Default::default() + }; + t.push(format!( + "add_usage {:?}", + s.add_usage("b", 60, &delta).map_err(|e| e.0) + )); + t.push(format!( + "add_usage {:?}", + s.add_usage("b", 60, &delta).map_err(|e| e.0) + )); + t.push(format!( + "get_usage {:?}", + s.get_usage("b", 60).map_err(|e| e.0) + )); + + t.push(format!( + "append_audit {:?}", + s.append_audit(&audit(1, "hash-a")).map_err(|e| e.0) + )); + t.push(format!( + "append_audit same {:?}", + s.append_audit(&audit(1, "hash-a")).map_err(|e| e.0) + )); + t.push(format!( + "append_audit fork {:?}", + s.append_audit(&audit(1, "hash-b")).map_err(|e| e.0) + )); + t.push(format!("list_audit {:?}", s.list_audit().map_err(|e| e.0))); + + let head = plane("probe", "t1", None, 0); + t.push(format!( + "upsert {:?}", + s.upsert_plane_record(head.view()).map_err(|e| e.0) + )); + t.push(format!( + "get {:?}", + s.get_plane_record("probe", "t1").map_err(|e| e.0) + )); + for seq in [1, 2] { + let step = plane("probe-step", "s", Some("t1"), seq); + t.push(format!( + "append {:?}", + s.append_plane_record(step.view()).map_err(|e| e.0) + )); + } + let chain = s.list_plane_records("probe-step", &PlaneSelector::Parent("t1".into())); + t.push(format!("list by parent {:?}", chain.map_err(|e| e.0))); + let parents = s.list_plane_record_parents("probe-step"); + t.push(format!("parents {:?}", parents.map_err(|e| e.0))); + + for _ in 0..2 { + let spent = s.redeem_plane_token("probe", "tok", u64::MAX, 5); + t.push(format!("redeem {:?}", spent.map_err(|e| e.0))); + } + let live = s.plane_token_live("probe", "t1", u64::MAX, 5); + t.push(format!("live {:?}", live.map_err(|e| e.0))); + let lapsed = s.plane_token_live("probe", "t1", 4, 5); + t.push(format!("lapsed {:?}", lapsed.map_err(|e| e.0))); + + t.push(format!( + "add_denylist {:?}", + s.add_denylist("sub", "why").map_err(|e| e.0) + )); + t.push(format!( + "list_denylist {:?}", + s.list_denylist().map_err(|e| e.0) + )); + t +} + +/// R-FIX3: THE KERNEL'S STORE DOUBLE STATES WHAT THE LINKED STORE DOOR STATES, so the kernel's tests, +/// which boot on the double and build every `GovState` over it, judge the store the binary ships. +/// Both Statements rendered: the same kind and ABI, `max_inflight`, flag marks, word marks, needs and +/// tail, and the same ephemeral flag on the linked row; the name is the double's own (the kernel names +/// no plugin, C1). Both opened through their doors on the store axis: the same answer to every verb +/// of one script, refusals byte for byte. +/// +/// RED by flipping any copied rule in the double (e.g. `redeem_plane_token` answering `true` twice, +/// or `put_key` clearing a tombstone), or by stating a different tail. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn the_kernel_store_double_states_what_the_linked_store_door_states() { + let row = real_row(); + let (real, double) = (read(row.2), read(store_double::door)); + assert_ne!( + double.name, real.name, + "the double states its own name: the kernel names no plugin (C1)" + ); + assert_eq!(double.name, store_double::NAME, "the Statement name"); + assert_eq!( + store_double::STORE.3, + store_double::NAME, + "the canonical name" + ); + assert_eq!(double.kind, real.kind); + assert_eq!(double.kind_abi, real.kind_abi); + assert_eq!(double.max_inflight, real.max_inflight, "max_inflight"); + assert_eq!(double.marks, real.marks, "the flag marks"); + assert_eq!(double.mark_words, real.mark_words, "every word mark"); + assert_eq!(double.needs, real.needs, "the needs"); + assert_eq!(double.kind_tail_size, real.kind_tail_size, "the tail"); + assert_eq!(store_double::STORE.1, row.1, "the ephemeral flag"); + + let (real_door, double_door) = (row.2, store_double::door); + let (want, got) = tokio::task::spawn_blocking(move || { + let want = transcript(open(real_door, "linked-real").as_ref()); + let got = transcript(open(double_door, "linked-double").as_ref()); + (want, got) + }) + .await + .expect("the blocking scripts ran"); + assert_eq!( + got, want, + "the double answers the script as the linked store does" + ); +} diff --git a/qa/kind-isolation.toml b/qa/kind-isolation.toml index dd77130f92..f2eb9d31b6 100644 --- a/qa/kind-isolation.toml +++ b/qa/kind-isolation.toml @@ -828,17 +828,6 @@ cite = "BUSBAR-1.6.0.md THE DESIGN, §1 — kernel -> plugin-tooling is a gra why = "busbar-kernel names busbar-plugin-loader: declared normal in the shipped graph, 1 declaration(s) at this commit. The count is TODAY'S MEASUREMENT, exact in both directions — above it is the landing that grew the coupling, below it is stale slack that was not drained on the commit that drained the edge." drain = "none: BUSBAR-1.6.0.md grants this class. Draining it would be an architecture change and not a landing — what this row holds is the NUMBER, so a second declaration of a granted edge is still a landing that has to say why." -[[dep]] -from = "busbar-kernel" -to = "busbar-store-memory" -half = "shipped" -count = "1" -verdict = "not-allowed" -cite = "ARCHITECT K5/K5b/K5c/K5d rulings (b17f0a2c1, 0ade205df): the composition root links the build's default store as a row of its linked tables (roster def 1); the kernel names neither crate in production. `kernel -> store` is in neither ARCHITECTURE_ALLOWED nor ARCHITECTURE_TCB, so the verdict stays the one the architecture implies; the row holds a TEST-SUPPORT stand-in edge, not a shipped coupling." -why = "busbar-kernel names busbar-store-memory as `fixture-store`: declared normal, optional in the shipped graph, enabled ONLY by the kernel's `test-support` feature (`dep:fixture-store`) — a test build has no root, so this fixture stands in for the row the root links (`preflight::install_linked_rows`); no kernel source names the plugin, and no default or shipped feature turns the edge on. 1 declaration(s) at this commit. The count is TODAY'S MEASUREMENT, exact in both directions." -drain = "delete the `fixture-store` stand-in from busbar-kernel's Cargo.toml once the kernel's own tests take their linked rows from a contract-only fixture; the shipped edge is the root's (busbar -> busbar-store-memory)." - - [[dep]] from = "busbar-a2a" to = "busbar-contract" @@ -1811,8 +1800,8 @@ cite = "measured on predev f830faa691 (ARCHITECT 2026-10-07, Law 9); the drain [[law0]] crate = "busbar-kernel" axis = "store" -count = "4" -cite = "measured on predev f830faa691 (ARCHITECT 2026-10-07, Law 9); the drain is THE DESIGN §1 / DoD item 2: core names no instance" +count = "1" +cite = "measured on predev f830faa691 (ARCHITECT 2026-10-07, Law 9); the drain is THE DESIGN §1 / DoD item 2: core names no instance; LOWERED 4 -> 1 by row 88 (kernel -> store edge): the kernel names no store crate, its tests run on the in-crate store double, and the three test comments that cited the plugin by package name now cite the double (audit/tests/journal_tests.rs, plane/tests/demotion_tests.rs, plane_host/tests/journal_tests.rs); the one left is config/sections.rs's 1.5.x `GOVERNANCE_STORE_MEMORY` citation" [[law0]] crate = "busbar-kernel" From 07a23cba3d76e789db449ba321f7238b1172096f Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:07:02 -0700 Subject: [PATCH 28/59] retire busbar-release from busbar: delete promote.yml; ModernCI alone judges every move (row 163) OWNER 2026-10-12 "ModernCI only"; 2026-10-13 "delete anything you don't need anymore, yes" and "we keep the busbar-release repo, slimmed down. ModernCI replaces its CI half; the busbar-specific tools stay." Spec Part 6 gains the ruling (keep/delete lists, the ladder on .ci/*.yaml, the oracle home) and the passages it supersedes are struck, Part 5's DEV-GREEN item 6 included. - .github/workflows/promote.yml deleted: the busbar-release hop ran on every PR into dev/qa/main and ended INFRA every run (R2 keys unset). - workflow-rules: R16 (engine pin), R18 (promote.yml release artifact), R19 (hop R2 keys) go with the file they held; the R1/R11/R14-flow mutations move to plugin-ci.yml and planted files; MIN_WORKFLOWS 5 -> 4 (the four plugin-*.yml workflows). - no-self-filed-issues: discovery floor 5 -> 4, the same workflow count. - infra: the "every gate the pipeline calls is registered" test reads .ci/*.yaml, the pipeline. - .ci/predev.yaml: no longer a shadow; gates requires git-history as in .ci/default.yaml. - RELEASE.md, required-status-checks, CONTRIBUTING, README badge, actions-pins: the ladder is ModernCI's. --- .ci/predev.yaml | 13 +- .github/required-status-checks.md | 12 +- .github/workflows/promote.yml | 474 ----------- CONTRIBUTING.md | 2 +- README.md | 1 - RELEASE.md | 56 +- docs/ci/actions-pins.md | 4 +- docs/design/BUSBAR-1.6.0.md | 150 ++-- xtask/src/gates/no_self_filed_issues.rs | 9 +- xtask/src/gates/package_selectors.rs | 2 +- xtask/src/gates/ship_ready.rs | 4 +- xtask/src/gates/tests/workflow_rules_tests.rs | 276 ------- xtask/src/gates/workflow_rules.rs | 738 +----------------- xtask/tests/infra.rs | 54 +- 14 files changed, 205 insertions(+), 1590 deletions(-) delete mode 100644 .github/workflows/promote.yml diff --git a/.ci/predev.yaml b/.ci/predev.yaml index 966fa5be98..077c7b39c8 100644 --- a/.ci/predev.yaml +++ b/.ci/predev.yaml @@ -1,7 +1,9 @@ -# What predev must pass to move into dev: busbar-release's `dev` rung (crates/busbar-release-cli/ -# ladder.json: gates inherit, oracle, conformance; verdict absolute), the 1.6.0 done-oracle, and the -# Q128 architecture audit (`cargo xtask q128 audit`). ModernCI names a branch file for the branch -# being moved, so the move predev -> dev reads this file. Shadow while busbar-release still decides. +# What predev must pass to move into dev: the `dev` rung (gates, oracle, conformance), the 1.6.0 +# done-oracle, and the Q128 architecture audit (`cargo xtask q128 audit`). ModernCI names a branch +# file for the branch being moved, so the move predev -> dev reads this file, and ModernCI alone +# judges it: promote.yml, the busbar-release hop that judged it before, is deleted (OWNER +# 2026-10-12, "ModernCI only"; docs/design/BUSBAR-1.6.0.md Part 6, "BUSBAR-RELEASE RETIRED FROM +# BUSBAR"). Its green landing is DEV-GREEN. to: dev on_green: merge # The owner's approval on the pull request holds the merge too; q128-audit gates the move on the @@ -24,6 +26,9 @@ checks: cargo xtask gate --all --format=tsv baseline: target rules: ["qa/*.toml"] + # The ceiling ratchet reads the merge-base (XTASK_CEILING_BASE), as in .ci/default.yaml: only a + # backend with the repo's history may run this check. + requires: [git-history] oracle: # busbar's own check tool, the pinned busbar-oracle (the backend's busbar-oracle tool sets # BUSBAR_ORACLE_RUST_BIN): the judge is the one dev's oracle-rust.pin names, run only when its diff --git a/.github/required-status-checks.md b/.github/required-status-checks.md index ffea47b689..530e300a00 100644 --- a/.github/required-status-checks.md +++ b/.github/required-status-checks.md @@ -5,22 +5,18 @@ Branch protection lives in repository rulesets. No script writes it. | Branch | Ruleset | Required | | --- | --- | --- | | `predev` | 24945926 | ModernCI's `ci` check. ModernCI judges the PR (`.ci/default.yaml`) and lands it. | -| `dev` | 24340358 | `preflight` and `hop`, both from `.github/workflows/promote.yml` (the predev to dev promotion). | +| `dev` | 24340358 | ModernCI's `ci` check on the predev to dev move (`.ci/predev.yaml`). The ruleset still names `preflight` and `hop`, the jobs of the deleted `promote.yml`: the owner replaces them with `ci`. | | `qa`, `main` | 24334334 | Owner-only; no status check stands in for the owner. | -Use the exact **check-run name** (the job's `name:`), not the workflow name. In `promote.yml`, `hop` -is the job id with no `name:`, so its context reads `hop`. - -* `preflight`: formatting, `Cargo.lock` current, the generated C header current, on a free runner. -* `hop`: the pinned busbar-release engine plans the hop and admits the candidate (every registered - `cargo xtask gate`, plus the oracle and conformance where the rung asks for them). +Use the exact **check name** the judge posts. ModernCI posts one check, `ci`, per pull request head. ## Reusable workflows (plugin repos call these) `plugin-ci.yml`, `plugin-consumer-verify.yml`, `plugin-repin.yml` and `plugin-release.yml` are `workflow_call` only, called by the plugin repos' rendered callers (busbar-release `template/`, `busbar-release plugin sync`). Their status bubbles up into the **caller's** job, so require the -caller's job in the plugin repo, never these directly. +caller's job in the plugin repo, never these directly. They stay until each plugin repo is +registered with ModernCI and judged by its own branch files. ## Branch hygiene diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml deleted file mode 100644 index aa1c9b0ae5..0000000000 --- a/.github/workflows/promote.yml +++ /dev/null @@ -1,474 +0,0 @@ -name: Promote -# This workflow serves the predev->dev promotion pull request and the dev->qa->main ladder above it. The -# hop is GetBusbar/busbar-release's `busbar-release ci hop|verdict|publish|fallback` at RELEASE_REF -# (private, so no `uses:`); these jobs only check out both repos and run it. No job here runs BOARD -# (`ci train promote` needs the train's write token, and the job that runs the candidate's code holds -# none): a PR lands by merge. -# Pull requests into predev are not judged here: ModernCI judges them (required check `ci`) and lands -# them with its own merge train, so predev has no merge queue and this workflow runs for no pull request -# into predev or into a lane branch (a stacked lane PR), nor for `merge_group`. The `merge_group` handling in the jobs below -# is left in place, dormant: it is job logic, and the concurrency and env lines the preflight holds to busbar-release's compiled copy read it. -on: { pull_request: { branches: [dev, qa, main] } } -# R5 (busbar-release gate.rs, OWNER 2026-10-08): no run is ever cancelled in progress. A pull request -# has at most one run running and one pending, and each newer push replaces the pending one, so the -# pending run is always the newest head. A merge-queue run is its own group (a cancelled queue run -# re-runs whole, owner money 2026-10-03). The preflight holds this line to the one compiled into -# busbar-release: any drift is RED. -concurrency: { group: "promote-${{ github.event.pull_request.number || github.event.merge_group.head_ref }}", cancel-in-progress: false } -permissions: { contents: read } -# THE ENGINE PIN IS NOT IN THIS FILE (OWNER 2026-10-10): the org variables ENGINE_REF (a full -# busbar-release commit) and ENGINE_SHA256 (the sha256 of that commit's dev-bin ci asset), set by -# hand with `busbar-release engine pin `, so an engine update never waits in predev's merge -# queue. The preflight reads them ONCE and hands them to every later job as outputs: one run is one -# engine even if the variables move mid-run. Unset or malformed is a hard FAIL (ENGINE-PIN), never -# an older pin; the bytes run only when their sha256 is ENGINE_SHA256 (workflow-rules R16). -env: - # The engine contract (busbar-release contract.rs): 2 = the hop holds only the read-only R2 keys and `cache-publish` writes. An engine refuses a contract it does not support, and the auto-pin moves only to engines that support this number. - BUSBAR_RELEASE_CONTRACT: "2" - SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }} - TO: ${{ github.base_ref || github.event.merge_group.base_ref }} - BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }} -jobs: - preflight: - if: github.event_name == 'merge_group' || github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-latest - timeout-minutes: 15 - # THE RUN GATE reads GitHub (open pull requests, the merge queue, this head's promote runs) with - # this job's token. This job runs the candidate's `cargo xtask`, so the token only READS - # (busbar-release 941028a): the gate's writes (ORPHANS, R7 REPEAT) are recorded in the act - # artifact before any candidate code runs, and `preflight-act` below makes them. - # OLD-ENGINE SHIM (remove once ENGINE_REF is at or past busbar-release 941028a): an older engine's - # R6 field `jobs.preflight.permissions (actions: read)` reads this line's text, comment included; - # this job's token stays contents read only. - permissions: { contents: read } # actions: read is public on this repo; the writes are preflight-act's - # RUNNERS: the runner gateway's table (vars.RUNNERS), from which the preflight sizes `runner=`. - env: { RELEASE_REF: "${{ vars.ENGINE_REF }}", ENGINE_SHA256: "${{ vars.ENGINE_SHA256 }}", RUNNERS: "${{ vars.RUNNERS }}" } - outputs: { engine_ref: "${{ steps.engine.outputs.engine_ref }}", engine_sha256: "${{ steps.engine.outputs.engine_sha256 }}", runner: "${{ steps.pre.outputs.runner }}", soak: "${{ steps.pre.outputs.soak }}", one_runner: "${{ steps.pre.outputs.one_runner }}", needs_release_artifact: "${{ steps.pre.outputs.needs_release_artifact }}", run: "${{ steps.pre.outputs.run }}", reason: "${{ steps.pre.outputs.reason }}" } - steps: - # Full history: the pre-flight reads the pull request's diff against BASE_SHA to choose one runner or the matrix. - - { uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1, with: { ref: "${{ env.SHA }}", path: busbar, fetch-depth: 0 } } # v7 - # THE ENGINE, downloaded, never compiled: CI's --no-default-features binary of RELEASE_REF from - # busbar-release's dev-bin release (dev-bin.yml), run only when its sha256 is ENGINE_SHA256. - # First the pin's shape: a full 40-hex commit and a 64-hex digest, or FAIL. - - name: Engine (busbar-release at RELEASE_REF, sha256-pinned) - id: engine - env: { GH_TOKEN: "${{ secrets.TURNSTILE_DISPATCH_TOKEN }}" } - run: | - set -euo pipefail - [[ "$RELEASE_REF" =~ ^[0-9a-f]{40}$ ]] || { echo "::error title=ENGINE-PIN FAIL::vars.ENGINE_REF is unset or not a full 40-hex busbar-release commit (busbar-release engine pin )"; exit 1; } - [[ "$ENGINE_SHA256" =~ ^[0-9a-f]{64}$ ]] || { echo "::error title=ENGINE-PIN FAIL::vars.ENGINE_SHA256 is unset or not a 64-hex sha256 (busbar-release engine pin )"; exit 1; } - api=https://api.github.com/repos/GetBusbar/busbar-release/releases - name="busbar-release-ci-linux-x86_64-$RELEASE_REF" - id=$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" "$api/tags/dev-bin" | jq -r --arg n "$name" '.assets[] | select(.name == $n) | .id') - [ -n "$id" ] || { echo "::error::dev-bin has no $name"; exit 1; } - curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/octet-stream" -o "$RUNNER_TEMP/busbar-release" "$api/assets/$id" - echo "$ENGINE_SHA256 $RUNNER_TEMP/busbar-release" | sha256sum -c - - chmod +x "$RUNNER_TEMP/busbar-release" - { echo "engine_ref=$RELEASE_REF"; echo "engine_sha256=$ENGINE_SHA256"; } >> "$GITHUB_OUTPUT" - - { uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1, with: { repository: GetBusbar/busbar-release, ref: "${{ env.RELEASE_REF }}", token: "${{ secrets.TURNSTILE_DISPATCH_TOKEN }}", path: busbar-release, persist-credentials: false } } # v7 - # The pre-flight is busbar-release's Rust CLI (preflight.json is the one list): it sizes the hop - # (runner= to $GITHUB_OUTPUT), chooses how a pull request into predev runs (one_runner=: ONE runner - # when the crates its diff affects are estimated within 20 min, else the shard matrix; OWNER-LOCKED - # 2026-10-06), whether this run reads the release artifact (needs_release_artifact=: the rung runs - # the oracle or conformance; predev runs neither), and runs every pre-flight check against the - # candidate. Built with no default features, as the CLI documents for this job. FIRST it is THE - # RUN GATE (OWNER 2026-10-08): run=1|0 and reason= from facts only (R1 STACK, R2 queued, R3 - # superseded, R4 duplicate head, R5/R6 this file's wiring); run=0 means nothing in this run - # builds, and a RED rule fails here. The gate's writes are recorded (`--act-out act/act.json`, - # before any candidate code runs) for preflight-act; this job makes none. - # OLD-ENGINE SHIM (remove once ENGINE_REF is at or past 941028a): an engine without --act-out - # runs without it. - - id: pre - working-directory: busbar-release - env: { GITHUB_TOKEN: "${{ github.token }}", RUNNERS: "${{ vars.RUNNERS }}" } - run: | - act=() - if "$RUNNER_TEMP/busbar-release" ci preflight --help | grep -q -- --act-out; then act=(--act-out "$GITHUB_WORKSPACE/act/act.json"); fi - "$RUNNER_TEMP/busbar-release" ci preflight "${act[@]}" - - { if: always(), uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a, with: { name: "preflight-act-${{ env.SHA }}", path: "act/", retention-days: 1, if-no-files-found: ignore, overwrite: true } } # v7.0.1 - # THE RUN GATE'S WRITES (busbar-release 941028a), in a job that runs NO candidate code: it checks - # out nothing, downloads the engine (sha256-pinned) and the preflight's act artifact, and runs - # `ci preflight-act`, which re-reads GitHub itself: ORPHANS cancels the merge-group runs a queue - # rebuild left running (actions: write), and R7 REPEAT turns off the auto-merge of a PR whose head - # already failed the queue as its own red (pull-requests: write), only the PR both the artifact - # and the re-read name. Merge groups only: a pull request has nothing to act on. It runs whatever - # the preflight concluded (R7 is a RED preflight), and never decides the verdict. - preflight-act: - needs: preflight - if: ${{ always() && github.event_name == 'merge_group' && needs.preflight.outputs.engine_ref != '' }} - runs-on: ubuntu-latest - timeout-minutes: 10 - permissions: { contents: read, actions: write, pull-requests: write } - env: { RELEASE_REF: "${{ needs.preflight.outputs.engine_ref }}", ENGINE_SHA256: "${{ needs.preflight.outputs.engine_sha256 }}" } - steps: - # THE ENGINE, downloaded, never compiled: CI's --no-default-features binary of RELEASE_REF from - # busbar-release's dev-bin release (dev-bin.yml), run only when its sha256 is ENGINE_SHA256. - # First the pin's shape: a full 40-hex commit and a 64-hex digest, or FAIL. - - name: Engine (busbar-release at RELEASE_REF, sha256-pinned) - env: { GH_TOKEN: "${{ secrets.TURNSTILE_DISPATCH_TOKEN }}" } - run: | - set -euo pipefail - [[ "$RELEASE_REF" =~ ^[0-9a-f]{40}$ ]] || { echo "::error title=ENGINE-PIN FAIL::vars.ENGINE_REF is unset or not a full 40-hex busbar-release commit (busbar-release engine pin )"; exit 1; } - [[ "$ENGINE_SHA256" =~ ^[0-9a-f]{64}$ ]] || { echo "::error title=ENGINE-PIN FAIL::vars.ENGINE_SHA256 is unset or not a 64-hex sha256 (busbar-release engine pin )"; exit 1; } - api=https://api.github.com/repos/GetBusbar/busbar-release/releases - name="busbar-release-ci-linux-x86_64-$RELEASE_REF" - id=$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" "$api/tags/dev-bin" | jq -r --arg n "$name" '.assets[] | select(.name == $n) | .id') - [ -n "$id" ] || { echo "::error::dev-bin has no $name"; exit 1; } - curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/octet-stream" -o "$RUNNER_TEMP/busbar-release" "$api/assets/$id" - echo "$ENGINE_SHA256 $RUNNER_TEMP/busbar-release" | sha256sum -c - - chmod +x "$RUNNER_TEMP/busbar-release" - - { continue-on-error: true, uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c, with: { name: "preflight-act-${{ env.SHA }}", path: act } } # v8.0.1 - # OLD-ENGINE SHIM (remove once ENGINE_REF is at or past 941028a): an engine without the verb - # made these writes in the preflight itself. - - name: The run gate's writes (ORPHANS, R7 REPEAT) - env: { GITHUB_TOKEN: "${{ github.token }}" } - run: | - if ! "$RUNNER_TEMP/busbar-release" ci --help | grep -q preflight-act; then echo "engine $RELEASE_REF has no preflight-act: nothing to act on here"; exit 0; fi - "$RUNNER_TEMP/busbar-release" ci preflight-act --act "$GITHUB_WORKSPACE/act/act.json" - # The release artifact the oracle records and the slsa-verifier conformance rig judges. It runs in - # every merge group (the fold requires it: a check on the release build), and in every run whose - # rung reads the artifact (`needs_release_artifact=true`: the rung runs the oracle or conformance, - # i.e. the predev->dev pull request and up). dev moves by pull request now, not by the ladder - # fast-forwarding a merge group's sha (ARCHITECT 2026-10-02, superseded), so the run that judges a - # promotion builds the artifact of exactly the commit it promotes: its shards download - # `slsa-artifact-` from THIS run, which a merge-group-only build never produced (#740 failed - # every run at that download). workflow-rules R18 holds this wiring. Its SLSA provenance is minted - # from `base64-subjects` by the pinned slsa-github-generator once the owner rules on R14 (the - # generator must be referenced by tag); until then the suite is not-run, a standing red here. - slsa-build: - if: needs.preflight.outputs.run == '1' && (github.event_name == 'merge_group' || needs.preflight.outputs.needs_release_artifact == 'true') - needs: preflight - permissions: { contents: read } - runs-on: ${{ needs.preflight.outputs.runner }} - timeout-minutes: 90 - outputs: { base64-subjects: "${{ steps.digest.outputs.base64-subjects }}" } - steps: - - { uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1, with: { ref: "${{ env.SHA }}", path: busbar, persist-credentials: false } } # v7 - - name: Build the release artifact - working-directory: busbar - run: | - cargo build --locked --release -p busbar --bin busbar - mkdir -p ../slsa && cp target/release/busbar ../slsa/busbar - - id: digest - name: Its sha256 (the provenance subject) - working-directory: slsa - run: | - sha256sum busbar > busbar.sha256 - echo "base64-subjects=$(base64 -w0 busbar.sha256)" >> "$GITHUB_OUTPUT" - - { uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a, with: { name: "slsa-artifact-${{ env.SHA }}", path: "slsa/", retention-days: 7, if-no-files-found: error } } # v7.0.1 - # THE QA SOAK (OWNER 2026-10-10; busbar #734): the soak stays, as a 1-hour stability gate on each - # qa promotion. `busbar-release soak ` (profile.rs's full profile: 3600 s, 50 rps, - # 16 concurrent) soaks THIS candidate's release binary on the runner the preflight chose, wherever - # the ladder's entry asks for it (`soak=true`: qa). The hop folds this job's result like - # slsa-build's (`ci turnstile merge --needs soak=...`): a failed soak DENIES the promotion. CI=false - # selects the full hour (the CI context would pick the two-minute smoke). It runs the candidate, so - # it holds no write token. The candidate binary is this run's slsa-build artifact (a rung with the - # soak reads the artifact, so slsa-build runs); a run without it builds it here. The engine is the - # downloaded ci binary (built with `--features soak`, dev-bin.yml), sha256-pinned like every other - # job's. - soak: - needs: [preflight, slsa-build] - if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.preflight.outputs.run == '1' && needs.preflight.outputs.soak == 'true' }} - permissions: { contents: read } - runs-on: ${{ needs.preflight.outputs.runner }} - timeout-minutes: 180 - env: { RELEASE_REF: "${{ needs.preflight.outputs.engine_ref }}", ENGINE_SHA256: "${{ needs.preflight.outputs.engine_sha256 }}" } - steps: - - { uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1, with: { ref: "${{ env.SHA }}", path: busbar, persist-credentials: false } } # v7 - # THE ENGINE, downloaded, never compiled: CI's --no-default-features binary of RELEASE_REF from - # busbar-release's dev-bin release (dev-bin.yml), run only when its sha256 is ENGINE_SHA256. - # First the pin's shape: a full 40-hex commit and a 64-hex digest, or FAIL. - - name: Engine (busbar-release at RELEASE_REF, sha256-pinned) - env: { GH_TOKEN: "${{ secrets.TURNSTILE_DISPATCH_TOKEN }}" } - run: | - set -euo pipefail - [[ "$RELEASE_REF" =~ ^[0-9a-f]{40}$ ]] || { echo "::error title=ENGINE-PIN FAIL::vars.ENGINE_REF is unset or not a full 40-hex busbar-release commit (busbar-release engine pin )"; exit 1; } - [[ "$ENGINE_SHA256" =~ ^[0-9a-f]{64}$ ]] || { echo "::error title=ENGINE-PIN FAIL::vars.ENGINE_SHA256 is unset or not a 64-hex sha256 (busbar-release engine pin )"; exit 1; } - api=https://api.github.com/repos/GetBusbar/busbar-release/releases - name="busbar-release-ci-linux-x86_64-$RELEASE_REF" - id=$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" "$api/tags/dev-bin" | jq -r --arg n "$name" '.assets[] | select(.name == $n) | .id') - [ -n "$id" ] || { echo "::error::dev-bin has no $name"; exit 1; } - curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/octet-stream" -o "$RUNNER_TEMP/busbar-release" "$api/assets/$id" - echo "$ENGINE_SHA256 $RUNNER_TEMP/busbar-release" | sha256sum -c - - chmod +x "$RUNNER_TEMP/busbar-release" - - { uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1, with: { repository: GetBusbar/busbar-release, ref: "${{ env.RELEASE_REF }}", token: "${{ secrets.TURNSTILE_DISPATCH_TOKEN }}", path: busbar-release, persist-credentials: false } } # v7 - - if: needs.slsa-build.result == 'success' - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: { name: "slsa-artifact-${{ env.SHA }}", path: slsa } - - if: needs.slsa-build.result != 'success' - working-directory: busbar - run: | - cargo build --locked --release -p busbar --bin busbar - mkdir -p ../slsa && cp target/release/busbar ../slsa/busbar - - name: Soak the candidate (the full profile) - working-directory: busbar-release - env: { CI: "false", GH_TOKEN: "${{ github.token }}", TMPDIR: "${{ runner.temp }}" } - run: | - chmod +x "$GITHUB_WORKSPACE/slsa/busbar" - "$RUNNER_TEMP/busbar-release" soak "$GITHUB_WORKSPACE/slsa/busbar" - - { if: always(), uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a, with: { name: "soak-${{ env.SHA }}", path: "${{ runner.temp }}/busbar-soak-*/", retention-days: 30, if-no-files-found: warn } } # v7.0.1 - # The merge group's hop, and the predev->dev pull request's, is SHARDED (ARCHITECT 2026-10-02): four - # runners each judge one part (`--shard K/4`: test:workspace's hash partition K, plus the unsplit steps - # dealt to K; the gates in shard 1, the oracle and conformance in shard 4). Each builds and runs the - # candidate's code, so it holds NO write token (contents and actions read only, no persisted - # credentials); its report leaves as an artifact. It starts beside slsa-build, not after it (B3-P, - # 2026-10-08), and no shard waits for it: the fold requires slsa-build's result (`needs:`, - # `--needs`, `--must-run`), and nothing a shard runs reads the release artifact (the slsa-verifier - # leg is not-run until `conformance record` is handed `--slsa-artifact`). The predev->dev shards - # sat ~7 min each in `await-job` for a download nobody read (2026-10-11). Every shard runs to - # the bottom (FULLY SEEN, busbar-release #152). A pull request into predev runs the shards only when the - # pre-flight chose the matrix (one_runner=false: every crate affected, or over 20 min on one runner); - # otherwise its hop is ONE runner, in the `hop` job below. - hop-shard: - name: "hop shard ${{ matrix.shard }}" - strategy: { fail-fast: false, matrix: { shard: [1, 2, 3, 4] } } - needs: [preflight] - if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.preflight.outputs.run == '1' && (github.event_name == 'merge_group' || github.base_ref == 'dev' || needs.preflight.outputs.one_runner != 'true') }} - permissions: { contents: read, actions: read } - runs-on: ${{ needs.preflight.outputs.runner }} - timeout-minutes: 330 - env: { RELEASE_REF: "${{ needs.preflight.outputs.engine_ref }}", ENGINE_SHA256: "${{ needs.preflight.outputs.engine_sha256 }}", RUNNERS: "${{ vars.RUNNERS }}" } - steps: - - { uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1, with: { ref: "${{ env.SHA }}", path: busbar, fetch-depth: 0, persist-credentials: false } } # v7 - # THE ENGINE, downloaded, never compiled: CI's --no-default-features binary of RELEASE_REF from - # busbar-release's dev-bin release (dev-bin.yml), run only when its sha256 is ENGINE_SHA256. - # First the pin's shape: a full 40-hex commit and a 64-hex digest, or FAIL. - - name: Engine (busbar-release at RELEASE_REF, sha256-pinned) - env: { GH_TOKEN: "${{ secrets.TURNSTILE_DISPATCH_TOKEN }}" } - run: | - set -euo pipefail - [[ "$RELEASE_REF" =~ ^[0-9a-f]{40}$ ]] || { echo "::error title=ENGINE-PIN FAIL::vars.ENGINE_REF is unset or not a full 40-hex busbar-release commit (busbar-release engine pin )"; exit 1; } - [[ "$ENGINE_SHA256" =~ ^[0-9a-f]{64}$ ]] || { echo "::error title=ENGINE-PIN FAIL::vars.ENGINE_SHA256 is unset or not a 64-hex sha256 (busbar-release engine pin )"; exit 1; } - api=https://api.github.com/repos/GetBusbar/busbar-release/releases - name="busbar-release-ci-linux-x86_64-$RELEASE_REF" - id=$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" "$api/tags/dev-bin" | jq -r --arg n "$name" '.assets[] | select(.name == $n) | .id') - [ -n "$id" ] || { echo "::error::dev-bin has no $name"; exit 1; } - curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/octet-stream" -o "$RUNNER_TEMP/busbar-release" "$api/assets/$id" - echo "$ENGINE_SHA256 $RUNNER_TEMP/busbar-release" | sha256sum -c - - chmod +x "$RUNNER_TEMP/busbar-release" - # THE JUDGE, pinned in busbar (OWNER 2026-10-10): the busbar-oracle testing/shadow-oracle/oracle-rust.pin - # names, read at the BASE (a candidate never picks its own judge), downloaded from busbar-release's - # dev-bin release and run only when its sha256 is the pin's sha256_linux_x86_64. The engine pin - # never moves it. Only the last shard runs the oracle; `ci hop` and bin/oracle both take the binary - # from BUSBAR_ORACLE_RUST_BIN, so nothing builds a judge. - - name: Oracle (pinned at the base, sha256-checked) - if: matrix.shard == 4 - env: { GH_TOKEN: "${{ secrets.TURNSTILE_DISPATCH_TOKEN }}" } - run: | - set -euo pipefail - pin="$(git -C busbar show "$BASE_SHA:testing/shadow-oracle/oracle-rust.pin" 2>/dev/null || true)" - # A base from before the pinned binary (no sha256 line): the oracle is built as before. - grep -q '^sha256_linux_x86_64=' <<<"$pin" || { echo "::notice::oracle-rust.pin at $BASE_SHA pins no binary: bin/oracle builds the judge"; exit 0; } - ref="$(printf '%s\n' "$pin" | sed -n 's/^release_ref=//p')" - ORACLE_SHA256="$(printf '%s\n' "$pin" | sed -n 's/^sha256_linux_x86_64=//p')" - [[ "$ref" =~ ^[0-9a-f]{40}$ && "$ORACLE_SHA256" =~ ^[0-9a-f]{64}$ ]] || { echo "::error title=ORACLE-PIN FAIL::oracle-rust.pin at $BASE_SHA needs a 40-hex release_ref and a 64-hex sha256_linux_x86_64"; exit 1; } - api=https://api.github.com/repos/GetBusbar/busbar-release/releases - name="busbar-oracle-linux-x86_64-$ref" - id=$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" "$api/tags/dev-bin" | jq -r --arg n "$name" '.assets[] | select(.name == $n) | .id') - [ -n "$id" ] || { echo "::error title=ORACLE-PIN FAIL::dev-bin has no $name"; exit 1; } - curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/octet-stream" -o "$RUNNER_TEMP/busbar-oracle" "$api/assets/$id" - echo "$ORACLE_SHA256 $RUNNER_TEMP/busbar-oracle" | sha256sum -c - - chmod +x "$RUNNER_TEMP/busbar-oracle" - echo "BUSBAR_ORACLE_RUST_BIN=$RUNNER_TEMP/busbar-oracle" >> "$GITHUB_ENV" - - { uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1, with: { repository: GetBusbar/busbar-release, ref: "${{ env.RELEASE_REF }}", token: "${{ secrets.TURNSTILE_DISPATCH_TOKEN }}", path: busbar-release, persist-credentials: false } } # v7 - # The conformance rigs `conformance record` runs in the full plan need node 22 and pnpm 10 (the mcp - # suite's official runner, and rolldown's native binding built under it); the Latchkey large image - # carries node 20, so mcp read not-run (CONFORMANCE-RIGS, 2026-10-02). Only the last shard of a - # rung that runs conformance or the oracle (needs_release_artifact) records them; the others - # skip the ~15 s install. - - { if: "matrix.shard == 4 && needs.preflight.outputs.needs_release_artifact == 'true'", uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413, with: { version: 10 } } # v6.1.0 - - { if: "matrix.shard == 4 && needs.preflight.outputs.needs_release_artifact == 'true'", uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020, with: { node-version: 22 } } # v7.0.0 - # No debuginfo in the dev/test builds: build_test's all-features, feature-matrix and test builds plus - # the oracle's release build filled the runner's ~41 GiB free disk with debuginfo (ENOSPC, no log). - # Base and candidate build alike, so the no-worse-than-base verdict is unchanged. - # GITHUB_TOKEN (contents read) is for `ci hop`'s R3 re-check alone (is this merge group still a - # current queue entry's head?), which runs before any candidate code; the judge that runs the - # candidate never holds it. A superseded group's shard builds nothing. - # THE R2 KEYS HERE ONLY READ (busbar-release 069f032): this job runs candidate code, and the hop - # refuses to start (INFRA) with the write keys in its environment. Unset read-only keys fail - # open (no cache). The judge's cache puts land in r2-outbox/, uploaded below and published by - # `cache-publish`, the one job that holds the write keys (workflow-rules R19). - - working-directory: busbar-release - run: $RUNNER_TEMP/busbar-release ci hop - env: { GITHUB_TOKEN: "${{ github.token }}", R2_RO_KEY_ID: "${{ secrets.R2_CACHE_RO_ACCESS_KEY_ID }}", R2_RO_KEY_SECRET: "${{ secrets.R2_CACHE_RO_SECRET_ACCESS_KEY }}", CARGO_PROFILE_DEV_DEBUG: "0", SHARD: "${{ matrix.shard }}/4" } - - { if: always(), uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a, with: { name: "turnstile-report-${{ env.SHA }}-shard${{ matrix.shard }}", path: "artifacts/", if-no-files-found: warn } } # v7.0.1 - - { if: always(), uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a, with: { name: "r2-outbox-${{ env.SHA }}-shard${{ matrix.shard }}", path: "r2-outbox/", retention-days: 1, if-no-files-found: ignore, overwrite: true } } # v7.0.1 - # `hop` (the required check) is the hop's ONE verdict. In a merge group and on the predev->dev pull - # request it folds the shards: BOARD only when every shard reported and boarded and slsa-build is - # green (or skipped: a pull request whose rung reads no release artifact): a red slsa-build denies - # it; a shard that is missing, timed out or INFRA makes it INFRA, never green. A pull request - # INTO PREDEV whose pre-flight - # chose one runner (OWNER-LOCKED 2026-10-06, BUSBAR-1.6.0 Part 6 "Verification runs once per input") - # runs it as ONE runner: no shard, no slsa-build; the ladder's predev `enter_pr`, the merge group's - # gates and steps scoped to the crates the PR changes plus their reverse dependencies, on the PR merged - # onto the base tip, judged no worse than the base the merge group landed in its 4 shards - # (BASE_SHARDS). It runs the candidate's code, so it holds no write token either. - hop: - needs: [preflight, slsa-build, hop-shard, soak] - if: ${{ !cancelled() && needs.preflight.result == 'success' }} - permissions: { contents: read, actions: read } - runs-on: ${{ (github.event_name == 'pull_request' && github.base_ref != 'dev' && needs.preflight.outputs.one_runner == 'true') && needs.preflight.outputs.runner || 'ubuntu-latest' }} - timeout-minutes: ${{ (github.event_name == 'pull_request' && github.base_ref != 'dev' && needs.preflight.outputs.one_runner == 'true') && 330 || 30 }} - env: { ONE_RUNNER: "${{ github.event_name == 'pull_request' && github.base_ref != 'dev' && needs.preflight.outputs.one_runner == 'true' }}", RELEASE_REF: "${{ needs.preflight.outputs.engine_ref }}", ENGINE_SHA256: "${{ needs.preflight.outputs.engine_sha256 }}", RUNNERS: "${{ vars.RUNNERS }}" } - steps: - # THE RUN GATE said run=0 (neutral: the merge queue, or another run of this head, judges it). - # Nothing was built, so this run does NOT JUDGE the head: the required check fails here, never - # passes by skipping (a skipped required check counts as success and would land an unjudged - # merge group). The preflight writes one_runner=false on run=0, so nothing below builds either. - - if: needs.preflight.outputs.run != '1' - env: { REASON: "${{ needs.preflight.outputs.reason }}" } - run: | - echo "::error title=NOT JUDGED::not judged by this run (run=0): $REASON. Not a verdict on the code." - exit 1 - - if: env.ONE_RUNNER == 'true' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: { ref: "${{ env.SHA }}", path: busbar, fetch-depth: 0, persist-credentials: false } - # THE ENGINE, downloaded, never compiled: CI's --no-default-features binary of RELEASE_REF from - # busbar-release's dev-bin release (dev-bin.yml), run only when its sha256 is ENGINE_SHA256. - # First the pin's shape: a full 40-hex commit and a 64-hex digest, or FAIL. - - name: Engine (busbar-release at RELEASE_REF, sha256-pinned) - env: { GH_TOKEN: "${{ secrets.TURNSTILE_DISPATCH_TOKEN }}" } - run: | - set -euo pipefail - [[ "$RELEASE_REF" =~ ^[0-9a-f]{40}$ ]] || { echo "::error title=ENGINE-PIN FAIL::vars.ENGINE_REF is unset or not a full 40-hex busbar-release commit (busbar-release engine pin )"; exit 1; } - [[ "$ENGINE_SHA256" =~ ^[0-9a-f]{64}$ ]] || { echo "::error title=ENGINE-PIN FAIL::vars.ENGINE_SHA256 is unset or not a 64-hex sha256 (busbar-release engine pin )"; exit 1; } - api=https://api.github.com/repos/GetBusbar/busbar-release/releases - name="busbar-release-ci-linux-x86_64-$RELEASE_REF" - id=$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" "$api/tags/dev-bin" | jq -r --arg n "$name" '.assets[] | select(.name == $n) | .id') - [ -n "$id" ] || { echo "::error::dev-bin has no $name"; exit 1; } - curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/octet-stream" -o "$RUNNER_TEMP/busbar-release" "$api/assets/$id" - echo "$ENGINE_SHA256 $RUNNER_TEMP/busbar-release" | sha256sum -c - - chmod +x "$RUNNER_TEMP/busbar-release" - - { uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1, with: { repository: GetBusbar/busbar-release, ref: "${{ env.RELEASE_REF }}", token: "${{ secrets.TURNSTILE_DISPATCH_TOKEN }}", path: busbar-release, persist-credentials: false } } # v7 - # No debuginfo in the dev/test builds (as in the shards: the runner's disk). Read-only R2 keys, - # the puts to r2-outbox/ for `cache-publish` (as in the shards: the judge runs candidate code). - - if: env.ONE_RUNNER == 'true' - working-directory: busbar-release - run: $RUNNER_TEMP/busbar-release ci hop - env: { R2_RO_KEY_ID: "${{ secrets.R2_CACHE_RO_ACCESS_KEY_ID }}", R2_RO_KEY_SECRET: "${{ secrets.R2_CACHE_RO_SECRET_ACCESS_KEY }}", CARGO_PROFILE_DEV_DEBUG: "0", BASE_SHARDS: "4" } - - if: always() && env.ONE_RUNNER == 'true' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: { name: "turnstile-report-${{ env.SHA }}", path: "artifacts/", if-no-files-found: warn } - - if: always() && env.ONE_RUNNER == 'true' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: { name: "r2-outbox-${{ env.SHA }}-one", path: "r2-outbox/", retention-days: 1, if-no-files-found: ignore, overwrite: true } - - if: env.ONE_RUNNER != 'true' - continue-on-error: true - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: { pattern: "turnstile-report-${{ env.SHA }}-shard*", path: shards } - - name: Fold the shards - if: env.ONE_RUNNER != 'true' - env: { GH_TOKEN: "${{ github.token }}", SLSA_BUILD: "${{ needs.slsa-build.result }}", SOAK: "${{ needs.soak.result }}", SLSA_READ: "${{ github.event_name == 'merge_group' || needs.preflight.outputs.needs_release_artifact == 'true' }}", SOAK_READ: "${{ needs.preflight.outputs.soak }}" } - run: | - mkdir -p merge artifacts - # A SKIPPED needed job DENIES (busbar-release 03cb349) unless the rung does not read it: - # slsa-build is read in a merge group and wherever needs_release_artifact, the soak where - # the ladder asks for it (soak=true). OLD-ENGINE SHIM (remove once ENGINE_REF is at or past - # 03cb349): an engine without --must-run takes a skip as neutral itself. - must=() - if "$RUNNER_TEMP/busbar-release" ci turnstile merge --help | grep -q -- --must-run; then - must=(--must-run "slsa-build=$SLSA_READ" --must-run "soak=$SOAK_READ") - fi - # The run's own job list: a shard with no report is INFRA:platform when its job was never - # created (GitHub dropped it, as in the 2026-10-07 16:52-17:01Z Actions outage), else - # INFRA:ours. A list that cannot be read leaves plain INFRA; the hop is never green on it. - gh api "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/attempts/$GITHUB_RUN_ATTEMPT/jobs?per_page=100" \ - >"$GITHUB_WORKSPACE/shard-jobs.json" || true - for k in 1 2 3 4; do - d="shards/turnstile-report-$SHA-shard$k" - mkdir -p "merge/shard-$k" "artifacts/shard-$k" - [ -f "$d/turnstile-report.json" ] && cp "$d/turnstile-report.json" "merge/shard-$k/" - [ -d "$d" ] && cp -r "$d/." "artifacts/shard-$k/" - done - cd busbar-release - "$RUNNER_TEMP/busbar-release" \ - ci turnstile merge --sha "$SHA" --shards 4 --dir "$GITHUB_WORKSPACE/merge" --out "$GITHUB_WORKSPACE/artifacts/turnstile-report.json" \ - --shard-jobs "$GITHUB_WORKSPACE/shard-jobs.json" --needs "slsa-build=$SLSA_BUILD" --needs "soak=$SOAK" "${must[@]}" - # The durable verdict (verdict/), from the folded report and every shard's step logs: the artifact publish reads. - - if: always() && needs.preflight.outputs.run == '1' - working-directory: busbar-release - run: $RUNNER_TEMP/busbar-release ci verdict - - { if: "always() && needs.preflight.outputs.run == '1'", uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a, with: { name: "verdict-${{ env.SHA }}", path: "verdict/", retention-days: 90, if-no-files-found: warn } } # v7.0.1 - # THE R2 CACHE'S ONE WRITER (busbar-release 069f032). The hop jobs judge with READ-ONLY keys and - # leave their cache puts (base reports, step PASSes) in r2-outbox/ artifacts; this job uploads them - # with the WRITE keys. It runs NO candidate code: it checks out nothing, downloads the engine - # (sha256-pinned) and the outboxes, and runs `ci cache-publish`, which uploads only objects under - # the cache's own prefixes. It is no part of the verdict: nothing needs it, it is no required - # check, and it fails open (no keys, no outbox, a failed upload: a later cache miss). - # workflow-rules R19: no job that runs candidate code names the write keys. - cache-publish: - needs: [preflight, hop-shard, hop] - if: ${{ always() && needs.preflight.result == 'success' && needs.preflight.outputs.run == '1' }} - runs-on: ubuntu-latest - timeout-minutes: 15 - continue-on-error: true - permissions: { contents: read } - env: { RELEASE_REF: "${{ needs.preflight.outputs.engine_ref }}", ENGINE_SHA256: "${{ needs.preflight.outputs.engine_sha256 }}" } - steps: - # THE ENGINE, downloaded, never compiled: CI's --no-default-features binary of RELEASE_REF from - # busbar-release's dev-bin release (dev-bin.yml), run only when its sha256 is ENGINE_SHA256. - # First the pin's shape: a full 40-hex commit and a 64-hex digest, or FAIL. - - name: Engine (busbar-release at RELEASE_REF, sha256-pinned) - env: { GH_TOKEN: "${{ secrets.TURNSTILE_DISPATCH_TOKEN }}" } - run: | - set -euo pipefail - [[ "$RELEASE_REF" =~ ^[0-9a-f]{40}$ ]] || { echo "::error title=ENGINE-PIN FAIL::vars.ENGINE_REF is unset or not a full 40-hex busbar-release commit (busbar-release engine pin )"; exit 1; } - [[ "$ENGINE_SHA256" =~ ^[0-9a-f]{64}$ ]] || { echo "::error title=ENGINE-PIN FAIL::vars.ENGINE_SHA256 is unset or not a 64-hex sha256 (busbar-release engine pin )"; exit 1; } - api=https://api.github.com/repos/GetBusbar/busbar-release/releases - name="busbar-release-ci-linux-x86_64-$RELEASE_REF" - id=$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" "$api/tags/dev-bin" | jq -r --arg n "$name" '.assets[] | select(.name == $n) | .id') - [ -n "$id" ] || { echo "::error::dev-bin has no $name"; exit 1; } - curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/octet-stream" -o "$RUNNER_TEMP/busbar-release" "$api/assets/$id" - echo "$ENGINE_SHA256 $RUNNER_TEMP/busbar-release" | sha256sum -c - - chmod +x "$RUNNER_TEMP/busbar-release" - - { continue-on-error: true, uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c, with: { pattern: "r2-outbox-${{ env.SHA }}-*", path: r2-outbox, merge-multiple: true } } # v8.0.1 - # OLD-ENGINE SHIM (remove once ENGINE_REF is at or past 069f032): an engine without the verb - # wrote the cache from the hop itself. - - name: Publish the hop's cache puts (the R2 write keys, in this job only) - env: { R2_KEY_ID: "${{ secrets.R2_CACHE_ACCESS_KEY_ID }}", R2_KEY_SECRET: "${{ secrets.R2_CACHE_SECRET_ACCESS_KEY }}" } - run: | - if ! "$RUNNER_TEMP/busbar-release" ci --help | grep -q cache-publish; then echo "engine $RELEASE_REF has no cache-publish: nothing published"; exit 0; fi - "$RUNNER_TEMP/busbar-release" ci cache-publish --outbox "$GITHUB_WORKSPACE/r2-outbox" - # A job with a write token (as is preflight-act). It never checks out the candidate: busbar-release at RELEASE_REF, - # and the hop's verdict artifact. The sticky PR comment and the branch health issue (`ci publish`); a - # hop whose runner was lost or timed out wrote no verdict, so `ci fallback` records the job and step - # conclusions the API keeps after the logs are purged, and the run gate's reason when it refused the - # run (STACK, wiring drift). A run the gate held neutral (run=0, preflight green) posts nothing: the - # queue or another run judges that head. - publish: - needs: [preflight, hop] - if: always() && needs.preflight.result != 'skipped' && (needs.preflight.result != 'success' || needs.preflight.outputs.run == '1') - runs-on: ubuntu-latest - timeout-minutes: 10 - # statuses: write: a merge group DENIED as its PR's own red (first in the group, or behind a green - # group) marks that PR's head red - # ("merge queue" status) beside its sticky verdict, so the watcher's fix agent starts at failure. - permissions: { contents: read, actions: read, checks: read, pull-requests: write, statuses: write } - env: { RELEASE_REF: "${{ needs.preflight.outputs.engine_ref }}", ENGINE_SHA256: "${{ needs.preflight.outputs.engine_sha256 }}" } - steps: - # THE ENGINE, downloaded, never compiled: CI's --no-default-features binary of RELEASE_REF from - # busbar-release's dev-bin release (dev-bin.yml), run only when its sha256 is ENGINE_SHA256. - # First the pin's shape: a full 40-hex commit and a 64-hex digest, or FAIL. - - name: Engine (busbar-release at RELEASE_REF, sha256-pinned) - env: { GH_TOKEN: "${{ secrets.TURNSTILE_DISPATCH_TOKEN }}" } - run: | - set -euo pipefail - [[ "$RELEASE_REF" =~ ^[0-9a-f]{40}$ ]] || { echo "::error title=ENGINE-PIN FAIL::vars.ENGINE_REF is unset or not a full 40-hex busbar-release commit (busbar-release engine pin )"; exit 1; } - [[ "$ENGINE_SHA256" =~ ^[0-9a-f]{64}$ ]] || { echo "::error title=ENGINE-PIN FAIL::vars.ENGINE_SHA256 is unset or not a 64-hex sha256 (busbar-release engine pin )"; exit 1; } - api=https://api.github.com/repos/GetBusbar/busbar-release/releases - name="busbar-release-ci-linux-x86_64-$RELEASE_REF" - id=$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" "$api/tags/dev-bin" | jq -r --arg n "$name" '.assets[] | select(.name == $n) | .id') - [ -n "$id" ] || { echo "::error::dev-bin has no $name"; exit 1; } - curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/octet-stream" -o "$RUNNER_TEMP/busbar-release" "$api/assets/$id" - echo "$ENGINE_SHA256 $RUNNER_TEMP/busbar-release" | sha256sum -c - - chmod +x "$RUNNER_TEMP/busbar-release" - - { uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1, with: { repository: GetBusbar/busbar-release, ref: "${{ env.RELEASE_REF }}", token: "${{ secrets.TURNSTILE_DISPATCH_TOKEN }}", path: busbar-release, persist-credentials: false } } # v7 - - { id: verdict, continue-on-error: true, uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c, with: { name: "verdict-${{ env.SHA }}", path: verdict } } # v8.0.1 - - if: steps.verdict.outcome == 'success' - working-directory: busbar-release - run: $RUNNER_TEMP/busbar-release ci publish - env: { GH_TOKEN: "${{ github.token }}", HEALTH_BRANCHES: none } - - if: needs.preflight.result != 'success' || needs.hop.result != 'success' - working-directory: busbar-release - run: $RUNNER_TEMP/busbar-release ci fallback - env: { GH_TOKEN: "${{ github.token }}", PR_NUMBER: "${{ github.event.pull_request.number }}", PREFLIGHT_RESULT: "${{ needs.preflight.result }}", PREFLIGHT_REASON: "${{ needs.preflight.outputs.reason }}", HOP_RESULT: "${{ needs.hop.result }}", HEALTH_BRANCHES: none } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8aea311471..5e4db11e43 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,7 +26,7 @@ This page is the short version. Where the two differ, Part 6 wins. both sides moved (theirs + ours − base; anything else stays a conflict): `git config merge.ledger-count.driver "cargo xtask ledger-merge %O %A %B"`. - Builds, tests, clippy, the gates and the oracle run in CI (ModernCI for `predev` PRs; `.github/workflows/promote.yml` for the promotion). + Builds, tests, clippy, the gates and the oracle run in CI: ModernCI, for `predev` PRs and for every promotion (`.ci/*.yaml`). 3. Push the lane and open a PR into `predev`. ModernCI (required check `ci`) judges and lands it. Then stop; CI is the proof and a red comes back as a task. 4. The PR body says what changed, why, and the ruling or issue it implements. No AI attribution. diff --git a/README.md b/README.md index fb5f0810aa..aba9701729 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,6 @@

-promote Coverage OpenSSF Scorecard OpenSSF Best Practices diff --git a/RELEASE.md b/RELEASE.md index d13efc6719..8680b85a0c 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,33 +1,21 @@ # Releasing Busbar -The branch ladder is **predev → dev → qa → main**. Pull requests into `predev` are judged and landed by -ModernCI (required check `ci`, configured by [`.ci/default.yaml`](.ci/default.yaml)). The promotion -from `predev` to `dev` runs [`.github/workflows/promote.yml`](.github/workflows/promote.yml). The release engine lives in the private `GetBusbar/busbar-release` repository -(pinned outside this repo by the org variables `ENGINE_REF` and `ENGINE_SHA256`, set with -`busbar-release engine pin `; `workflow-rules` R16 holds promote.yml to a 40-hex check and a -sha256 verify of the engine at run time); busbar-release's `crates/busbar-release-train/ladder.json` -says what entering each rung needs. - -| Rung entered (the PR's base) | What the hop runs | Consent | -|---|---|---| -| **`predev`** (from a `lane-*` branch) | every registered gate (`cargo xtask gate --list`, each by name) | none | -| **`dev`** | the gates, the oracle against the 1.5.5 golden, conformance | none | -| **`qa`** | the same, plus build + test, then the release binaries and the container | the owner (the org ruleset keeps `qa` locked) | -| **`main`** | the same, then publish | the owner (the org ruleset keeps `main` locked) | - -A hop has two jobs, both in `promote.yml`: - -* **`preflight`**, on a free runner: `cargo fmt --all -- --check`, `cargo metadata --locked`, and the - generated C header is current (`cargo xtask gate abi-header --write` leaves no diff). -* **`hop`**, on a Latchkey runner sized by the rung: the pinned engine plans the hop and admits the - candidate sha, and the verdict is uploaded as an artifact. When - the rung needs no consent and the repository variable `PROMOTE_BOARD` is `true`, the engine - fast-forwards the base branch to the exact sha it tested. Both promotions are **fast-forward - pushes**: linear history is what makes "main's HEAD *is* the qa commit" true. - -> The entry builds a plan names (`release-binaries`, `container`, `publish`) are not yet ported into -> the engine: `promote.yml` fails a hop that plans one with that message rather than skipping it. -> Nothing is built or published until they land in busbar-release. +The branch ladder is **predev → dev → qa → main**, and ModernCI judges and moves every rung from +this repository's branch files (OWNER 2026-10-12, "ModernCI only"; the ruling is +[`docs/design/BUSBAR-1.6.0.md`](docs/design/BUSBAR-1.6.0.md) Part 6, "BUSBAR-RELEASE RETIRED FROM +BUSBAR"). A branch file is named for the branch being moved and says what the move must pass. + +| Move | Branch file | What it runs | Consent | +|---|---|---|---| +| a `lane-*` branch into **`predev`** | [`.ci/default.yaml`](.ci/default.yaml) | preflight, every registered gate, clippy, compile, test, doctest, deletion matrix, `cargo deny`; no worse than predev (required check `ci`) | none | +| **`predev`** into **`dev`** | [`.ci/predev.yaml`](.ci/predev.yaml) | the gates, the oracle against the 1.5.5 golden, conformance, `scripts/verify-1.6.0-done.sh`, the Q128 audit | the owner's approval | +| **`dev`** into **`qa`** | `.ci/dev.yaml` | the same, plus build and test, the fuzz smoke and the signing gate, the soak, the release binaries and the container | the owner (the org ruleset keeps `qa` locked) | +| **`qa`** into **`main`** | `.ci/qa.yaml` | the same; on landing, the release is signed and published from the build that was judged, never rebuilt | the owner (the org ruleset keeps `main` locked) | + +`.ci/dev.yaml` and `.ci/qa.yaml` are not written yet: until they land, nothing is built or published +from `qa` or `main`. The busbar-release hop (`promote.yml`, the engine pin) is deleted. busbar-release +remains the home of the oracle (`busbar-oracle`, pinned by `testing/shadow-oracle/oracle-rust.pin`), +the soak and the plugin fleet generator. **The tag is an OUTPUT of a green release, never an input.** Nobody tags by hand; no workflow is triggered by a `v*` tag (`cargo xtask gate workflow-rules`, rule R1). Docker Hub tag immutability @@ -49,19 +37,17 @@ python3 .github/scripts/roll_changelog.py 1.6.0 CHANGELOG.md ``` That bumps `crates/busbar/Cargo.toml` + `Cargo.lock`, regenerates the committed OpenAPI schema, and -rolls `[Unreleased]` to `[1.6.0]` with today's date. It does **not** tag. Open the result as a pull -request against `dev` and let the hop run. +rolls `[Unreleased]` to `[1.6.0]` with today's date. It does **not** tag. Land it on `predev` like +any other change; it reaches `dev` with the next promotion. ## Moving a version up the ladder -Open a pull request whose base is the next rung (`dev` into `qa`, `qa` into `main`; a lane branch into `predev`). `promote.yml` -runs the hop on the PR; the run's check names are `preflight` and `hop`. A red hop is fixed -forward on the lower branch and the pull request re-run: every push re-runs the hop on the new sha. +ModernCI opens and judges each promotion (`modernci call moves` shows how each stands). A red move +is fixed forward on the lower branch: every push re-judges it on the new head. ## Branch protection this assumes -`qa` and `main` require the two contexts `preflight` and `hop` (plus the org ruleset's owner -consent). +`predev` requires ModernCI's check `ci`; `qa` and `main` take the owner's consent (the org ruleset). Branch protection lives in repository rulesets, not in a script (see `.github/required-status-checks.md`). Keep `enforce_admins: true`, `required_linear_history: true`, `allow_force_pushes: false` and diff --git a/docs/ci/actions-pins.md b/docs/ci/actions-pins.md index 93e848d582..c3f9f461dc 100644 --- a/docs/ci/actions-pins.md +++ b/docs/ci/actions-pins.md @@ -63,8 +63,8 @@ PR that preserves the trailing tag comment. ## Every action currently in the tree -Every `uses:` in `.github/workflows/` (`promote.yml` and the reusable `plugin-*.yml` workflows) is one -of the nine distinct `(action, ref)` pairs above, pinned to a commit SHA with its tag kept as a +Every `uses:` in `.github/workflows/` (the reusable `plugin-*.yml` workflows; `promote.yml` is deleted) +is one of the distinct `(action, ref)` pairs above, pinned to a commit SHA with its tag kept as a trailing comment. This table is the independent re-verification of that state. There is no `.github/actions` composite action beyond `cargo-home`, and `R14` is proven able to catch a floating tag inside one by `r14_reads_uses_out_of_a_composite_action_too_not_only_workflows` in diff --git a/docs/design/BUSBAR-1.6.0.md b/docs/design/BUSBAR-1.6.0.md index 5178d9ec6f..bf77b09c7a 100644 --- a/docs/design/BUSBAR-1.6.0.md +++ b/docs/design/BUSBAR-1.6.0.md @@ -2893,7 +2893,7 @@ shape guarantee each is a clean add, never a break. That is "fits-or-cleanly-ext > definition and the owner rulings that shape execution. ## DONE = DEV-GREEN (the definition every wave drives to) -DEV-GREEN is reached when ALL of the following hold and turnstile boards ADMIT: +DEV-GREEN is reached when ALL of the following hold ~~and turnstile boards ADMIT~~ and ModernCI's `predev` → `dev` move (`.ci/predev.yaml`) is green and lands (Part 6, "BUSBAR-RELEASE RETIRED FROM BUSBAR", OWNER 2026-10-12/13): 1. **Oracle byte-identical vs the 1.5.5 golden on every family** (money sacred, #9/#10). The ONLY accepted diffs are the entries of `testing/shadow-oracle/accepted-differences.json`, all 45 re-signed by the owner 2026-09-30. Everything else byte-green. @@ -2911,7 +2911,7 @@ DEV-GREEN is reached when ALL of the following hold and turnstile boards ADMIT: independent architecture audit on the DEV-GREEN candidate sha, graded against the Laws and THE DESIGN; it passes with no open BLOCK findings. The "two audits ≥9/10" requirement is struck (#60; the §11a baseline that score depended on never existed). -6. **turnstile BOARD = ADMIT → train ff `predev`→`dev` = DEV-GREEN** (#32/#67). +6. ~~**turnstile BOARD = ADMIT → train ff `predev`→`dev` = DEV-GREEN** (#32/#67).~~ SUPERSEDED 2026-10-13 by Part 6, "BUSBAR-RELEASE RETIRED FROM BUSBAR": **ModernCI's `predev` → `dev` move green on `.ci/predev.yaml` and landed = DEV-GREEN.** ## money-touching step shadow-oracle-gated; each wave loops `/codeaudit` to 2 clean passes #60) @@ -2984,8 +2984,8 @@ Agents: (a) land `integration/conformance-b`; conformance MUST-set green for all arm + green every gate in the DONE battery above (each RED-provable, red-before-green). ### W8 — BOARD → DEV-GREEN [#32/#67] -turnstile runs gates + oracle + conformance → BOARD verdict. On ADMIT, the train fast-forwards -`predev`→`dev`. **DEV-GREEN.** +~~turnstile runs gates + oracle + conformance → BOARD verdict. On ADMIT, the train fast-forwards +`predev`→`dev`.~~ ModernCI runs `.ci/predev.yaml` (gates, oracle, conformance, the done-oracle, the Q128 audit) on the `predev` → `dev` move; green, it lands. **DEV-GREEN.** --- @@ -3110,14 +3110,60 @@ Everything in Parts 1–5 describes the product. This Part describes the machine lives in **two repos**, and that split is the single most common source of "I can't find the autoscaler" confusion. +## BUSBAR-RELEASE RETIRED FROM BUSBAR; MODERNCI IS BUSBAR'S CI (OWNER 2026-10-12, 2026-10-13) + +> **OWNER 2026-10-12:** *"ModernCI only."* **OWNER 2026-10-13:** *"delete anything you don't need +> anymore, yes"* and *"Yes, we keep the busbar-release repo, slimmed down. ModernCI replaces its CI +> half; the busbar-specific tools stay."* This section governs Part 6. Every passage below that +> names the hop, the turnstile, the train, the conductor, the runner gateway, `promote.yml` or the +> engine pin is SUPERSEDED by it and is struck where it stands. + +**ModernCI judges and moves every rung, from busbar's own branch files** (ModernCI's answer, +2026-10-13: a branch file per moving branch, `promote: auto` opens the promotion, `on.landed` steps +run on each new target tip): + +| Move | Branch file | What it runs | +|---|---|---| +| `lane-*` → `predev` | `.ci/default.yaml` | preflight, gates, clippy, compile, test, doctest, deletion-matrix, deny; no worse than predev; ModernCI's merge train lands it (required check `ci`) | +| `predev` → `dev` | `.ci/predev.yaml` | gates, the oracle against the 1.5.5 golden, conformance, `verify-1.6.0-done.sh`, the Q128 audit; the owner's approval. **Its green landing is DEV-GREEN** (Part 5) | +| `dev` → `qa` | `.ci/dev.yaml` | the `dev` rung plus build and test, the fuzz smoke and the signing gate (today `qa/pipeline-checks.json`), the soak, the release binaries and the container; the owner's approval | +| `qa` → `main` | `.ci/qa.yaml` | the same battery; on landing, the `release` step signs and publishes the build it judged (no rebuild) and a `run` step does cosign and the container push, with secrets scoped to `on_landed` | + +**busbar-release stays, as the tools repo** (judged by ModernCI, not by its own Actions, once +ModernCI registers it). KEEP: `busbar-release-oracle` (the money judge `busbar-oracle`, pinned by +`testing/shadow-oracle/oracle-rust.pin`: one commit, one sha256 per architecture), with +`busbar-release-fetch` and `busbar-release-golden-scope`; the published `busbar-oracle-linux--` +assets every pin names, kept for as long as a busbar branch pins them; `oracle-rerecord` (the +add-only golden recorder); `busbar-release-soak`; `busbar-release-plugin` (the fleet generator: +`plugin new|sync|check`, `template/`) and its daily `plugin-drift` check, which stays on Actions +until ModernCI's scheduled checks are live (ModernCI PR #91); `busbar-release-github` (the plugin +generator and the soak call GitHub through it); and the `busbar-release` command line, cut to those +verbs. DELETE: the conductor and its state repo, the turnstile, the hop, the train and the ladder +(`ci` verbs, `ladder.json`, `preflight.json`), the runner gateway (`busbar-release-runners`, +`vars.RUNNERS` once the last workflow that reads it is gone), the EC2 backend and autoscaler, the +runner canary and probe, the `dev` dispatch workflow, the engine build on `dev-bin`, and the engine +pin (the org variables `ENGINE_REF`, `ENGINE_SHA256`, `ENGINE_JUDGE`, and the R2 cache keys the +hop alone read). In busbar: `.github/workflows/promote.yml` and the `workflow-rules` rows that +held it (R16 the engine pin, R18 its release-artifact wiring, R19 its R2 keys). + +**The oracle and the golden do not move.** The 1.5.5 golden stays in +`testing/shadow-oracle/golden/1.5.5`, read-only and add-only (above, "The 1.5.5 golden is +read-only"). The judge is built from busbar-release at the pinned commit; ModernCI runs only that +pinned install, checked against the pin's sha256 for its host, and a candidate never picks its own +judge. + +**The plugin fleet.** Each plugin repo is judged by ModernCI once it is registered there with its +own branch files; until then the reusable `plugin-ci.yml`, `plugin-consumer-verify.yml`, +`plugin-release.yml` and `plugin-repin.yml` stay, because the fleet's rendered callers name them. + ## The branch roster (#67) — four branches, and no others | Branch | Rule | CI | |---|---|---| -| `predev` | **permanent WIP.** Every in-flight session lands here and forks from here. | Pull requests only, landed through GitHub's merge queue; `promote.yml` `preflight` + `hop` are the required checks on both the pull request (the fast pre-check) and the merge group (the full plan, the proof of record), and the verdict is "no worse than base" (see "How work lands" below). | -| `dev` | **release-train-write-only.** "Next version's WIP, nowhere near done." | full `ci.yml` | -| `qa` | promotion target. **The one release build happens here** — the PGO build that ships, tested for real. | full CI + the real-media matrix | -| `main` | **a push here cuts a release** — tag, GitHub Release, container promotion, `latest` moved. Irreversible. **`main` never compiles:** it tags and publishes the artifacts, by digest, that `qa` built and verified. | release orchestration | +| `predev` | **permanent WIP.** Every in-flight session lands here and forks from here. | ~~Pull requests only, landed through GitHub's merge queue; `promote.yml` `preflight` + `hop` are the required checks on both the pull request (the fast pre-check) and the merge group (the full plan, the proof of record), and the verdict is "no worse than base" (see "How work lands" below).~~ SUPERSEDED 2026-10-13 by "BUSBAR-RELEASE RETIRED FROM BUSBAR" above: pull requests only, judged by ModernCI on `.ci/default.yaml` (required check `ci`), no worse than predev, landed by ModernCI's merge train. | +| `dev` | **release-train-write-only.** "Next version's WIP, nowhere near done." | ~~full `ci.yml`~~ SUPERSEDED 2026-10-13 by "BUSBAR-RELEASE RETIRED FROM BUSBAR" above: `.ci/predev.yaml` judges the move in | +| `qa` | promotion target. **The one release build happens here** — the PGO build that ships, tested for real. | ~~full CI + the real-media matrix~~ SUPERSEDED 2026-10-13 by "BUSBAR-RELEASE RETIRED FROM BUSBAR" above: `.ci/dev.yaml` judges the move in | +| `main` | **a push here cuts a release** — tag, GitHub Release, container promotion, `latest` moved. Irreversible. **`main` never compiles:** it tags and publishes the artifacts, by digest, that `qa` built and verified. | ~~release orchestration~~ SUPERSEDED 2026-10-13 by "BUSBAR-RELEASE RETIRED FROM BUSBAR" above: `.ci/qa.yaml` judges the move in; its `on.landed` steps publish | **Never push `dev`, `qa` or `main` by hand.** `predev` and `dev` take changes by pull request only (ruleset; OWNER R3 2026-10-01); `--force-with-lease` is permitted only on your own `lane-*` branch. **Branch protection is on `qa` only** — @@ -3138,21 +3184,21 @@ Nothing is rebuilt between them. **`GetBusbar/busbar`** — the product. `crates/`, `xtask/` (every gate), `scripts/` (including `verify-1.6.0-done.sh`, the definition of done), `conformance/`, `testing/shadow-oracle/`. -**`GetBusbar/busbar-release`** — the engine, nine crates. Being redesigned as one PR state machine (busbar-release docs/ARCHITECTURE.md). +~~**`GetBusbar/busbar-release`** — the engine, nine crates. Being redesigned as one PR state machine (busbar-release docs/ARCHITECTURE.md).~~ SUPERSEDED 2026-10-13 by "BUSBAR-RELEASE RETIRED FROM BUSBAR" above: busbar-release is the tools repo; the rows below for the turnstile, the train, the autoscaler and the CI half of the command line are deleted. | Crate | Job | |---|---| -| `busbar-release-turnstile` | The gate. Runs the batteries and returns a **BOARD** verdict — `ADMIT` or deny. Nothing reaches `dev` without it. | -| `busbar-release-train` | The mover. Fast-forwards `predev` → `dev` → `qa` → `main` in lockstep, refusing unless every precondition holds. | +| `busbar-release-turnstile` | ~~The gate. Runs the batteries and returns a **BOARD** verdict — `ADMIT` or deny. Nothing reaches `dev` without it.~~ DELETE | +| `busbar-release-train` | ~~The mover. Fast-forwards `predev` → `dev` → `qa` → `main` in lockstep, refusing unless every precondition holds.~~ DELETE | | `busbar-release-oracle` | The byte-identity oracle: this build vs the published 1.5.5 binary. | -| `busbar-release-autoscaler` | Runner capacity. Ships the **`busbar-fleet`** binary (`src/bin/busbar-fleet.rs`) — this is the "fleet" tool; there is no separate fleet crate. | +| `busbar-release-autoscaler` | ~~Runner capacity. Ships the **`busbar-fleet`** binary (`src/bin/busbar-fleet.rs`) — this is the "fleet" tool; there is no separate fleet crate.~~ DELETE | | `busbar-release-corpus` | The recorded request corpus the oracle replays. | -| `busbar-release-cli` | The one `busbar-release` command line: ship, dev, watch, status, plugin, and the CI internals under `ci`. | +| `busbar-release-cli` | ~~The one `busbar-release` command line: ship, dev, watch, status, plugin, and the CI internals under `ci`.~~ KEEP only the oracle, soak and plugin verbs | | `busbar-release-github` | The one door to GitHub: batched GraphQL reads, REST writes, shared rate-limit and retry policy. | | `busbar-release-plugin` | The plugin fleet generator: renders `template/` into every `busbar--` repo, with drift check, sync and repin. | | `busbar-release-soak` | The sustained mixed-traffic soak of a built binary against the oracle's mocks, judged row by row. | -Its workflows: `turnstile.yml`, `fleet-control.yml`, `oracle-rerecord.yml`, `docker-autoscaler.yml`, `ci.yml`. +~~Its workflows: `turnstile.yml`, `fleet-control.yml`, `oracle-rerecord.yml`, `docker-autoscaler.yml`, `ci.yml`.~~ SUPERSEDED 2026-10-13 by "BUSBAR-RELEASE RETIRED FROM BUSBAR" above. **The marketing site is observed, never depended on** (owner, 2026-09-06). Nothing in the release path triggers, `needs:` or waits on the site deploy; the site keeps its own trigger. Site checks @@ -3165,7 +3211,7 @@ fully torn down — zero instances, zero volumes, zero NAT gateways, zero elasti both cheaper and the money oracle's home (**#56 wins over #29** — the oracle rides Latchkey, it does not need a dedicated fleet box; `CI_RUNNER_ONDEMAND_FLOOR` is 0, not 2). -**Runner gateway (OWNER 2026-10-10, supersedes the 2026-09-28 capacity order):** every job asks for a logical size (small, medium, large, xlarge), never a vendor label. One table maps each size to a backend (latchkey, ec2 or github), so switching a backend is a one-line change and nothing else breaks. Today every size maps to Latchkey (account cap 80 concurrent jobs); EC2 burst is off. A daily canary job runs on every backend in the table, including the switched-off ones, so a dormant backend is proven before anyone switches to it. #78's spend breaker binds every paid backend. +~~**Runner gateway (OWNER 2026-10-10, supersedes the 2026-09-28 capacity order):** every job asks for a logical size (small, medium, large, xlarge), never a vendor label. One table maps each size to a backend (latchkey, ec2 or github), so switching a backend is a one-line change and nothing else breaks. Today every size maps to Latchkey (account cap 80 concurrent jobs); EC2 burst is off. A daily canary job runs on every backend in the table, including the switched-off ones, so a dormant backend is proven before anyone switches to it. #78's spend breaker binds every paid backend.~~ SUPERSEDED 2026-10-13 by "BUSBAR-RELEASE RETIRED FROM BUSBAR" above: ModernCI places each check on its backends. Agent compute is Latchkey only, through one shared job budget (`1.6.0-TODO.md`, THE RULES 6.1-6.3); the owner has ruled no extra build capacity (2026-09-29). EC2 is allowed for the performance phase (§11.9). @@ -3270,7 +3316,7 @@ now fixed, one was a misdiagnosis, and one dissolves on the first promotion.** C ## Size is not a CI check (owner 2026-10-02) -The hop (`promote` → `preflight` + `hop`) never denies a PR for size, at any rung. Line counts are +ModernCI ~~The hop (`promote` → `preflight` + `hop`)~~ never denies a PR for size, at any rung. Line counts are not measured in CI at all: the construction gate's `loc-ceilings:*` and `surface-ceiling:*` rows, its `ceiling-rose` / `ceiling-slack` rows, `ship-ready`'s two ceiling rows and the per-cell counts of `kind-isolation:matrix` are deleted, and with them every hand-kept size integer in @@ -3297,33 +3343,36 @@ in the default `pull_request` types. ## THE CONDUCTOR (OWNER 2026-10-11) — one deterministic driver; authors write code, nothing else -> **OWNER 2026-10-11.** *"We have the pieces, not a thing pushing things through the pieces, and +> **SUPERSEDED 2026-10-13 by "BUSBAR-RELEASE RETIRED FROM BUSBAR" above.** The conductor is deleted: ModernCI is the one program that judges, trains and +> moves busbar's branches. The text below is struck, kept for its history. + +> ~~**OWNER 2026-10-11.** *"We have the pieces, not a thing pushing things through the pieces, and > that can't be an agent."* *"I wake up one day, never use agents again, replace them with human Bob, > and the system still works. Bob codes, runs one command, and it does its thing and tells Bob (or > the agent) if they need to do something else."* *"Every second of waste is $ burnt — money and > time."* *"The workflow is the workflow"* (no run cap). Supersedes, where they disagree: "How work > lands" item 3 (GitHub's merge queue, OWNER 2026-10-02), "Ship and stop", and every rule that has -> an agent push, prove, retry, land, promote, or be spawned by the pipeline. +> an agent push, prove, retry, land, promote, or be spawned by the pipeline.~~ -**Why (measured 2026-10-10/11, busbar-release's 2-hour audit ledger, 9 windows):** Latchkey $442, +~~**Why (measured 2026-10-10/11, busbar-release's 2-hour audit ledger, 9 windows):** Latchkey $442, of which $254 (57%) bought nothing: 166 of 264 merge-group runs were cascades thrown away behind a red group, 211 of 419 PR runs were red (fixes nobody had run), and the proof that would have caught -them cheaply was refused by a run cap. +them cheaply was refused by a run cap.~~ -**The rule.** One program decides; authors only write code. An AUTHOR is whoever writes code — a +~~**The rule.** One program decides; authors only write code. An AUTHOR is whoever writes code — a person or an agent, interchangeably; the pipeline never knows or cares which, and never spawns one. The CONDUCTOR is `busbar-release conductor`, one always-on service (today on the build server, the M3 at `10.1.7.14`; it runs anywhere from one config directory). It is deterministic code: every decision is a rule over measured facts. It updates itself from each green `main` release of -busbar-release; nothing about running it is agent-driven. +busbar-release; nothing about running it is agent-driven.~~ -**The author's whole interface is one command:** `busbar-release submit` from a branch (it replaces +~~**The author's whole interface is one command:** `busbar-release submit` from a branch (it replaces `ship`). The conductor takes it from there and tells the author only when they must act — what is red, with the output, and the one thing to do next (fix and `submit` again; resolve a conflict; owner sign-off needed) — on the terminal, as the PR's one verdict comment, and in `busbar-release -status`. +status`.~~ -**The stations after `submit`.** The conductor owns every move. +~~**The stations after `submit`.** The conductor owns every move.~~ | Station | What happens | Junk it refuses | |---|---|---| @@ -3332,46 +3381,46 @@ status`. | TRAIN | The conductor batches the PRs whose PR check is green onto the predev tip (`train/`, one merge per PR, a conflicting PR goes back to its author) and runs the full plan ONCE on the batch. Green: predev takes the batch and every PR in it lands. Red: the failing rows are attributed to the PRs whose changes reach them and those go back to their authors; the rest re-batch (unattributable: split in half). Only the conductor moves predev. | Speculative per-PR merge groups and their cascades (impossible by construction); a batch rebuilt for PRs that did not change. | | PROMOTE | predev -> dev -> qa -> main on the same engine, each rung judged once, absolutely where the ladder says so. | A promotion run that cannot pass (a rung whose required suite is registered not-run). | -**METER** runs under every station: each proof and CI run records backend, minutes, dollars and -outcome in one ledger; the waste over any window is a `health` row that fails above 10%. +~~**METER** runs under every station: each proof and CI run records backend, minutes, dollars and +outcome in one ledger; the waste over any window is a `health` row that fails above 10%.~~ -**PROVERS are registered backends, like runners.** A prover is a drop-in driver beside the runner +~~**PROVERS are registered backends, like runners.** A prover is a drop-in driver beside the runner gateway's (`Driver`): its name, slots, price per minute, readiness, canary, its own idempotent `up`/`down` (the M3 prover's `up` builds its Linux VM from nothing), and `prove(commit, commands) -> verdict`. Which provers are live, in what order and with how many slots is one table (`PROVERS`), switched with one command after a canary, never by code: today the M3 (an arm64 Linux VM, warm cache), Latchkey as overflow; tomorrow any mix of M3, Latchkey, EC2. The conductor fills the first prover with a free slot. A proof is a filter, never the verdict: the verdict stays CI's, on Linux -x86_64, so a rare arm64/x86_64 difference costs at most one CI run. +x86_64, so a rare arm64/x86_64 difference costs at most one CI run.~~ -**The conductor IS the workflow; GitHub is one forge driver (OWNER 2026-10-11).** *"Conductor is the +~~**The conductor IS the workflow; GitHub is one forge driver (OWNER 2026-10-11).** *"Conductor is the master workflow: what could be a GitHub Actions workflow is the Rust conductor with pluggable infra."* *"We shouldn't be tightly coupled to GitHub; it is a code repo, and if I want to go to -Forgejo I could, mid-work."* So: -- The conductor executes every job of every station itself, on INFRA DRIVERS (the M3's per-job +Forgejo I could, mid-work."* So:~~ +- ~~The conductor executes every job of every station itself, on INFRA DRIVERS (the M3's per-job Linux VMs, the Latchkey CLI, EC2 boxes, GitHub-hosted runners), chosen by one owner command (`busbar-release infra use `, canaried, effective on the next job). Every job asks for a size, including the light orchestration jobs; nothing names a vendor. It stores logs and links them in the PR's one verdict comment, and posts ONE verdict status per PR head that branch - protection requires. -- The code host is a FORGE DRIVER: git push/fetch, pull requests, comments, the verdict status, branch + protection requires.~~ +- ~~The code host is a FORGE DRIVER: git push/fetch, pull requests, comments, the verdict status, branch protection. GitHub is the driver today; Forgejo is the same interface. Nothing in the pipeline depends on a forge-only feature: Actions workflows (replaced by the conductor), the merge queue - (replaced by the train), forge-only APIs. Switching forge is configuration and a mirror push. -- Platform of record: busbar's VERDICT jobs run on x86_64 Linux (the 1.5.5 golden was recorded + (replaced by the train), forge-only APIs. Switching forge is configuration and a mirror push.~~ +- ~~Platform of record: busbar's VERDICT jobs run on x86_64 Linux (the 1.5.5 golden was recorded there) unless a measured run proves arm64 byte-identical; the M3's arm64 VMs take proofs and - busbar-release's own CI. The M3 runs untrusted-safe: one throwaway VM per job. -- OPEN (owner, before step 7 of 1.6.0): release signing without a forge-specific identity (SLSA + busbar-release's own CI. The M3 runs untrusted-safe: one throwaway VM per job.~~ +- ~~OPEN (owner, before step 7 of 1.6.0): release signing without a forge-specific identity (SLSA provenance today assumes GitHub's OIDC token, which exists only inside Actions): our own signing - key, or the chosen forge's CI. + key, or the chosen forge's CI.~~ -**What the conductor replaces** (deleted as each station lands): GitHub's merge queue on predev, +~~**What the conductor replaces** (deleted as each station lands): GitHub's merge queue on predev, `jump`, the merge-group re-check and orphan machinery, the STALE/UPDATE rule, `ship` and `dev` as author tools, and every agent the pipeline spawns (fix, stuck, rebase, shared). The turnstile, the -hop, the ladder, the step cache, autopin and the EC2 runner backend stay as its internals. +hop, the ladder, the step cache, autopin and the EC2 runner backend stay as its internals.~~ -**Interim, until the train is live:** predev's merge queue builds at most 2 groups at once (OWNER -2026-10-11, was 10). +~~**Interim, until the train is live:** predev's merge queue builds at most 2 groups at once (OWNER +2026-10-11, was 10).~~ ## How work lands: the PR flow and the rules every contributor follows @@ -3385,10 +3434,11 @@ branches live on GitHub so CI runs the pipeline). 1. One branch per lane, `lane-`, cut from `origin/predev`. A branch that depends on another unmerged lane says so in its PR body. -2. Changes reach `predev` and `dev` **only by pull request**. The ruleset on both requires the two - jobs of `.github/workflows/promote.yml`, `preflight` and `hop`; no direct push, no force-push, no - deletion. `dev` moves only through the promote engine. -3. predev lands through GitHub's merge queue (OWNER 2026-10-02). The MERGE-GROUP run is the proof of +2. Changes reach `predev` and `dev` **only by pull request**. The ruleset on both requires ~~the two + jobs of `.github/workflows/promote.yml`, `preflight` and `hop`~~ ModernCI's check `ci`; no direct + push, no force-push, no deletion. `dev` moves only through ~~the promote engine~~ ModernCI's + `predev` → `dev` move (`.ci/predev.yaml`). +3. ~~predev lands through GitHub's merge queue (OWNER 2026-10-02). The MERGE-GROUP run is the proof of record: the full plan (every gate, every build/test step, the money oracle forced, conformance produced at judge time and judged absolutely per #68) on the queue's merge commit, judged "no worse than base" against the base the queue built it on; its report becomes the base of the commit it @@ -3398,19 +3448,19 @@ branches live on GitHub so CI runs the pipeline). PR merged onto the base tip, with no oracle and no conformance; on ONE runner when that is estimated within 20 minutes, else as the merge group's shard matrix ("Verification runs once per input", OWNER-LOCKED 2026-10-06). Neither may add a red. A local Latchkey - run is for iteration only. + run is for iteration only.~~ SUPERSEDED 2026-10-13 by "BUSBAR-RELEASE RETIRED FROM BUSBAR" above: ModernCI judges every pull request on its branch file and lands it with its own merge train; `dev`, `qa` and `main` move by ModernCI's promotion. 4. A `$` (money-touching) change is its own PR, never bundled with anything else. 5. On merge the branch is deleted (OWNER 2026-10-01, BRANCH LIFETIME: "as soon as merged into predev get rid of the branch"). -**Ship and stop** (ARCHITECT 2026-10-02, supersedes every polling instruction). When a slice is +~~**Ship and stop** (ARCHITECT 2026-10-02, supersedes every polling instruction). When a slice is ready, run one command from the lane's worktree: `busbar-release ship "" [--body ]` (the release CLI, `bin/busbar-release` in a clone of GetBusbar/busbar-release; OWNER 2026-10-02: every release tool lives there, and `cargo xtask ship` is gone). It refuses any branch that is not `lane-*`, merges `origin/predev` in, runs the local pre-flight, commits what the pre-flight changed (tracked files only), pushes, opens or reuses the PR into `predev` and turns on auto-merge. Then STOP: no polling of CI, PRs or Latchkey, no sleep loops. A red or conflicted PR comes back to the owning -lane as a new task; fix it, ship again, stop again. +lane as a new task; fix it, ship again, stop again.~~ SUPERSEDED 2026-10-13 by "BUSBAR-RELEASE RETIRED FROM BUSBAR" above: the author pushes the branch and opens the pull request; ModernCI does the rest (`modernci done`). **Never bless a golden or a ratchet.** A red is fixed forward with a root cause; nothing is waived and nothing is blessed (OWNER 2026-10-01, PREDEV IS THE CONVERGENCE BRANCH). Customer-visible diff --git a/xtask/src/gates/no_self_filed_issues.rs b/xtask/src/gates/no_self_filed_issues.rs index 01cb82559b..85264976d2 100644 --- a/xtask/src/gates/no_self_filed_issues.rs +++ b/xtask/src/gates/no_self_filed_issues.rs @@ -42,9 +42,12 @@ pub const ROW_PERM: &str = "no-self-filed-issues:no-issues-permission"; const WF_DIR: &str = ".github/workflows"; -/// A discovery step that finds nothing passes everything. Twenty-odd workflows today; the floor is -/// the shell's, a `const` with no environment override. -const DISCOVERY_FLOOR: usize = 5; +/// A discovery step that finds nothing passes everything. The floor is the workflow count, a `const` +/// with no environment override: the four reusable `plugin-*.yml` workflows the plugin repos call. +/// It was the shell's 5 while `promote.yml` (the busbar-release hop) stood; that file is deleted +/// (OWNER 2026-10-12, "ModernCI only": busbar's moves are judged from `.ci/*.yaml`), so 5 would +/// read the honest tree as broken discovery. +const DISCOVERY_FLOOR: usize = 4; const CLEAN: &str = "the scan cleared its floor and named nothing"; diff --git a/xtask/src/gates/package_selectors.rs b/xtask/src/gates/package_selectors.rs index 0a92532e34..1e3973830b 100644 --- a/xtask/src/gates/package_selectors.rs +++ b/xtask/src/gates/package_selectors.rs @@ -1317,7 +1317,7 @@ mod tests { #[test] fn the_reader_is_picked_by_the_files_own_shape_and_only_markdown_is_unread() { assert_eq!(reader_for("xtask/src/gates/x.rs"), Reader::Rust); - assert_eq!(reader_for(".github/workflows/promote.yml"), Reader::Yaml); + assert_eq!(reader_for(".github/workflows/plugin-ci.yml"), Reader::Yaml); assert_eq!(reader_for(".github/actions/a/action.yaml"), Reader::Yaml); assert_eq!(reader_for("qa/pipeline-checks.json"), Reader::Json); assert_eq!(reader_for("qa/DESIGN-BINDINGS.md"), Reader::Prose); diff --git a/xtask/src/gates/ship_ready.rs b/xtask/src/gates/ship_ready.rs index b4815f6579..6abb1a9aac 100644 --- a/xtask/src/gates/ship_ready.rs +++ b/xtask/src/gates/ship_ready.rs @@ -60,8 +60,8 @@ pub struct ShipReadyGate; /// `HEAD` and the empty string are folded in here for the same reason: `git rev-parse --abbrev-ref /// HEAD` answers `HEAD` on a detached checkout, which is git saying it cannot name a branch, not /// git naming a branch called HEAD. Reading it as a dev-line name is the same fail-open one step -/// removed. The pipeline sets `XTASK_SHIP_TARGET` explicitly (`promote.yml`, the PR's base branch), which -/// is the supported way to say what a detached checkout is a checkout OF. +/// removed. The pipeline sets `XTASK_SHIP_TARGET` explicitly (`.ci/*.yaml`, the move's target branch), +/// which is the supported way to say what a detached checkout is a checkout OF. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Posture { /// The target, named: `XTASK_SHIP_TARGET` when set and non-empty, else the branch git reports. diff --git a/xtask/src/gates/tests/workflow_rules_tests.rs b/xtask/src/gates/tests/workflow_rules_tests.rs index 7a4c011c5c..279daaa56d 100644 --- a/xtask/src/gates/tests/workflow_rules_tests.rs +++ b/xtask/src/gates/tests/workflow_rules_tests.rs @@ -175,210 +175,6 @@ fn every_rule_is_proven_able_to_go_red() { crate::gates::verify_report(&gate, &report).expect("every rule proven RED"); } -/// A two-job workflow: `slsa-build` (under `BUILDER_IF`) uploads the release artifact, and a shard -/// downloads it from its own run under `STEP_IF`, in the block spelling promote.yml uses. -const R18_FIXTURE: &str = "on: [pull_request, merge_group] -jobs: - slsa-build: - if: BUILDER_IF - steps: - - run: cargo build --release - - { uses: actions/upload-artifact@abc, with: { name: \"slsa-artifact-x\", path: slsa/ } } # v7 - hop-shard: - steps: - - if: STEP_IF - uses: actions/download-artifact@abc # v8 - with: { name: \"slsa-artifact-x\", path: slsa } - - run: ci hop -"; - -fn r18(builder_if: &str, step_if: &str) -> Vec { - let text = R18_FIXTURE - .replace("BUILDER_IF", builder_if) - .replace("STEP_IF", step_if); - release_artifact_findings("promote.yml", &text) - .into_iter() - .map(|f| { - assert_eq!(f.rule, "R18"); - f.message - }) - .collect() -} - -const NEEDS_ARTIFACT: &str = "needs.preflight.outputs.needs_release_artifact == 'true'"; - -#[test] -fn r18_a_merge_group_only_build_under_a_pull_request_download_is_red() { - // #740 exactly: the builder runs only in a merge group, the download whenever the rung reads it. - let got = r18( - "github.event_name == 'merge_group' && needs.preflight.outputs.run == '1'", - NEEDS_ARTIFACT, - ); - assert_eq!(got.len(), 1, "{got:?}"); - assert!( - got[0].contains("`hop-shard`") && got[0].contains("`slsa-build`"), - "{got:?}" - ); - // The same narrowing written as a base-branch test is the same red. - assert_eq!(r18("github.base_ref == 'qa'", NEEDS_ARTIFACT).len(), 1); -} - -#[test] -fn r18_a_builder_that_admits_the_download_condition_is_green() { - let fixed = format!( - "needs.preflight.outputs.run == '1' && (github.event_name == 'merge_group' || {NEEDS_ARTIFACT})" - ); - assert!(r18(&fixed, NEEDS_ARTIFACT).is_empty()); - // Wrapped in `${{ }}` the same. - assert!(r18(&format!("${{{{ {fixed} }}}}"), NEEDS_ARTIFACT).is_empty()); - // A builder narrowed only by the run gate, not by event, runs wherever the run runs. - assert!(r18("needs.preflight.outputs.run == '1'", NEEDS_ARTIFACT).is_empty()); -} - -#[test] -fn r18_a_download_that_waits_on_the_builders_success_is_green() { - // The soak's spelling: it downloads only after slsa-build succeeded, else builds its own. - assert!(r18( - "github.event_name == 'merge_group'", - "needs.slsa-build.result == 'success'" - ) - .is_empty()); -} - -#[test] -fn r18_an_unconditional_download_under_an_event_narrowed_builder_is_red() { - let text = R18_FIXTURE - .replace("BUILDER_IF", "github.event_name == 'merge_group'") - .replace(" - if: STEP_IF\n uses:", " - uses:"); - assert_eq!(release_artifact_findings("promote.yml", &text).len(), 1); -} - -#[test] -fn r18_a_download_with_no_builder_in_the_run_is_red_and_a_run_id_download_is_not_judged() { - let orphan = R18_FIXTURE - .replace("BUILDER_IF", "github.event_name == 'merge_group'") - .replace("actions/upload-artifact@abc", "actions/cache@abc") - .replace("STEP_IF", NEEDS_ARTIFACT); - let got = release_artifact_findings("promote.yml", &orphan); - assert_eq!(got.len(), 1); - assert!(got[0] - .message - .contains("no job of this workflow uploads it")); - // Another run's artifact (by run-id) is not this run's to build. - let other = orphan.replace( - "with: { name: \"slsa-artifact-x\", path: slsa }", - "with: { name: \"slsa-artifact-x\", path: slsa, run-id: \"${{ env.RUN }}\" }", - ); - assert!(release_artifact_findings("promote.yml", &other).is_empty()); -} - -#[test] -fn r18_a_flow_step_condition_is_read_up_to_the_next_key() { - assert_eq!( - step_condition(&[" - { if: always(), uses: actions/upload-artifact@abc }"]).as_deref(), - Some("always()") - ); - assert_eq!( - step_condition(&[" - { uses: a@b, if: \"${{ x == 'y' }}\", with: { n: 1 } }"]) - .as_deref(), - Some("x == 'y'") - ); - assert_eq!(step_condition(&[" - run: true"]), None); -} - -#[test] -fn r18_the_mutation_anchor_rewrites_only_the_named_jobs_if() { - let t = "jobs:\n a:\n if: one\n slsa-build:\n if: two\n needs: a\n"; - assert_eq!( - reif_job(t, "slsa-build", "three"), - "jobs:\n a:\n if: one\n slsa-build:\n if: three\n needs: a\n" - ); - assert_eq!(reif_job(t, "absent", "three"), t); -} - -/// R19: a shard that runs the hop on the candidate with the read-only keys, and the publisher that -/// runs no candidate code with the write keys. -const R19_FIXTURE: &str = "on: [pull_request, merge_group] -jobs: - hop-shard: - steps: - - { uses: actions/checkout@abc, with: { ref: x, path: busbar } } # v7 - - { uses: actions/checkout@abc, with: { repository: GetBusbar/busbar-release, ref: x, path: busbar-release } } # v7 - - run: ci hop - env: { R2_RO_KEY_ID: \"${{ secrets.R2_CACHE_RO_ACCESS_KEY_ID }}\", R2_RO_KEY_SECRET: \"${{ secrets.R2_CACHE_RO_SECRET_ACCESS_KEY }}\" } - cache-publish: - needs: [hop-shard] - steps: - - run: echo the engine - - run: ci cache-publish - env: { R2_KEY_ID: \"${{ secrets.R2_CACHE_ACCESS_KEY_ID }}\", R2_KEY_SECRET: \"${{ secrets.R2_CACHE_SECRET_ACCESS_KEY }}\" } -"; - -fn r19(text: &str) -> Vec { - r2_write_key_findings("promote.yml", text) - .into_iter() - .map(|f| { - assert_eq!(f.rule, "R19"); - f.message - }) - .collect() -} - -#[test] -fn r19_read_only_keys_on_the_candidate_and_write_keys_on_the_publisher_are_green() { - let got = r19(R19_FIXTURE); - assert!(got.is_empty(), "{got:?}"); -} - -#[test] -fn r19_a_write_key_on_a_job_that_runs_candidate_code_is_red() { - // The shard as it stood before busbar-release 069f032. - let shard = R19_FIXTURE.replacen( - "R2_RO_KEY_ID: \"${{ secrets.R2_CACHE_RO_ACCESS_KEY_ID }}\"", - "R2_KEY_ID: \"${{ secrets.R2_CACHE_ACCESS_KEY_ID }}\"", - 1, - ); - let got = r19(&shard); - assert_eq!(got.len(), 1, "{got:?}"); - assert!( - got[0].contains("`hop-shard`") && got[0].contains("secrets.R2_CACHE_ACCESS_KEY_ID"), - "{got:?}" - ); - // A job that only builds (cargo, no checkout step of its own) runs candidate code too. - let build = "jobs:\n b:\n steps:\n - run: cargo build\n env: { K: \"${{ secrets.R2_CACHE_SECRET_ACCESS_KEY }}\" }\n"; - assert_eq!(r19(build).len(), 1); -} - -#[test] -fn r19_a_workflow_level_write_key_reaches_every_candidate_job() { - let text = String::from("env:\n K: \"${{ secrets.R2_CACHE_ACCESS_KEY_ID }}\"\n") + R19_FIXTURE; - let got = r19(&text); - assert_eq!(got.len(), 1, "{got:?}"); - assert!(got[0].contains("`hop-shard`"), "{got:?}"); -} - -#[test] -fn r19_reads_a_secret_whole_and_another_repositorys_checkout_as_no_candidate() { - assert!(r2_write_secrets_in("${{ secrets.R2_CACHE_ACCESS_KEY_ID2 }}").is_empty()); - assert!(r2_write_secrets_in("${{ secrets.R2_CACHE_RO_ACCESS_KEY_ID }}").is_empty()); - assert!(r2_write_secrets_in("${{ secrets.R2_CACHE_RO_SECRET_ACCESS_KEY }}").is_empty()); - assert_eq!( - r2_write_secrets_in("${{ secrets.R2_CACHE_SECRET_ACCESS_KEY }}"), - vec!["secrets.R2_CACHE_SECRET_ACCESS_KEY"] - ); - let other = "jobs:\n p:\n steps:\n - { uses: actions/checkout@abc, with: { repository: GetBusbar/busbar-release, ref: x } } # v7\n - run: ci cache-publish\n env: { K: \"${{ secrets.R2_CACHE_ACCESS_KEY_ID }}\" }\n"; - assert!(r19(other).is_empty()); -} - -const ENGINE_VAR_JOB: &str = "env:\n SHA: x\njobs:\n pre:\n env: { RELEASE_REF: \"${{ vars.ENGINE_REF }}\", ENGINE_SHA256: \"${{ vars.ENGINE_SHA256 }}\" }\n steps:\n - name: Engine\n run: |\n [[ \"$RELEASE_REF\" =~ ^[0-9a-f]{40}$ ]] || { echo bad; exit 1; }\n name=\"busbar-release-ci-linux-x86_64-$RELEASE_REF\"\n echo \"$ENGINE_SHA256 bin\" | sha256sum -c -\n - { uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1, with: { repository: GetBusbar/busbar-release, ref: \"${{ env.RELEASE_REF }}\", path: busbar-release } } # v7\n"; - -fn r16(text: &str) -> Vec { - engine_pin_findings("w.yml", text) - .into_iter() - .map(|f| f.message) - .collect() -} - fn r17(text: &str) -> Vec { vendor_runs_on(text) .into_iter() @@ -386,78 +182,6 @@ fn r17(text: &str) -> Vec { .collect() } -#[test] -fn r16_accepts_the_variable_pin_with_its_shape_check_and_its_sha256() { - assert_eq!(r16(ENGINE_VAR_JOB), Vec::::new()); -} - -#[test] -fn r16_accepts_an_in_file_sha_pin() { - let t = "jobs:\n a:\n steps:\n - { uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1, with: { repository: GetBusbar/busbar-release, ref: 7ab21912d664d11c7df6cc98fe78eed6b0119177 } } # v7\n"; - assert_eq!(r16(t), Vec::::new()); - // and through a workflow-level env var holding the literal - let t = "env:\n RELEASE_REF: 7ab21912d664d11c7df6cc98fe78eed6b0119177\njobs:\n a:\n steps:\n - { uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1, with: { repository: GetBusbar/busbar-release, ref: \"${{ env.RELEASE_REF }}\" } } # v7\n"; - assert_eq!(r16(t), Vec::::new()); -} - -#[test] -fn r16_refuses_a_branch_or_a_tag_ref() { - // RED: the checkout at a branch. - let t = ENGINE_VAR_JOB.replace("ref: \"${{ env.RELEASE_REF }}\"", "ref: dev"); - assert!(r16(&t).iter().any(|m| m.contains("`dev`")), "{:?}", r16(&t)); - // RED: the variable is defined as a tag. - let t = ENGINE_VAR_JOB.replace( - "RELEASE_REF: \"${{ vars.ENGINE_REF }}\"", - "RELEASE_REF: v1.6.0", - ); - assert!( - r16(&t).iter().any(|m| m.contains("v1.6.0")), - "{:?}", - r16(&t) - ); - // RED: in-file, but a tag at the workflow level. - let t = "env:\n RELEASE_REF: refs/tags/v1\njobs:\n a:\n steps:\n - { uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1, with: { repository: GetBusbar/busbar-release, ref: \"${{ env.RELEASE_REF }}\" } } # v7\n"; - assert!(!r16(t).is_empty()); - // RED: no ref at all (the default branch). - let t = "jobs:\n a:\n steps:\n - { uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1, with: { repository: GetBusbar/busbar-release } } # v7\n"; - assert!(r16(t).iter().any(|m| m.contains("no `ref:`"))); -} - -#[test] -fn r16_refuses_a_variable_pin_without_its_shape_check_or_its_sha256() { - let t = ENGINE_VAR_JOB.replace("=~ ^[0-9a-f]{40}$", "!= \"\""); - assert!( - r16(&t).iter().any(|m| m.contains("default branch")), - "{:?}", - r16(&t) - ); - let t = ENGINE_VAR_JOB.replace("sha256sum -c -", "cat"); - let got = r16(&t); - assert_eq!( - got.len(), - 2, - "the download and the pin both name the missing check: {got:?}" - ); - // The shape check AFTER the checkout guards nothing. - let t = "jobs:\n pre:\n env: { RELEASE_REF: \"${{ vars.ENGINE_REF }}\" }\n steps:\n - { uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1, with: { repository: GetBusbar/busbar-release, ref: \"${{ env.RELEASE_REF }}\" } } # v7\n - run: |\n [[ \"$RELEASE_REF\" =~ ^[0-9a-f]{40}$ ]] || exit 1\n echo \"$ENGINE_SHA256 b\" | sha256sum -c -\n"; - assert!(r16(t).iter().any(|m| m.contains("default branch"))); -} - -#[test] -fn a_key_value_is_read_whole_from_a_flow_mapping() { - assert_eq!( - key_value("{ repository: o/r, ref: \"${{ env.X }}\", path: p }", "ref").as_deref(), - Some("${{ env.X }}") - ); - assert_eq!( - key_value("{ ref: dev, path: p }", "ref").as_deref(), - Some("dev") - ); - assert_eq!(key_value("{ prefref: dev }", "ref"), None); - assert_eq!(env_ref("${{ env.RELEASE_REF }}"), Some("RELEASE_REF")); - assert_eq!(env_ref("${{ vars.ENGINE_REF }}"), None); -} - #[test] fn a_vendor_runs_on_label_is_refused_in_every_spelling() { assert_eq!( diff --git a/xtask/src/gates/workflow_rules.rs b/xtask/src/gates/workflow_rules.rs index 261a98fd9e..30b80982fd 100644 --- a/xtask/src/gates/workflow_rules.rs +++ b/xtask/src/gates/workflow_rules.rs @@ -2,14 +2,16 @@ //! //! This gate was `release-order`: "nothing may be tagged until it has been verified from the //! consumer side", enforced over the SHAPE OF busbar's own release workflows (`release.yml`, -//! `release-stage.yml`, `docker.yml`, `verify-deploy.yml`). Those workflows are deleted: busbar has -//! one pipeline workflow, `promote.yml`, and the release engine lives in busbar-release. The rules -//! that were ABOUT those files (the staged-verification graph, the draft flag, the red-branch gate, -//! the first gate's run list) went with them, rows and code together. +//! `release-stage.yml`, `docker.yml`, `verify-deploy.yml`). Those workflows are deleted, and so is +//! `promote.yml`, the busbar-release hop (OWNER 2026-10-12, "ModernCI only"): busbar's moves are +//! judged by ModernCI from `.ci/*.yaml`, which this gate does not read. The rules that were ABOUT +//! those files (the staged-verification graph, the draft flag, the red-branch gate, the first gate's +//! run list, and promote.yml's engine pin, release-artifact wiring and R2 keys, R16/R18/R19) went +//! with them, rows and code together. //! //! Five rules were never about one file. They are bans over EVERY workflow and composite action -//! that remains (`promote.yml` and the reusable `plugin-*.yml` workflows the plugin repos call), -//! and each has a specific observed failure behind it: +//! that remains (the reusable `plugin-*.yml` workflows the plugin repos call), and each has a +//! specific observed failure behind it: //! //! * **R1 — no workflow is triggered by a version tag.** A tag is the OUTPUT of a verified release, //! never its trigger. @@ -18,22 +20,8 @@ //! * **R14 — every third-party action runs from a commit sha** with its tag as a trailing comment, //! because an action that can be force-moved under a name we already trust runs with our tokens. //! * **R15 — every attestation verify names the workflow that signed**, not just the repository. -//! * **R16 — the engine runs from a commit, never a branch or a tag.** Every checkout of -//! busbar-release names a full 40-hex sha, either in the file or through an env var the job -//! shape-checks as 40 hex before the checkout (the org variable `vars.ENGINE_REF`, OWNER -//! 2026-10-10: the engine pin lives outside this repo), and every job that downloads the engine -//! binary runs it only after `sha256sum -c` against `ENGINE_SHA256`. //! * **R17 — no job hard-codes a vendor runner label.** Every job asks the runner gateway for a //! logical size and `vars.RUNNERS` names the label, so a workflow never spells a vendor. -//! * **R18 — a step that downloads the release artifact from its own run runs only where the job -//! that builds it runs.** `slsa-build` was merge-group-only while the predev->dev pull request's -//! shards (its rung runs the oracle and conformance) downloaded `slsa-artifact-` from their -//! own run: the artifact could never exist there, and #740 failed every run at that download. -//! * **R19 — no job that runs candidate code holds the R2 cache write keys.** The hop judges the -//! candidate's build scripts, tests and xtask in-process, and every process of a job can read -//! another's environment: the shards held `secrets.R2_CACHE_ACCESS_KEY_ID` / -//! `secrets.R2_CACHE_SECRET_ACCESS_KEY` until busbar-release 069f032, which refuses to start a -//! hop that holds them. They belong to `cache-publish` alone, a job that runs no candidate code. //! //! **The parser stays deliberately small, and here that is a correctness argument rather than a //! dependency one.** The rules are assertions about what a human WROTE (a trailing `# tag` comment @@ -61,28 +49,12 @@ const WORKFLOWS: &str = ".github/workflows"; const ACTIONS_DIR: &str = ".github/actions"; /// The floor under the workflow discovery. A scan that finds nothing satisfies every ban, and a -/// tree with fewer workflows than this has lost its subject, not been cleaned: `promote.yml` plus -/// the four reusable `plugin-*.yml` workflows. -const MIN_WORKFLOWS: usize = 5; +/// tree with fewer workflows than this has lost its subject, not been cleaned: the four reusable +/// `plugin-*.yml` workflows. +const MIN_WORKFLOWS: usize = 4; /// The rule ids, which are also the ledger row ids. -const RULES: &[&str] = &["R1", "R11", "R14", "R15", "R16", "R17", "R18", "R19"]; - -/// The release artifact's name prefix: `promote.yml`'s `slsa-build` uploads `slsa-artifact-`, -/// and the steps that read the release binary download it by that name (R18). -const RELEASE_ARTIFACT: &str = "slsa-artifact-"; -/// R16's subject: the release engine's repository, and the name prefix of its CI binary on the -/// `dev-bin` release. -const ENGINE_REPO: &str = "GetBusbar/busbar-release"; -const ENGINE_ASSET: &str = "busbar-release-ci-"; -/// The shape test R16 requires of a variable engine pin, and the bytes check of the binary. -const SHA40_TEST: &str = "=~ ^[0-9a-f]{40}$"; -const SHA256_CHECK: &str = "sha256sum -c"; -const SHA256_VAR: &str = "ENGINE_SHA256"; -/// The pinned judge's binary on the `dev-bin` release, and the digest its job checks it against -/// (`sha256_linux_x86_64=` of testing/shadow-oracle/oracle-rust.pin). -const ORACLE_ASSET: &str = "busbar-oracle-linux-x86_64-"; -const ORACLE_SHA256_VAR: &str = "ORACLE_SHA256"; +const RULES: &[&str] = &["R1", "R11", "R14", "R15", "R17"]; // ------------------------------------------------------------------------------------------- // THE SMALL PARSER @@ -379,257 +351,6 @@ fn pushes_bare_release_branch(cmd: &str) -> bool { false } -/// A line that opens a YAML key at exactly `indent` spaces: not a comment, not a sequence item, -/// not deeper. -fn opens_key_at(line: &str, indent: usize) -> bool { - let b = line.as_bytes(); - b.len() > indent - && b[..indent].iter().all(|c| *c == b' ') - && !matches!(b[indent], b' ' | b'\t' | b'#' | b'-') -} - -/// An `if:` condition as written, without its `${{ }}` wrapper or the double quotes around the -/// whole of it (a single quote that ends the condition, `== 'true'`, is the condition's own). -fn bare_condition(raw: &str) -> String { - let c = raw.trim(); - let c = c - .strip_prefix('"') - .and_then(|r| r.strip_suffix('"')) - .unwrap_or(c) - .trim(); - let c = c.strip_prefix("${{").unwrap_or(c); - let c = c.strip_suffix("}}").unwrap_or(c); - c.trim().to_string() -} - -/// A job's own `if:` (the line opening `if:` at four spaces), bare. `None`: the job is -/// unconditional. -fn job_condition(lines: &[&str]) -> Option { - lines - .iter() - .find(|l| opens_key_at(l, 4) && l.trim_start().starts_with("if:")) - .map(|l| bare_condition(&l.trim_start()["if:".len()..])) -} - -/// A job's steps: each step's lines, its item line first. The items sit at the indent of the first -/// `- ` line under `steps:`; deeper lines (a `run: |` body, a block `with:`) belong to the step. -fn steps_of<'a>(lines: &[&'a str]) -> Vec> { - let mut out: Vec> = Vec::new(); - let Some(start) = lines - .iter() - .position(|l| opens_key_at(l, 4) && l.trim_end() == " steps:") - else { - return out; - }; - let mut indent: Option = None; - for line in &lines[start + 1..] { - if opens_key_at(line, 4) { - break; - } - let lead = line.len() - line.trim_start().len(); - let item = line.trim_start().starts_with("- "); - if item && indent.is_none() { - indent = Some(lead); - } - if item && Some(lead) == indent { - out.push(vec![*line]); - } else if let Some(step) = out.last_mut() { - step.push(*line); - } - } - out -} - -/// A step's own `if:`, bare, in either spelling: `- if: X` or an `if:` line of the step (block), -/// or `- { if: X, uses: ... }` (flow). `None`: the step is unconditional. -fn step_condition(step: &[&str]) -> Option { - let first = step.first()?.trim_start().strip_prefix("- ")?.trim_start(); - if let Some(flow) = first.strip_prefix('{') { - let flow = flow.trim_start(); - let rest = match flow.strip_prefix("if:") { - Some(r) => r, - None => &flow[flow.find(", if:")? + ", if:".len()..], - }; - let end = [ - ", uses:", - ", run:", - ", with:", - ", id:", - ", name:", - ", env:", - ", working-directory:", - ", continue-on-error:", - ] - .iter() - .filter_map(|k| rest.find(k)) - .min() - .unwrap_or(rest.len()); - return Some(bare_condition(&rest[..end])); - } - if let Some(rest) = first.strip_prefix("if:") { - return Some(bare_condition(rest)); - } - step[1..] - .iter() - .map(|l| l.trim_start()) - .find_map(|l| l.strip_prefix("if:")) - .map(bare_condition) -} - -/// R18 over one workflow's comment-stripped text. Every step that downloads the release artifact -/// from its OWN run (no `run-id:`) must run only where a job of this run uploads it. The step is -/// safe when it waits on the builder's success (`needs..result == 'success'`), or when -/// the builder's `if:` does not narrow it by event; a builder narrowed by event -/// (`github.event_name`, `github.base_ref`) must also admit the step's own condition, written into -/// its `if:` as one of the alternatives. No builder at all is the same red: the artifact can never -/// exist in that run. -fn release_artifact_findings(name: &str, text: &str) -> Vec { - fn carries(step: &[&str], verb: &str) -> bool { - let body = step.join("\n"); - body.contains(verb) && body.contains(RELEASE_ARTIFACT) - } - let jobs = jobs_of(text); - let builders: Vec<(&str, Option)> = jobs - .iter() - .filter(|(_, lines)| { - steps_of(lines) - .iter() - .any(|s| carries(s, "upload-artifact")) - }) - .map(|(id, lines)| (id.as_str(), job_condition(lines))) - .collect(); - let mut bad = Vec::new(); - for (job, lines) in &jobs { - for step in steps_of(lines) { - if !carries(&step, "download-artifact") || step.join("\n").contains("run-id:") { - continue; - } - let gate = step_condition(&step).unwrap_or_default(); - let when = if gate.is_empty() { - "always" - } else { - gate.as_str() - }; - if builders.is_empty() { - bad.push(Finding::new( - "R18", - format!( - "{name}: job `{job}` downloads the release artifact \ - (`{RELEASE_ARTIFACT}*`) from its own run, and no job of this workflow \ - uploads it, so the download can never find it. Build it in this run, or \ - download it by `run-id:` from the run that built it." - ), - )); - continue; - } - for (builder, cond) in &builders { - if gate.contains(&format!("needs.{builder}.result == 'success'")) { - continue; - } - let Some(cond) = cond else { continue }; - let by_event = - cond.contains("github.event_name") || cond.contains("github.base_ref"); - if by_event && (gate.is_empty() || !cond.contains(gate.as_str())) { - bad.push(Finding::new( - "R18", - format!( - "{name}: job `{job}` downloads the release artifact \ - (`{RELEASE_ARTIFACT}*`) from its own run when `{when}`, but \ - `{builder}`, the job that uploads it, runs only when `{cond}`. In \ - every run where the first holds and the second does not, the artifact \ - does not exist and the download fails (#740: a merge-group-only \ - slsa-build under the predev->dev pull request's shards). Admit \ - `{when}` in `{builder}`'s `if:`." - ), - )); - } - } - } - } - bad -} - -/// R19's subject: the R2 cache's WRITE keys, as this repository's secrets name them. The read-only -/// pair (`secrets.R2_CACHE_RO_*`) is another name, not a longer spelling of these. -const R2_WRITE_SECRETS: [&str; 2] = [ - "secrets.R2_CACHE_ACCESS_KEY_ID", - "secrets.R2_CACHE_SECRET_ACCESS_KEY", -]; - -/// The R2 write-key secrets `text` names, each as a whole name (`secrets.R2_CACHE_ACCESS_KEY_ID2` -/// is not `secrets.R2_CACHE_ACCESS_KEY_ID`). -fn r2_write_secrets_in(text: &str) -> Vec<&'static str> { - R2_WRITE_SECRETS - .into_iter() - .filter(|s| { - text.match_indices(*s).any(|(at, m)| { - !text[at + m.len()..] - .chars() - .next() - .is_some_and(|c| c.is_ascii_alphanumeric() || c == '_') - }) - }) - .collect() -} - -/// How job `lines` runs candidate code, when it does: a step checks out this repository (an -/// `actions/checkout` with no `repository:` is the candidate), runs cargo, or runs the hop (`ci -/// hop` judges the candidate in-process). A job that only downloads the engine, checks out another -/// repository or downloads artifacts runs none. -fn candidate_code(lines: &[&str]) -> Option<&'static str> { - for step in steps_of(lines) { - let body = step.join("\n"); - if body.contains("actions/checkout@") && key_value(&body, "repository").is_none() { - return Some("checks out the candidate"); - } - if body.contains("cargo ") { - return Some("runs cargo"); - } - if body.contains("ci hop") { - return Some("runs `ci hop` on the candidate"); - } - } - None -} - -/// R19 over one workflow's comment-stripped text. A job that runs candidate code names no R2 cache -/// write key: not in its own lines (job env, step env, `with:`), and not through the workflow's -/// top-level `env:`, which every job inherits. -fn r2_write_key_findings(name: &str, text: &str) -> Vec { - let top = r2_write_secrets_in(&top_level_block(text, "env")); - let mut bad = Vec::new(); - for (job, lines) in jobs_of(text) { - let Some(how) = candidate_code(&lines) else { - continue; - }; - let mut held = r2_write_secrets_in(&lines.join("\n")); - for s in &top { - if !held.contains(s) { - held.push(*s); - } - } - if held.is_empty() { - continue; - } - let named = held - .iter() - .map(|s| format!("`{s}`")) - .collect::>() - .join(", "); - bad.push(Finding::new( - "R19", - format!( - "{name}: job `{job}` {how} and names the R2 cache write key(s) {named}: every \ - candidate process the job starts can read them from its environment, and `ci hop` \ - refuses to start with them (INFRA). Give it the read-only `secrets.R2_CACHE_RO_*` \ - keys; the write keys belong to `cache-publish` alone, a job that runs no candidate \ - code." - ), - )); - } - bad -} - /// True when there is an actual composite-action manifest to scan, either on disk or planted by a /// selftest overlay. Mirrors `Ctx::list`'s own `overlay_adds_it` test: `.github/actions` need not /// exist in the real tree today (it does not) for a plant UNDER it to be a legitimate root, exactly @@ -681,215 +402,6 @@ fn action_manifests(cx: &Ctx) -> Result, String> { Ok(out) } -/// The jobs of a workflow (comments already stripped): each job's name and its lines, from its -/// header (a key at exactly two spaces under the top-level `jobs:`) to the next job or top-level key. -fn jobs_of(text: &str) -> Vec<(String, Vec<&str>)> { - let mut out: Vec<(String, Vec<&str>)> = Vec::new(); - let mut in_jobs = false; - for line in text.lines() { - if !line.starts_with(' ') && !line.trim().is_empty() { - in_jobs = line.trim_end() == "jobs:"; - continue; - } - if !in_jobs { - continue; - } - let b = line.as_bytes(); - if b.len() > 2 && b[0] == b' ' && b[1] == b' ' && b[2] != b' ' { - if let Some(name) = line.trim().strip_suffix(':') { - out.push((name.to_string(), Vec::new())); - continue; - } - } - if let Some((_, lines)) = out.last_mut() { - lines.push(line); - } - } - out -} - -/// The value of `key:` in `text` (a flow mapping or a block line), the first time `key` appears as -/// a key (not as the tail of a longer one): a quoted string up to its closing quote, an `${{ }}` -/// expression whole, or a bare scalar up to whitespace, `,` or `}`. -fn key_value(text: &str, key: &str) -> Option { - let needle = format!("{key}:"); - let mut from = 0usize; - while let Some(rel) = text[from..].find(&needle) { - let at = from + rel; - from = at + needle.len(); - let before = text[..at].chars().next_back(); - if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.') { - continue; - } - let rest = text[from..].trim_start_matches([' ', '\t']); - let v = if let Some(q) = rest.strip_prefix('"') { - q.split('"').next().unwrap_or("") - } else if let Some(q) = rest.strip_prefix('\'') { - q.split('\'').next().unwrap_or("") - } else if rest.starts_with("${{") { - rest.find("}}").map(|i| &rest[..i + 2]).unwrap_or(rest) - } else { - let end = rest - .find(|c: char| c.is_whitespace() || c == ',' || c == '}') - .unwrap_or(rest.len()); - &rest[..end] - }; - return Some(v.trim().to_string()); - } - None -} - -/// `${{ env.NAME }}` -> `NAME`. -fn env_ref(v: &str) -> Option<&str> { - let inner = v.strip_prefix("${{")?.strip_suffix("}}")?.trim(); - let name = inner.strip_prefix("env.")?; - (!name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')) - .then_some(name) -} - -/// The step span holding line `at` of `lines`: from the nearest `- ` item at or above it to the -/// next item at that indent (exclusive). -fn step_span(lines: &[&str], at: usize) -> String { - let indent = |l: &str| l.len() - l.trim_start().len(); - let start = (0..=at) - .rev() - .find(|&i| lines[i].trim_start().starts_with("- ")) - .unwrap_or(at); - let ind = indent(lines[start]); - let end = (start + 1..lines.len()) - .find(|&i| { - let l = lines[i]; - !l.trim().is_empty() && indent(l) <= ind - }) - .unwrap_or(lines.len()); - lines[start..end].join("\n") -} - -/// R16 over one workflow's text (comments stripped). -/// -/// THE ENGINE IS PINNED BY A COMMIT, AND ITS BYTES BY A DIGEST. promote.yml runs busbar-release's -/// engine with the jobs' tokens; a checkout at a branch or a tag runs whatever that name points at -/// today. The pin may be written in the file (a 40-hex sha) or held outside it (OWNER 2026-10-10: -/// the org variable `vars.ENGINE_REF`, so an engine update never waits in predev's merge queue). The -/// variable form is accepted only when the job proves at run time what the file can no longer show: -/// a step BEFORE the checkout tests the variable against 40 hex and exits on anything else (an -/// unset variable must FAIL, never fall back to the default branch an empty `ref:` checks out), and -/// the downloaded binary runs only after `sha256sum -c` against `ENGINE_SHA256`. -fn engine_pin_findings(name: &str, text: &str) -> Vec { - let mut bad = Vec::new(); - let top_env = top_level_block(text, "env"); - for (job, lines) in jobs_of(text) { - let job_text = lines.join("\n"); - let downloads = job_text.contains(ENGINE_ASSET); - let verifies = lines - .iter() - .any(|l| l.contains(SHA256_CHECK) && l.contains(SHA256_VAR)); - // THE JUDGE'S BYTES TOO: a job that downloads the pinned oracle runs it only after - // `sha256sum -c` against the pin's digest. - if job_text.contains(ORACLE_ASSET) - && !lines - .iter() - .any(|l| l.contains(SHA256_CHECK) && l.contains(ORACLE_SHA256_VAR)) - { - bad.push(Finding::new( - "R16", - format!( - "{name}: job `{job}` downloads the pinned oracle ({ORACLE_ASSET}*) without \ - `{SHA256_CHECK}` against ${ORACLE_SHA256_VAR} (oracle-rust.pin's \ - sha256_linux_x86_64): the judge would be whatever the release holds." - ), - )); - } - if downloads && !verifies { - bad.push(Finding::new( - "R16", - format!( - "{name}: job `{job}` downloads the engine binary ({ENGINE_ASSET}*) without \ - `{SHA256_CHECK}` against ${SHA256_VAR}: the bytes it runs with this job's \ - tokens are whatever the release holds under that name today." - ), - )); - } - for (i, line) in lines.iter().enumerate() { - if !line.contains(&format!("repository: {ENGINE_REPO}")) { - continue; - } - let span = step_span(&lines, i); - let Some(r) = key_value(&span, "ref").filter(|r| !r.is_empty()) else { - bad.push(Finding::new( - "R16", - format!( - "{name}: job `{job}` checks out {ENGINE_REPO} with no `ref:`, so it runs \ - the engine's default branch, not a pinned commit." - ), - )); - continue; - }; - if is_sha40(&r) { - continue; - } - let Some(var) = env_ref(&r) else { - bad.push(Finding::new( - "R16", - format!( - "{name}: job `{job}` checks out {ENGINE_REPO} at `{r}`, a branch or tag \ - the engine's owner can move, not a commit. Pin a 40-hex sha, or \ - `${{{{ env.RELEASE_REF }}}}` from vars.ENGINE_REF with its shape checked." - ), - )); - continue; - }; - // The variable's definition: the job's own env first, then the workflow's. - let def = key_value(&job_text, var).or_else(|| key_value(&top_env, var)); - match def.as_deref() { - Some(d) if is_sha40(d) => continue, - Some(d) if d.starts_with("${{") => {} - other => { - bad.push(Finding::new( - "R16", - format!( - "{name}: job `{job}` checks out {ENGINE_REPO} at ${var} = `{}`, which \ - is not a commit: a branch or tag the engine's owner can move. Pin a \ - 40-hex sha, or set {var} from vars.ENGINE_REF.", - other.unwrap_or("(undefined)") - ), - )); - continue; - } - } - let guarded = lines[..i].iter().any(|l| { - l.contains(SHA40_TEST) - && (l.contains(&format!("${var}\"")) - || l.contains(&format!("${{{var}}}")) - || l.contains(&format!("${var} "))) - && l.contains("exit") - }); - if !guarded { - bad.push(Finding::new( - "R16", - format!( - "{name}: job `{job}` checks out {ENGINE_REPO} at the variable ${var} with \ - no step before it that tests ${var} `{SHA40_TEST}` and exits: an unset or \ - malformed variable then checks out the engine's default branch instead \ - of failing." - ), - )); - } - if !verifies { - bad.push(Finding::new( - "R16", - format!( - "{name}: job `{job}` pins the engine through ${var} but never runs \ - `{SHA256_CHECK}` against ${SHA256_VAR}: a variable pin is only a pin \ - when the bytes are checked at run time." - ), - )); - } - } - } - bad -} - /// One finding, carrying the rule id that owns it so the row set is derived from the findings /// rather than restated beside them. #[derive(Debug, Clone)] @@ -1267,12 +779,6 @@ pub fn check(cx: &Ctx) -> Result, String> { } } - // R16. THE ENGINE RUNS FROM A COMMIT, AND ITS BYTES FROM A DIGEST ([`engine_pin_findings`]). - for name in &names { - let text = strip_comments(&read(name).unwrap_or_default()); - bad.extend(engine_pin_findings(name, &text)); - } - // R17. NO JOB HARD-CODES A VENDOR RUNNER LABEL. // // Every job asks the runner gateway for a logical size (small, medium, large, xlarge) and the @@ -1289,38 +795,12 @@ pub fn check(cx: &Ctx) -> Result, String> { format!( "{name}:{line}: job '{job}' runs on the vendor label '{label}'. A workflow \ never names a runner vendor: use `fromJSON(vars.RUNNERS)..label` \ - (size is small, medium, large or xlarge) or the preflight output \ - `needs.preflight.outputs.runner`." + (size is small, medium, large or xlarge)." ), )); } } - // R18. A STEP THAT DOWNLOADS THE RELEASE ARTIFACT FROM ITS OWN RUN RUNS ONLY WHERE IT IS BUILT. - // - // The shards of a rung that runs the oracle or conformance (`needs_release_artifact=true`: the - // predev->dev pull request and up) download `slsa-artifact-` from their own run, after - // `ci turnstile await-job` reads `slsa-build` green, and a SKIPPED job reads green there. With - // slsa-build gated to merge groups, every predev->dev run waited on nothing, failed that - // download on all four shards, and the fold called it INFRA: #740, every run. The rule holds - // the builder's `if:` to admit every condition a same-run download of its artifact runs under. - for name in &names { - let text = strip_comments(&read(name).unwrap_or_default()); - bad.extend(release_artifact_findings(name, &text)); - } - - // R19. NO JOB THAT RUNS CANDIDATE CODE HOLDS THE R2 CACHE WRITE KEYS ([`r2_write_key_findings`]). - // - // The shards and the one-runner hop passed `R2_KEY_ID` / `R2_KEY_SECRET` (the write keys) to - // `ci hop`, whose judge runs the candidate's build scripts, tests and xtask: any of them could - // read the keys and write the bucket every later hop reads its base reports and step PASSes - // from. busbar-release 069f032 refuses (INFRA) a hop that holds them; this rule keeps the keys - // from coming back to any job that runs candidate code, where the hop's refusal is not there. - for name in &names { - let text = strip_comments(&read(name).unwrap_or_default()); - bad.extend(r2_write_key_findings(name, &text)); - } - Ok(bad) } @@ -1335,10 +815,7 @@ fn rule_title(rule: &str) -> &'static str { "R11" => "no workflow pushes a commit to a release branch", "R14" => "every third-party action runs from a commit sha", "R15" => "every attestation verify names the workflow that signed", - "R16" => "the engine runs from a commit sha, its bytes from a sha256", "R17" => "no job hard-codes a vendor runner label", - "R18" => "a same-run download of the release artifact runs only where it is built", - "R19" => "no job that runs candidate code holds the R2 cache write keys", _ => "workflow rule", } } @@ -1526,7 +1003,7 @@ fn mutations() -> Vec { file: "zz-planted-verify.yml", rule: "R15", apply: |_| { - "jobs:\n v:\n steps:\n - run: |\n gh attestation verify ./x --repo GetBusbar/busbar --signer-workflow GetBusbar/busbar/.github/workflows/promote.yml\n" + "jobs:\n v:\n steps:\n - run: |\n gh attestation verify ./x --repo GetBusbar/busbar --signer-workflow GetBusbar/busbar/.github/workflows/plugin-ci.yml\n" .to_string() }, creates: true, @@ -1579,24 +1056,31 @@ fn mutations() -> Vec { creates: false, }, Mutation { - // THE SAME TWO REGRESSIONS IN THE FLOW SPELLING the pipeline workflow itself is - // written in: a step as one `{ uses: ..., with: ... }` mapping. + // THE SAME TWO REGRESSIONS IN THE FLOW SPELLING: a step written as one + // `{ uses: ..., with: ... }` mapping. No remaining workflow is written that way (the + // flow-spelled promote.yml is deleted), so the regression is a workflow that is. label: "R14 a flow-style action reverts from a sha to a force-movable tag", - file: "promote.yml", + file: "zz-planted-flow-pin.yml", rule: "R14", - apply: |t| retag_first_pin(t, false), - creates: false, + apply: |_| { + "jobs:\n a:\n steps:\n - { uses: actions/checkout@v7, with: { fetch-depth: 0 } }\n" + .to_string() + }, + creates: true, }, Mutation { label: "R14 a flow-style pin loses the trailing tag comment Dependabot reads", - file: "promote.yml", + file: "zz-planted-flow-pin.yml", rule: "R14", - apply: |t| retag_first_pin(t, true), - creates: false, + apply: |_| { + "jobs:\n a:\n steps:\n - { uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1, with: { fetch-depth: 0 } }\n" + .to_string() + }, + creates: true, }, Mutation { label: "R1 a v* tag trigger comes back", - file: "promote.yml", + file: "plugin-ci.yml", rule: "R1", apply: |t| replant_on(t, "on:\n push:\n tags:\n - \"v*\"\n"), creates: false, @@ -1605,75 +1089,17 @@ fn mutations() -> Vec { // THE SAME RULE, IN THE OTHER YAML SPELLING. The block form above was caught; the flow // form is ordinary YAML the runner treats identically. label: "R1 a v* tag trigger comes back as a FLOW sequence", - file: "promote.yml", + file: "plugin-ci.yml", rule: "R1", apply: |t| replant_on(t, "on:\n push:\n tags: [\"v*\"]\n"), creates: false, }, - Mutation { - // THE REGRESSION AS IT WOULD ACTUALLY ARRIVE: "just follow dev" -- the engine checked - // out at its branch, so every hop runs whatever dev holds at that minute. - label: "R16 the engine is checked out at a branch", - file: "promote.yml", - rule: "R16", - apply: |t| { - replace_once( - t, - "repository: GetBusbar/busbar-release, ref: \"${{ env.RELEASE_REF }}\"", - "repository: GetBusbar/busbar-release, ref: dev", - ) - }, - creates: false, - }, - Mutation { - // The same through the variable: the pin becomes a release tag. - label: "R16 the engine pin becomes a movable tag", - file: "promote.yml", - rule: "R16", - apply: |t| { - replace_once( - t, - "RELEASE_REF: \"${{ vars.ENGINE_REF }}\"", - "RELEASE_REF: v1.6.0", - ) - }, - creates: false, - }, - Mutation { - // The variable form loses its shape check: an unset variable is an empty `ref:`, and - // an empty `ref:` is the default branch, a silent fallback instead of a FAIL. - label: "R16 the variable pin's 40-hex shape check is dropped", - file: "promote.yml", - rule: "R16", - apply: |t| drop_first_line_with(t, SHA40_TEST), - creates: false, - }, - Mutation { - label: "R16 the engine binary runs without its sha256 check", - file: "promote.yml", - rule: "R16", - apply: |t| drop_first_line_with(t, SHA256_CHECK), - creates: false, - }, - Mutation { - label: "R16 the pinned oracle runs without its sha256 check", - file: "promote.yml", - rule: "R16", - apply: |t| { - replace_once( - t, - "echo \"$ORACLE_SHA256 $RUNNER_TEMP/busbar-oracle\" | sha256sum -c -\n", - "", - ) - }, - creates: false, - }, Mutation { // The refspec form a promote script documents, which the `HEAD:` test and the // no-refspec test both walked past. THE ANCHOR IS `steps:`, NOT A `uses:` LINE: a // structural feature of every job cannot be renamed by a pin bump or a formatting pass. label: "R11 a workflow pushes a refspec straight to main", - file: "promote.yml", + file: "plugin-ci.yml", rule: "R11", apply: |t| { replace_once( @@ -1684,109 +1110,9 @@ fn mutations() -> Vec { }, creates: false, }, - Mutation { - // THE REGRESSION AS IT ARRIVED (#740): the release build gated to merge groups while - // the predev->dev pull request's shards still download its artifact from their run. - label: "R18 slsa-build goes back to merge-group-only under a pull request's download", - file: "promote.yml", - rule: "R18", - apply: |t| { - // The shards no longer download the artifact (nothing they run reads it), so the - // pull request's same-run download is planted back into hop-shard, gated on the - // rung as it was, with slsa-build narrowed to merge groups. - let t = plant_step_in_job( - t, - "hop-shard", - " - if: needs.preflight.outputs.needs_release_artifact == 'true'\n uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1\n with: { name: \"slsa-artifact-${{ env.SHA }}\", path: slsa }\n", - ); - reif_job( - &t, - "slsa-build", - "github.event_name == 'merge_group' && needs.preflight.outputs.run == '1'", - ) - }, - creates: false, - }, - Mutation { - // THE REGRESSION AS IT STOOD BEFORE busbar-release 069f032: a hop shard's `ci hop` holds - // the R2 write key again, where every candidate process it starts can read it. - label: "R19 a hop shard's `ci hop` holds the R2 cache write key", - file: "promote.yml", - rule: "R19", - apply: |t| { - replace_once( - t, - r#"R2_RO_KEY_ID: "${{ secrets.R2_CACHE_RO_ACCESS_KEY_ID }}""#, - r#"R2_KEY_ID: "${{ secrets.R2_CACHE_ACCESS_KEY_ID }}""#, - ) - }, - creates: false, - }, ] } -/// Insert `step` (a whole ` - ...\n` step) as the first step of job `job`. A renamed job or one -/// without `steps:` edits nothing, which the selftest reports as unproven. -fn plant_step_in_job(t: &str, job: &str, step: &str) -> String { - let head = format!(" {job}:"); - let mut in_job = false; - let mut done = false; - let mut out = String::with_capacity(t.len() + step.len()); - for line in t.split_inclusive('\n') { - let bare = line.trim_end_matches('\n'); - if opens_key_at(bare, 2) { - in_job = bare.trim_end() == head; - } - out.push_str(line); - if !done && in_job && bare == " steps:" { - out.push_str(step); - done = true; - } - } - out -} - -/// Replace job `job`'s own `if:` line with `if: `. The anchor is the job id and its -/// four-space `if:` key, not today's condition, so rewording the condition cannot unprove R18; a -/// renamed or unconditional job edits nothing, which the selftest reports as unproven. -fn reif_job(t: &str, job: &str, cond: &str) -> String { - let head = format!(" {job}:"); - let mut in_job = false; - let mut done = false; - let mut out = String::with_capacity(t.len() + cond.len()); - for line in t.split_inclusive('\n') { - let bare = line.trim_end_matches('\n'); - if opens_key_at(bare, 2) { - in_job = bare.trim_end() == head; - } - if !done && in_job && opens_key_at(bare, 4) && bare.trim_start().starts_with("if:") { - out.push_str(" if: "); - out.push_str(cond); - out.push('\n'); - done = true; - } else { - out.push_str(line); - } - } - out -} - -/// `t` without the first line containing `needle` (unchanged when none does: the case then -/// reports its anchor moved). -fn drop_first_line_with(t: &str, needle: &str) -> String { - let mut done = false; - t.split_inclusive('\n') - .filter(|l| { - if !done && l.contains(needle) { - done = true; - false - } else { - true - } - }) - .collect() -} - /// The R14 mutations both target the FIRST fully-pinned third-party action in the file, found by /// shape rather than by a literal sha. A literal would rot the day that action is bumped, and a /// mutation that edits nothing proves nothing. diff --git a/xtask/tests/infra.rs b/xtask/tests/infra.rs index 61f9dee334..fbb2ea17da 100644 --- a/xtask/tests/infra.rs +++ b/xtask/tests/infra.rs @@ -722,35 +722,35 @@ jobs: } #[test] -fn the_real_pipeline_workflow_parses_and_its_gate_call_sites_are_discoverable() { - let text = std::fs::read_to_string(repo_root().join(".github/workflows/promote.yml")).unwrap(); - let wf = yaml_lite::parse_workflow(&text).expect("the real promote.yml parses"); +fn every_gate_the_modernci_branch_files_call_is_registered() { + // THE PIPELINE IS `.ci/*.yaml` (OWNER 2026-10-12, "ModernCI only"): ModernCI runs each branch + // file's checks on every move, and promote.yml, the busbar-release hop, is deleted. EVERY GATE A + // BRANCH FILE CALLS BY NAME IS ONE THE REGISTRY ANSWERS TO: a `cargo xtask gate ` is a + // check that exits 2 on every move, and the registry is what can say so here rather than in a + // red move. `.ci/default.yaml` calls `abi-header` by name, so the scan is never empty. + let dir = repo_root().join(".ci"); + let mut files: Vec<_> = std::fs::read_dir(&dir) + .unwrap() + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| p.extension().is_some_and(|x| x == "yaml" || x == "yml")) + .collect(); + files.sort(); + let mut called_any = false; + for path in &files { + let text = std::fs::read_to_string(path).unwrap(); + for name in yaml_lite::xtask_gate_invocations(&text) { + called_any = true; + assert!( + gates::find(&name).is_some(), + "{} calls `cargo xtask gate {name}`, which no registration answers to", + path.display() + ); + } + } assert!( - wf.job_names().len() >= 2, - "promote.yml carries the preflight and hop jobs: {:?}", - wf.job_names() + called_any, + "no `.ci/*.yaml` calls a gate by name: the scan read nothing ({files:?})" ); - assert!( - wf.job_names() - .iter() - .any(|j| wf.job(j).unwrap().steps.iter().any(|s| s.run.is_some())), - "every job's `run:` steps must be visible to the reader" - ); - - // EVERY GATE THE PIPELINE CALLS BY NAME IS ONE THE REGISTRY ANSWERS TO: a `cargo xtask gate - // ` is a step that exits 2 on every hop, and the registry is what can say so here rather - // than in a red run. - // promote.yml is a thin bootstrap: it runs busbar-release's `busbar-release ci hop` at - // RELEASE_REF, and the gate calls (the C header's `abi-header`, the registry's `--list`) live in - // that engine, not in this file. So the file names no gate today; the loop below keeps holding - // anything it ever does name to the registry. - let called = yaml_lite::xtask_gate_invocations(&text); - for name in &called { - assert!( - gates::find(name).is_some(), - "promote.yml calls `cargo xtask gate {name}`, which no registration answers to" - ); - } assert_eq!( yaml_lite::xtask_gate_invocations(" run: |\n cargo xtask gate plane-purity --selftest\n cargo xtask gate plane-purity\n"), vec!["plane-purity".to_string()] From 58f951084619d775ab7b0384e40b585bd3651be1 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:26:29 -0700 Subject: [PATCH 29/59] ci: test-gated check runs the feature-gated suites (row 164); default.yaml mirrors modernci-config (oracle, manual) --- .ci/default.yaml | 57 ++++++++++++++++++++++++++++++++++++++++++++++++ .ci/predev.yaml | 33 +++++++++++++++++++++++++++- 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/.ci/default.yaml b/.ci/default.yaml index c6b7702fc3..3f8759b234 100644 --- a/.ci/default.yaml +++ b/.ci/default.yaml @@ -3,6 +3,11 @@ to: predev on_green: merge deadline: 6h +# The PR battery, named explicitly so a check declared below but NOT listed here is on-demand only +# (`modernci call measure {repo, from, check}`), never run on every move. +on: + move: [preflight, gates, clippy, compile, test, doctest, deletion-matrix, deny] + manual: [oracle, test-gated] env: RUSTFLAGS: "-D warnings" CARGO_TERM_COLOR: never @@ -71,6 +76,37 @@ checks: # The selftests run the gates over the real tree, merge-base included. requires: [git-history] needs: [gates] + test-gated: + # The feature-gated suites the `test` check never builds (row 164): `test` runs default features + # only. A, the busbar/kernel/core-admin gates at once (test-harness, llm-on-driver, + # transport-webrtc, openapi-schema, timing), filtered to the gated binaries and modules so the + # default suites do not run twice; B, the plugin-pack CLI (pack); C, the loom models in release. + run: | + meta=$(cargo metadata --format-version 1 --locked) + cargo build --locked $(echo "$meta" | jq -r '.workspace_members as $m | .packages[] | select(.id as $i | $m | index($i)) | select(any(.targets[]; .kind | index("cdylib"))) | "-p " + .name') + plugin() { + man=$(echo "$meta" | jq -r --arg p "$1" 'first(.packages[] | select(.name == $p) | .manifest_path) // empty') + if [ -z "$man" ]; then [ "$3" = optional ] && return 0; echo "busbar resolves no $1"; return 1; fi + ws="$(dirname "$man")/Cargo.toml" + pkgs=$(cargo metadata --format-version 1 --no-deps --manifest-path "$ws" | jq -r '.packages[].name') + if ! printf '%s\n' $pkgs | grep -qx "$2"; then [ "$3" = optional ] && return 0; echo "$1 has no $2"; return 1; fi + if [ "$3" = dropped-in ] && cargo metadata --format-version 1 --no-deps --manifest-path "$ws" | jq -e --arg p "$2" '.packages[] | select(.name == $p) | .features | has("dropped-in")' >/dev/null; then + cargo build --locked --manifest-path "$ws" -p "$2" --features dropped-in --target-dir "$PWD/target" + else + cargo build --locked --manifest-path "$ws" -p "$2" --target-dir "$PWD/target" + fi + } + plugin busbar-transport-tcp-plugin busbar-transport-tcp-plugin dropped-in + plugin busbar-transport-http busbar-transport-http-plugin dropped-in + plugin busbar-secret-env busbar-secret-env-plugin optional + plugin busbar-plane-decisions busbar-plane-decisions-plugin optional + cargo build --workspace --locked + cargo nextest run --locked --no-fail-fast -p busbar -p busbar-kernel -p busbar-core-admin --features busbar/test-harness,busbar/llm-on-driver,busbar/transport-webrtc,busbar/openapi-schema,busbar-kernel/timing -E 'binary(mcp_stdio_serve) | binary(hook_parity_driver) | binary(admin_cross_plane) | binary(timing_smoke) | package(busbar-core-admin) | (package(busbar-kernel) & test(/timing::/)) | (package(busbar) & test(/(serve_tests|secret_source|connector|boot)::/))' + cargo nextest run --locked --no-fail-fast -p busbar-plugin-loader --features pack -E 'binary(/busbar-plugin-pack/)' + timeout 3000 cargo test --release --locked -p busbar-kernel --features loom-model txn_loom + baseline: target + requires: [git-history] + needs: [gates] doctest: run: cargo test --workspace --doc --locked --no-fail-fast scope: affected @@ -88,3 +124,24 @@ checks: baseline: target requires: [cargo-deny] needs: [preflight] + # ON-DEMAND ONLY (not in on.move): the predev->dev oracle, measurable on a PR head that changes the + # served LLM path (e.g. a plane default flip), so claim C3 is proven before landing, not only at the move. + oracle: + # busbar's own check tool, the pinned busbar-oracle (the backend's busbar-oracle tool sets + # BUSBAR_ORACLE_RUST_BIN): the judge is the one dev's oracle-rust.pin names, run only when its + # sha256 is that pin's for this host (a candidate never picks its own judge); the 1.5.5 golden is + # add-only from dev to this commit; then the candidate's release binary is recorded over the + # corpus and replayed against the committed 1.5.5 golden, strict: green is every cell byte-green + # or forgiven by an owner-signed accepted difference. + run: | + pin=$(git show "$MODERNCI_TO_SHA:testing/shadow-oracle/oracle-rust.pin") + want=$(printf '%s\n' "$pin" | sed -n "s/^sha256_linux_$(uname -m)=//p") + [[ "$want" =~ ^[0-9a-f]{64}$ ]] || { echo "ORACLE-PIN: oracle-rust.pin at $MODERNCI_TO_SHA pins no linux $(uname -m) judge"; exit 1; } + echo "$want $BUSBAR_ORACLE_RUST_BIN" | sha256sum -c - + "$BUSBAR_ORACLE_RUST_BIN" golden-guard --repo . --base "$MODERNCI_TO_SHA" --head HEAD + cargo build --locked --release -p busbar --bin busbar + ./bin/oracle fetch-golden + rm -rf target/oracle/recordings/candidate target/oracle/reports/dev + ./bin/oracle record --bin target/release/busbar --plane all --out target/oracle/recordings/candidate + ./bin/oracle replay --golden testing/shadow-oracle/golden/1.5.5 --candidate target/oracle/recordings/candidate --out target/oracle/reports/dev --strict + needs: [gates] diff --git a/.ci/predev.yaml b/.ci/predev.yaml index 966fa5be98..5e7063a3a6 100644 --- a/.ci/predev.yaml +++ b/.ci/predev.yaml @@ -14,7 +14,7 @@ env: CARGO_PROFILE_DEV_DEBUG: "0" CARGO_INCREMENTAL: "0" on: - move: [gates, oracle, conformance, verify-done, q128-audit] + move: [gates, oracle, conformance, verify-done, q128-audit, test-gated] checks: gates: # The rung inherits predev's boarded gates; here they are re-run on the commit, no worse than @@ -99,3 +99,34 @@ checks: # tree, or none, is a BLOCK. run: cargo xtask q128 audit needs: [gates] + test-gated: + # The feature-gated suites the `test` check never builds (row 164): `test` runs default features + # only. A, the busbar/kernel/core-admin gates at once (test-harness, llm-on-driver, + # transport-webrtc, openapi-schema, timing), filtered to the gated binaries and modules so the + # default suites do not run twice; B, the plugin-pack CLI (pack); C, the loom models in release. + run: | + meta=$(cargo metadata --format-version 1 --locked) + cargo build --locked $(echo "$meta" | jq -r '.workspace_members as $m | .packages[] | select(.id as $i | $m | index($i)) | select(any(.targets[]; .kind | index("cdylib"))) | "-p " + .name') + plugin() { + man=$(echo "$meta" | jq -r --arg p "$1" 'first(.packages[] | select(.name == $p) | .manifest_path) // empty') + if [ -z "$man" ]; then [ "$3" = optional ] && return 0; echo "busbar resolves no $1"; return 1; fi + ws="$(dirname "$man")/Cargo.toml" + pkgs=$(cargo metadata --format-version 1 --no-deps --manifest-path "$ws" | jq -r '.packages[].name') + if ! printf '%s\n' $pkgs | grep -qx "$2"; then [ "$3" = optional ] && return 0; echo "$1 has no $2"; return 1; fi + if [ "$3" = dropped-in ] && cargo metadata --format-version 1 --no-deps --manifest-path "$ws" | jq -e --arg p "$2" '.packages[] | select(.name == $p) | .features | has("dropped-in")' >/dev/null; then + cargo build --locked --manifest-path "$ws" -p "$2" --features dropped-in --target-dir "$PWD/target" + else + cargo build --locked --manifest-path "$ws" -p "$2" --target-dir "$PWD/target" + fi + } + plugin busbar-transport-tcp-plugin busbar-transport-tcp-plugin dropped-in + plugin busbar-transport-http busbar-transport-http-plugin dropped-in + plugin busbar-secret-env busbar-secret-env-plugin optional + plugin busbar-plane-decisions busbar-plane-decisions-plugin optional + cargo build --workspace --locked + cargo nextest run --locked --no-fail-fast -p busbar -p busbar-kernel -p busbar-core-admin --features busbar/test-harness,busbar/llm-on-driver,busbar/transport-webrtc,busbar/openapi-schema,busbar-kernel/timing -E 'binary(mcp_stdio_serve) | binary(hook_parity_driver) | binary(admin_cross_plane) | binary(timing_smoke) | package(busbar-core-admin) | (package(busbar-kernel) & test(/timing::/)) | (package(busbar) & test(/(serve_tests|secret_source|connector|boot)::/))' + cargo nextest run --locked --no-fail-fast -p busbar-plugin-loader --features pack -E 'binary(/busbar-plugin-pack/)' + timeout 3000 cargo test --release --locked -p busbar-kernel --features loom-model txn_loom + baseline: target + requires: [git-history] + needs: [gates] From 98026e8f957e011877cca406fe2e94d2684d691b Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:32:04 -0700 Subject: [PATCH 30/59] row 155: the door path serves budget `on_exhaust: downgrade` as 1.5.5 did ($) A budget block whose limit declared `on_exhaust: downgrade` re-admits the unit through its `downgrade_to` pool instead of refusing it (1.5.5 `ingress::admit_check`): the charge lands on the effective pool, the unit's walk routes over that pool's members, and its request count and span name it. The chain may cascade; the visited set bounds it, and every hop re-judges the caller's grant over the pool and its fallback chain, so a downgrade never routes a key into a pool it may not use. The kernel already computes `downgrade_to` (governance/state.rs); the door's `$` step acts on it. Before this, the door refused the unit `OverBudget`. RED-able (root/tests/door_steps.rs, ported from busbar-llm's ingress tests): an_exhausted_budget_re_pools_the_unit_through_its_downgrade_pool (red while the door refuses), a_downgrade_cycle_ends_in_the_plain_refusal_naming_the_last_pool_walked, a_downgrade_never_routes_a_key_into_a_pool_it_may_not_use. --- crates/busbar/src/root/door_steps.rs | 104 ++++++++-- crates/busbar/src/root/serve.rs | 16 +- crates/busbar/src/root/tests/door_steps.rs | 213 +++++++++++++++++++++ 3 files changed, 311 insertions(+), 22 deletions(-) diff --git a/crates/busbar/src/root/door_steps.rs b/crates/busbar/src/root/door_steps.rs index 3a7492cba9..0e3e107849 100644 --- a/crates/busbar/src/root/door_steps.rs +++ b/crates/busbar/src/root/door_steps.rs @@ -499,6 +499,9 @@ struct DoorUnit { recorded: Option, /// A `ROUTE_SCOPE` unit several entries reach: those entries, named in its refusal's words. reachable: Vec, + /// The pool a budget `on_exhaust: downgrade` re-admitted the unit through, where one did: its + /// charge landed there and its walk routes there. + downgraded: Option, } /// ONE UNIT'S KERNEL STEPS (see the module doc), lent to the plane driver for the unit's life. @@ -638,6 +641,46 @@ impl<'s> DoorSteps<'s> { self.lock().charged } + /// The pool a budget `on_exhaust: downgrade` re-admitted the unit through, where one did: the + /// pool its charge landed on, its walk routes over and its request is counted under (1.5.5's + /// `ingress::dispatch`: "dispatch through the pool the charge actually landed on"). + #[must_use] + pub fn downgraded(&self) -> Option { + self.lock().downgraded.clone() + } + + /// WHETHER A BUDGET DOWNGRADE MAY SEND THE UNIT TO `to` (1.5.5's `admit_check` guard, hop by + /// hop): `to` is a pool of this section not yet visited, the visited set is smaller than the + /// pool count, and the caller's grant reaches `to` and every pool its `on_exhausted` fallback + /// chain spills into (a downgrade never routes a key into a pool it may not use). + fn downgrade_reaches(&self, key: &VirtualKey, to: &str, visited: &[String]) -> bool { + let pools = self.pools.pools(); + if visited.iter().any(|v| v == to) + || visited.len() >= pools.len() + || !pools.contains_key(to) + { + return false; + } + let mut seen: Vec<&str> = Vec::new(); + let mut at = Some(to); + while let Some(pool) = at { + if seen.contains(&pool) { + break; + } + if !self.pools.granted( + self.facts.scope_kind.as_deref(), + Some(key), + ROUTE_POOL, + Some(pool.as_bytes()), + ) { + return false; + } + seen.push(pool); + at = self.pools.fallback(pool); + } + true + } + /// The pool a unit's charge and refund land on: the plane's own (#47 per-plane fees), the /// route's label qualified by the plane's key (a direct route's label is 1.5.5's empty one). fn charged_pool(&self, label: &str) -> String { @@ -668,26 +711,51 @@ impl<'s> DoorSteps<'s> { let u = self.lock(); (u.routed.clone(), u.expected.clone()) }; - let label = routed.as_ref().map_or("", |(label, _)| label.as_str()); - let pool = self.charged_pool(label); - let lanes: Vec = routed - .iter() - .flat_map(|(_, members)| members) - .map(|m| plane_lane(&self.facts.plane, m)) - .collect(); - let models: Vec<&str> = lanes.iter().map(String::as_str).collect(); let units = self.expected_units(&expected); - let grant = gov - .try_admit_estimated(&self.app.cost, key, &pool, self.arrived, &models, &units) - .map_err(|blocked| { - // The plane serving the `pools` map answers a blocked admission in the previous - // release's words (`busbar_kernel::ingress::limit_refusal`). - if self.facts.plane.is_empty() { - self.lock().refused.1 = - Some(busbar_kernel::ingress::limit_refusal("", &blocked).2); + let lanes_of = |routed: &Option| -> Vec { + routed + .iter() + .flat_map(|(_, members)| members) + .map(|m| plane_lane(&self.facts.plane, m)) + .collect() + }; + // BUDGET DOWNGRADE (1.5.5 `ingress::admit_check`): a budget block whose limit declared + // `on_exhaust: downgrade` re-admits through its `downgrade_to` pool instead of refusing. The + // chain may cascade; the visited set bounds it, and every hop re-judges the caller's grant. + // The charge lands on the EFFECTIVE pool and the unit's walk routes there. + let mut routed = routed; + let mut visited: Vec = Vec::new(); + let (grant, pool, lanes) = loop { + let label = routed.as_ref().map_or("", |(label, _)| label.as_str()); + let pool = self.charged_pool(label); + let lanes = lanes_of(&routed); + let models: Vec<&str> = lanes.iter().map(String::as_str).collect(); + match gov.try_admit_estimated(&self.app.cost, key, &pool, self.arrived, &models, &units) + { + Ok(grant) => break (grant, pool, lanes), + Err(LimitBlocked::Limit { + downgrade_to: Some(to), + .. + }) if self.downgrade_reaches(key, &to, &visited) => { + routed = self.pools.resolve(ROUTE_POOL, Some(to.as_bytes())); + visited.push(to); } - refusal_for(&blocked) - })?; + Err(blocked) => { + // The plane serving the `pools` map answers a blocked admission in the + // previous release's words (`busbar_kernel::ingress::limit_refusal`). + if self.facts.plane.is_empty() { + self.lock().refused.1 = + Some(busbar_kernel::ingress::limit_refusal("", &blocked).2); + } + return Err(refusal_for(&blocked)); + } + } + }; + if let Some(to) = visited.last() { + let mut u = self.lock(); + u.routed = routed; + u.downgraded = Some(to.clone()); + } money.open( ctx.key, UnitMoney { diff --git a/crates/busbar/src/root/serve.rs b/crates/busbar/src/root/serve.rs index 9c18e4da54..f8461f20b8 100644 --- a/crates/busbar/src/root/serve.rs +++ b/crates/busbar/src/root/serve.rs @@ -3156,8 +3156,13 @@ impl DataRoutes { .instrument(span.clone()) .await }; + // A budget downgrade re-pooled the unit: its span, like its request count below, names the + // pool the charge landed on (1.5.5 `ingress::dispatch`). + let downgraded = steps.downgraded(); if let Some(decoded) = units.decoded() { - if let Some(pool) = decoded.pool.as_ref() { + if let Some(pool) = downgraded.as_deref() { + span.record("pool", pool); + } else if let Some(pool) = decoded.pool.as_ref() { span.record("pool", String::from_utf8_lossy(pool).as_ref()); } if let Some(dialect) = served.dialects.get(decoded.dialect as usize) { @@ -3197,9 +3202,12 @@ impl DataRoutes { }, |d| d.dialect, ); - let pool = decoded - .and_then(|d| d.pool) - .map(|p| String::from_utf8_lossy(&p).into_owned()) + let pool = downgraded + .or_else(|| { + decoded + .and_then(|d| d.pool) + .map(|p| String::from_utf8_lossy(&p).into_owned()) + }) .filter(|name| { live.pools.pools().contains_key(name) || live.pools.entries().iter().any(|e| e == name) diff --git a/crates/busbar/src/root/tests/door_steps.rs b/crates/busbar/src/root/tests/door_steps.rs index 675d3401ad..213620c4c9 100644 --- a/crates/busbar/src/root/tests/door_steps.rs +++ b/crates/busbar/src/root/tests/door_steps.rs @@ -355,6 +355,219 @@ fn a_units_principal_is_recorded_from_authenticate_until_its_steps_drop() { assert!(records.get(8).is_none(), "a refused unit writes no record"); } +// ── budget downgrade (`on_exhaust: downgrade`, 1.5.5 `ingress::admit_check`) ────────────────────── + +/// A budget limit on `pool` that downgrades to `to` once `amount` minor units are spent in a day. +fn downgrading(pool: &str, amount: u64, to: &str) -> busbar_kernel::config::groups::LimitCfg { + use busbar_kernel::config::groups::{LimitCfg, LimitMetric, LimitWindow, OnExhaust}; + LimitCfg { + metric: LimitMetric::Budget, + amount, + per: Some(LimitWindow::Day), + scope: Some(busbar_contract::records::ScopeRef::pool(pool)), + on_exhaust: Some(OnExhaust::Downgrade), + downgrade_to: Some(busbar_contract::records::ScopeRef::pool(to)), + admission: None, + on_exhaustion: None, + } +} + +/// A governed generation whose group `team` holds `limits` at a flat 10-minor-unit fee per request, +/// and a key in that group granted `allowed_pools` (`None`: every pool). +fn downgrade_book( + limits: Vec, + allowed_pools: Option>, +) -> ( + std::sync::Arc, + std::sync::Arc, +) { + use busbar_kernel::test_support::engine_kit::{EngineTestKit as _, CORE_ENGINE_KIT}; + let gov = CORE_ENGINE_KIT + .governance(CORE_ENGINE_KIT.scratch_store(), None, None) + .expect("governance"); + let groups = std::collections::BTreeMap::from([( + "team".to_string(), + busbar_kernel::config::groups::GroupCfg { + parent: None, + enabled: true, + limits, + ..Default::default() + }, + )]); + let cost = CORE_ENGINE_KIT.cost_parts(None, 10, &groups); + let (key, _secret) = gov + .create_key( + busbar_kernel::governance::NewKeySpec { + name: "dev".to_string(), + allowed_pools, + group: Some("team".to_string()), + ..Default::default() + }, + 1_700_000_000, + ) + .expect("a key"); + let app = busbar_kernel::test_support::TestApp::new() + .governance_kit(gov) + .keys_chain() + .cost_kit(cost) + .build(); + (app, std::sync::Arc::new(key)) +} + +/// What one keyed unit of the plane serving the `pools` map did at its `$` door, routed to `pool`: +/// its admission (`Ok(())`, or the refusal's reason and words), the route its walk takes, and the +/// pool a downgrade sent it to. +struct Charged { + admitted: Result<(), (busbar_contract::caps::ReasonCode, Option)>, + routed: Option<(String, Vec)>, + downgraded: Option, +} + +fn charge_once( + app: &std::sync::Arc, + key: &std::sync::Arc, + pools: &DoorPools, + pool: &str, + unit: u64, +) -> Charged { + use busbar_kernel::plane_driver::{DriverSteps as _, EndPost, PlaneMoney}; + let facts = super::door_facts("", &["pool"], &[], &[], "door", Vec::new()); + let gov = std::sync::Arc::clone(app.governance.as_ref().expect("a governed generation")); + let post = std::sync::Arc::new(crate::root::plane_node::NodeEndPost::new( + std::sync::Arc::new(crate::root::plane_node::Node::new()), + )); + let money = PlaneMoney::new(gov, post as std::sync::Arc); + let steps = super::DoorSteps::new( + &facts, + pools, + std::sync::Arc::new(|_: &str| None), + std::sync::Arc::clone(app), + Some(&money), + super::DoorCaller { + principal: busbar_contract::caps::PrincipalId::new(key.id.as_str()), + key: Some(std::sync::Arc::clone(key)), + open: false, + arrived: busbar_kernel::store::now(), + records: None, + depth: 0, + credential: None, + }, + ); + steps.lock().routed = pools.resolve(ROUTE_POOL, Some(pool.as_bytes())); + let ctx = busbar_kernel::teller::UnitCtx { + key: busbar_contract::UnitKey::new(unit), + origin: busbar_contract::caps::OriginKind::Client, + session: None, + generation: busbar_kernel::registry::Generation::FIRST, + admin_listener: false, + kernel_verb_only: false, + }; + let admitted = steps + .charge(&ctx, key) + .map(|_| ()) + .map_err(|refusal| (refusal.reason(), steps.refused_words().1)); + Charged { + admitted, + routed: steps.routed(), + downgraded: steps.downgraded(), + } +} + +const DOWNGRADE_SECTION: &str = "models:\n big: {}\n small: {}\n tiny: {}\n\ + pools:\n frontier:\n members: [big]\n value:\n members: [small]\n\ + \x20 a:\n members: [big]\n b:\n members: [small]\n c:\n members: [tiny]"; + +/// BUDGET DOWNGRADE ON THE DOOR PATH (1.5.5 `test_budget_exhaustion_downgrades_pool`): exhausting +/// the `frontier` budget re-admits the unit through `value` instead of refusing it; the charge lands +/// there and the unit's walk routes over `value`'s members. RED: a door that refuses a budget block +/// carrying `downgrade_to` answers the third unit `OverBudget` and never re-pools it. +#[test] +fn an_exhausted_budget_re_pools_the_unit_through_its_downgrade_pool() { + let (app, key) = downgrade_book(vec![downgrading("frontier", 25, "value")], None); + let pools = pools(DOWNGRADE_SECTION); + // fee 10, cap 25: two frontier admissions spend 20; the third would reach 30. + for unit in 1..=2 { + let c = charge_once(&app, &key, &pools, "frontier", unit); + assert_eq!(c.admitted, Ok(()), "unit {unit} is under the frontier cap"); + assert_eq!(c.downgraded, None, "no downgrade while under the cap"); + assert_eq!(c.routed, owned("frontier", &["big"])); + } + let c = charge_once(&app, &key, &pools, "frontier", 3); + assert_eq!(c.admitted, Ok(()), "downgraded, not refused"); + assert_eq!( + c.downgraded.as_deref(), + Some("value"), + "the charge landed on the downgrade pool" + ); + assert_eq!( + c.routed, + owned("value", &["small"]), + "the walk follows the charge to the downgrade pool" + ); +} + +/// A DOWNGRADE CYCLE TERMINATES (1.5.5 `test_downgrade_cycle_terminates_via_the_revisit_guard`): +/// `a` -> `b` -> `c` -> `b` walks to `c` and stops at the revisit, refusing with `c`'s bucket named +/// in the previous release's words; a guard that refused one hop early names `b`. +#[test] +fn a_downgrade_cycle_ends_in_the_plain_refusal_naming_the_last_pool_walked() { + let (app, key) = downgrade_book( + vec![ + downgrading("a", 10, "b"), + downgrading("b", 0, "c"), + downgrading("c", 0, "b"), + ], + None, + ); + let pools = pools(DOWNGRADE_SECTION); + let first = charge_once(&app, &key, &pools, "a", 1); + assert_eq!(first.admitted, Ok(()), "under a's cap"); + assert_eq!(first.downgraded, None); + let second = charge_once(&app, &key, &pools, "a", 2); + let (reason, words) = second + .admitted + .expect_err("a cyclic downgrade chain ends in a refusal"); + assert_eq!(reason, busbar_contract::caps::ReasonCode::OverBudget); + let words = words.expect("the plane serving the `pools` map renders 1.5.5's words"); + assert!( + words.contains("pool 'c'"), + "the chain walked a -> b -> c before the revisit guard: {words}" + ); + assert_eq!( + second.downgraded, None, + "a refused unit is re-pooled nowhere" + ); +} + +/// A DOWNGRADE NEVER BYPASSES THE KEY'S POOL GRANT (1.5.5 `test_downgrade_never_bypasses_pool_acl`): +/// a key granted only `frontier` is refused the plain budget refusal, never routed into `value`. +#[test] +fn a_downgrade_never_routes_a_key_into_a_pool_it_may_not_use() { + let (app, key) = downgrade_book( + vec![downgrading("frontier", 25, "value")], + Some(vec!["frontier".to_string()]), + ); + let pools = pools(DOWNGRADE_SECTION); + for unit in 1..=2 { + assert_eq!( + charge_once(&app, &key, &pools, "frontier", unit).admitted, + Ok(()) + ); + } + let c = charge_once(&app, &key, &pools, "frontier", 3); + assert_eq!( + c.admitted.map_err(|(reason, _)| reason), + Err(busbar_contract::caps::ReasonCode::OverBudget), + "the plain budget refusal, not a leaked pool" + ); + assert_eq!(c.downgraded, None); + assert_eq!( + c.routed, + owned("frontier", &["big"]), + "the route is unchanged" + ); +} + // ── the members' routes (THE DESIGN §6 steps 2-3) ────────────────────────────────────────────── /// The decisions-shaped plane facts: one dialect `d` whose default style is `bearer`, one outbound From 8cf355970b590de561d1a3c3cbfcad21dc6dbbf4 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:54:05 -0700 Subject: [PATCH 31/59] root: one shutdown tail; stdio serve mode drains the journal lane and plane records before exit What: a new root module, root::shutdown, holds the one shutdown tail: the budget/metering write-behind flush, the plane record write-behind drain (root::serve::drain_records) and the journal lane drain under RECORD_DRAIN. main.rs calls it from both tails: the stdio (line-serve) branch before its std::process::exit, and the listener path after the graceful drain. The listener path runs the same steps in the same order with the same log lines. Why: audit busbar-src #1 (HIGH). The stdio branch flushed budgets and metering and exited without draining the plane record write-behind or the journal's lane to the store, though the book is opened in stdio mode too. A stdio child with a store-only journal (no data directory) lost the journal records and queued plane records of the last units served before stdin EOF: settled money and audit records the caller already had answers for never reached the store. BUSBAR-1.6.0.md THE DESIGN, section 7 and ruling H2 U10: a graceful shutdown drains the write-behind before the store closes. RED tests (crates/busbar/src/root/tests/shutdown.rs): - the_shutdown_tail_drains_the_journal_lane_before_it_returns: records wait in the lane while the store refuses; the store recovers mid-tail; the tail returns only with the lane empty and every record put. On predev root::shutdown does not exist and the stdio tail never touched the lane. - the_line_serve_tail_and_the_listener_tail_are_the_one_shutdown_tail: main.rs calls root::shutdown::tail inside the serve_lines branch before std::process::exit and again on the listener path, and spells no tail step itself. On predev the serve_lines branch spells flush_budgets inline and has no call to the tail. --- crates/busbar/src/main.rs | 48 ++------- crates/busbar/src/root/mod.rs | 1 + crates/busbar/src/root/shutdown.rs | 70 ++++++++++++ crates/busbar/src/root/tests/shutdown.rs | 130 +++++++++++++++++++++++ 4 files changed, 209 insertions(+), 40 deletions(-) create mode 100644 crates/busbar/src/root/shutdown.rs create mode 100644 crates/busbar/src/root/tests/shutdown.rs diff --git a/crates/busbar/src/main.rs b/crates/busbar/src/main.rs index 0385d60dac..16848f9c85 100644 --- a/crates/busbar/src/main.rs +++ b/crates/busbar/src/main.rs @@ -1332,7 +1332,8 @@ async fn run(data_workers: usize, late_services: std::sync::Arc>, + late: &LateServices, + journal: Option<&Arc>>, +) { + // BUDGET WRITE-BEHIND: one FINAL, SYNCHRONOUS flush after the graceful drain, so a graceful stop + // persists the freshest accrued spend/requests before the process exits. The background + // flusher's shutdown arm also flushes, but it is a fire-and-forget task that could lose the race + // with process exit; flushing inline here guarantees durability (this call blocks briefly under + // the budget lock, off any request path — the serving side has already stopped). + if let Some(gov) = governance { + let n = gov.flush_budgets(); + tracing::info!(flushed = n, "budget counters flushed on shutdown"); + // The flusher task's own shutdown arm also flushes metering, but it is fire-and-forget and + // can lose the race with process exit (same reason the budget flush above is inline here). + let m = gov.flush_metering(); + tracing::info!(flushed = m, "metering rows flushed on shutdown"); + } + // THE PLANE RECORD WRITE-BEHIND, drained before the store closes. + if !drain_records(late).await { + tracing::warn!("plane record writes were still queued at shutdown"); + } + // THE JOURNAL'S LANE TO THE STORE, drained the same way and under the same deadline. + if let Some(lane) = journal.and_then(|book| { + book.lock() + .unwrap_or_else(|p| p.into_inner()) + .lane() + .cloned() + }) { + let drained = tokio::task::spawn_blocking(move || lane.drain(RECORD_DRAIN)) + .await + .unwrap_or(false); + if !drained { + tracing::warn!("journal records were still queued for the store at shutdown"); + } + } +} + +#[cfg(test)] +#[path = "tests/shutdown.rs"] +mod tests; diff --git a/crates/busbar/src/root/tests/shutdown.rs b/crates/busbar/src/root/tests/shutdown.rs new file mode 100644 index 0000000000..09ad73023e --- /dev/null +++ b/crates/busbar/src/root/tests/shutdown.rs @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! THE ONE SHUTDOWN TAIL (audit busbar-src #1; ruling H2 U10; BUSBAR-1.6.0.md THE DESIGN, §7): it +//! returns only once the journal records queued for the configured store have reached it, and both +//! of the process's tails — the listener path's and the line-serve mode's — are this one function. + +use busbar_kernel_ledger::legacy::RecordingRows; +use busbar_kernel_wal::Record; + +use super::*; +use crate::root::durability::{build_on_store, DurabilityConfig, JournalLane}; +use crate::root::kernel::PinnedHistory; +use crate::root::store_double::RecordSlots; + +/// The node the test book is built as. +const NODE: u64 = 31; + +/// How long the store refuses before it recovers: long enough that a tail which does not wait has +/// returned well before, short enough to sit far inside [`RECORD_DRAIN`]. +const OUTAGE: std::time::Duration = std::time::Duration::from_millis(300); + +/// No dated history: the test book prices nothing. +fn no_history() -> Option { + None +} + +/// A book over `slots` with no data directory (the production boot's shape for a child process +/// with no disk), and a handle on the same lane its journal ships through. +fn store_book(slots: &RecordSlots) -> (Arc>, JournalLane) { + let lane = JournalLane::start(slots.calls(), "test-store").expect("the lane starts"); + let durability = build_on_store( + &DurabilityConfig { data_dir: None }, + NODE, + lane.clone(), + Box::new(RecordingRows::new()), + Box::new(no_history), + None, + ) + .expect("the store reads back"); + (Arc::new(Mutex::new(durability)), lane) +} + +/// **THE SHUTDOWN TAIL DRAINS THE JOURNAL'S LANE BEFORE IT RETURNS.** Journal records wait in the +/// lane while the store refuses; the store recovers a moment into the tail. The tail returns only +/// once the store took every one of them, so the process that exits right after it (the line-serve +/// mode's `std::process::exit`) leaves nothing queued. +/// +/// RED before the fix: there was no shared tail (`root::shutdown` did not exist), and the line-serve +/// mode's own tail flushed the budget and metering write-behind and exited, never touching the +/// lane: the records it held at stdin EOF were lost with the process. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn the_shutdown_tail_drains_the_journal_lane_before_it_returns() { + let slots = RecordSlots::new(); + let (book, lane) = store_book(&slots); + slots.refuse(true); + let before = slots.puts(); + let queued: Vec = (1..=4) + .map(|seq| Record::new(NODE + 1, seq, vec![7u8; 24])) + .collect(); + lane.shipper() + .ship(&queued) + .expect("a refusing store does not refuse the hand-off: the lane has room"); + assert!(lane.pending() > 0, "the records wait for the store"); + + let recovers = { + let slots = slots.clone(); + std::thread::spawn(move || { + std::thread::sleep(OUTAGE); + slots.refuse(false); + }) + }; + let late = LateServices::new(); + tail(None, &late, Some(&book)).await; + + assert_eq!( + lane.pending(), + 0, + "the tail returned with journal records still queued for the store" + ); + assert!( + slots.puts() >= before + queued.len(), + "every queued record reached the store before the tail returned" + ); + recovers.join().expect("the recovery thread"); +} + +/// **BOTH TAILS ARE THE ONE TAIL.** `main.rs` calls [`tail`] from the line-serve branch, before its +/// `std::process::exit`, and from the listener path's end; and it spells none of the tail's steps +/// itself, so neither path can again carry a shorter list than the other. +/// +/// RED before the fix: the line-serve branch flushed the budget and metering write-behind inline +/// and exited without draining the plane records or the journal lane; the listener path spelled +/// its own longer list. +#[test] +fn the_line_serve_tail_and_the_listener_tail_are_the_one_shutdown_tail() { + let main = include_str!("../../main.rs"); + let call = "root::shutdown::tail("; + assert_eq!( + main.matches(call).count(), + 2, + "main.rs runs the shutdown tail once per tail: the line-serve branch and the listener path" + ); + let served = main + .find("serve_lines(") + .expect("main.rs serves the line-serve mode"); + let exit = served + + main[served..] + .find("std::process::exit(code)") + .expect("the line-serve branch exits with the loop's code"); + assert!( + main[served..exit].contains(call), + "the line-serve branch exits without running the shutdown tail" + ); + assert!( + main[exit..].contains(call), + "the listener path ends without running the shutdown tail" + ); + for step in [ + "flush_budgets(", + "flush_metering(", + "drain_records(", + "RECORD_DRAIN", + ] { + assert!( + !main.contains(step), + "main.rs spells the shutdown step `{step}` itself instead of running the one tail" + ); + } +} From 79283d52924b34b98915754effdb31b26b244841 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:35:42 -0700 Subject: [PATCH 32/59] .ci/predev.yaml: the predev -> dev move is judged absolute and from predev's own pin (dev holds no xtask, ledgers, pin or golden) ModernCI's plan for the first move (coordinator 2026-10-13): no baseline: target (absolute, as the dev rung always was); the oracle pin is read at $MODERNCI_HEAD (7ab21912d664 on predev 5c99befcfa); golden-guard holds dev's golden add-only wherever dev has one; the base-relative gate rules take predev's last landed commit, never dev; the oracle runs only where the pinned judge is installed. --- .ci/predev.yaml | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/.ci/predev.yaml b/.ci/predev.yaml index 077c7b39c8..f68999418c 100644 --- a/.ci/predev.yaml +++ b/.ci/predev.yaml @@ -19,35 +19,42 @@ on: move: [gates, oracle, conformance, verify-done, q128-audit] checks: gates: - # The rung inherits predev's boarded gates; here they are re-run on the commit, no worse than - # dev, as the predev file runs them. + # The rung re-runs every registered gate on the commit, ABSOLUTE (no `baseline`: every row + # green, not "no worse than dev"; dev holds no xtask and no ledgers to be measured against). + # The base-relative rules (new edges, minted rows) take predev's own last landed commit as their + # base: every lane move already judged its delta against predev, so dev is never the base. run: | - export XTASK_CEILING_BASE="$MODERNCI_TO_SHA" XTASK_SHIP_TARGET="$MODERNCI_TO" + export XTASK_CEILING_BASE="$(git rev-parse "$MODERNCI_HEAD~1")" XTASK_SHIP_TARGET="$MODERNCI_TO" cargo xtask gate --all --format=tsv - baseline: target - rules: ["qa/*.toml"] - # The ceiling ratchet reads the merge-base (XTASK_CEILING_BASE), as in .ci/default.yaml: only a - # backend with the repo's history may run this check. + # The base-relative rules read history: only a backend with the repo's history runs this check. requires: [git-history] oracle: # busbar's own check tool, the pinned busbar-oracle (the backend's busbar-oracle tool sets - # BUSBAR_ORACLE_RUST_BIN): the judge is the one dev's oracle-rust.pin names, run only when its - # sha256 is that pin's for this host (a candidate never picks its own judge); the 1.5.5 golden is - # add-only from dev to this commit; then the candidate's release binary is recorded over the + # BUSBAR_ORACLE_RUST_BIN): the judge is the one predev's oracle-rust.pin names ($MODERNCI_HEAD, + # the commit that landed through every lane move's review; dev holds no pin), run only when its + # sha256 is that pin's for this host, and the backend's install is pinned by ModernCI, not by the + # candidate; the 1.5.5 golden is add-only from dev to this commit wherever dev holds it (dev + # holds none before the first move); then the candidate's release binary is recorded over the # corpus and replayed against the committed 1.5.5 golden, strict: green is every cell byte-green # or forgiven by an owner-signed accepted difference. run: | - pin=$(git show "$MODERNCI_TO_SHA:testing/shadow-oracle/oracle-rust.pin") + pin=$(git show "$MODERNCI_HEAD:testing/shadow-oracle/oracle-rust.pin") want=$(printf '%s\n' "$pin" | sed -n "s/^sha256_linux_$(uname -m)=//p") - [[ "$want" =~ ^[0-9a-f]{64}$ ]] || { echo "ORACLE-PIN: oracle-rust.pin at $MODERNCI_TO_SHA pins no linux $(uname -m) judge"; exit 1; } + [[ "$want" =~ ^[0-9a-f]{64}$ ]] || { echo "ORACLE-PIN: oracle-rust.pin at $MODERNCI_HEAD pins no linux $(uname -m) judge"; exit 1; } echo "$want $BUSBAR_ORACLE_RUST_BIN" | sha256sum -c - - "$BUSBAR_ORACLE_RUST_BIN" golden-guard --repo . --base "$MODERNCI_TO_SHA" --head HEAD + if git cat-file -e "$MODERNCI_TO_SHA:testing/shadow-oracle/golden/1.5.5" 2>/dev/null; then + "$BUSBAR_ORACLE_RUST_BIN" golden-guard --repo . --base "$MODERNCI_TO_SHA" --head HEAD + else + echo "golden-guard: dev ($MODERNCI_TO_SHA) holds no golden/1.5.5; nothing to hold add-only" + fi cargo build --locked --release -p busbar --bin busbar ./bin/oracle fetch-golden rm -rf target/oracle/recordings/candidate target/oracle/reports/dev ./bin/oracle record --bin target/release/busbar --plane all --out target/oracle/recordings/candidate ./bin/oracle replay --golden testing/shadow-oracle/golden/1.5.5 --candidate target/oracle/recordings/candidate --out target/oracle/reports/dev --strict needs: [gates] + # Only the backend that installs the pinned judge (the prover: aarch64) runs this check. + requires: [busbar-oracle] conformance: # Every registered suite recorded on this commit (the llm rigs judge the oracle's candidate # recording), then every MUST a fresh pass for it. A not-run suite denies. From 18c234c1963666899c8aa749d4db0ee3027c5fa1 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:53:00 -0700 Subject: [PATCH 33/59] llm codec $: image answers' token counts are recorded, not billed, as in 1.5.5 The OpenAI (gpt-image-1) and Gemini image readers parsed the answer's token usage into Billing::Tokens, and both image ops now tap the same-dialect body, so a key was charged the model entry's token rates, and its tokens: caps counted, where 1.5.5 billed no image token (v1.5.5 crates/busbar/src/handlers/openai.rs:731-758 and handlers/gemini.rs:446-472 set neither usage nor cost_basis; no image op tapped usage). An unreadable count also refused an answer 1.5.5 served. ImageResp::billing now records the reported tokens as residual units (the settle's usage.residual audit row) on a zero token line, and the readers read them leniently. The per-image `images` class (LEDGER-100, CHANGELOG Upgrade) is unchanged. --- .../src/codec/gemini/handler.rs | 50 ++++++++----------- .../codec/gemini/tests/float_usage_tests.rs | 33 +++++++----- .../src/codec/gemini/tests/handler_tests.rs | 23 ++++++--- crates/busbar-plane-llm/src/codec/ir/image.rs | 38 ++++++++++++-- .../src/codec/ir/tests/image_tests.rs | 10 +++- .../src/codec/openai_chat/handler.rs | 42 +++++++--------- .../openai_chat/tests/float_usage_tests.rs | 48 +++++++++++------- .../codec/openai_chat/tests/handler_tests.rs | 34 ++++++++++--- 8 files changed, 177 insertions(+), 101 deletions(-) diff --git a/crates/busbar-plane-llm/src/codec/gemini/handler.rs b/crates/busbar-plane-llm/src/codec/gemini/handler.rs index d917387928..a419837c18 100644 --- a/crates/busbar-plane-llm/src/codec/gemini/handler.rs +++ b/crates/busbar-plane-llm/src/codec/gemini/handler.rs @@ -348,11 +348,10 @@ leaf_op! { GeminiImage: COUNT_LABEL, ImageReqHandle = read_image_request, ImageRespHandle = read_image_response; - // Buffer the same-protocol non-stream 2xx body so the default `extract_usage` can read the - // response's usage and bill the virtual key's TPM/spend. Token-metered image models expose a - // `usageMetadata` object (billed as tokens); Imagen `:predict` per-image responses have none, so - // the tap bills 0 tokens and the request still meters once via the per-image cost basis on the - // cross-protocol seam — mirrors the OpenAI image cell. + // Buffer the same-protocol non-stream 2xx body so the default `extract_usage` runs this op's + // reader: a token-reporting image model's `usageMetadata` is RECORDED (never billed, as in + // 1.5.5; see `ImageResp::billing`), and an Imagen `:predict` per-image answer bills its + // `images` count. fn taps_usage(&self) -> bool { true } @@ -838,30 +837,23 @@ pub fn read_image_response(wire: &[u8]) -> Result Result { - // BILLED COUNTS: absent is zero, UNREADABLE IS A REFUSAL (#81/#42) — see - // `usage_count::billed_count`. - Ok(busbar_contract::billing::TokenUsage { - input: crate::codec::usage_count::billed_count(u, FIELD_PROMPT_TOKEN_COUNT) - .map_err(|e| CodecError::Malformed(e.to_string()))?, - output: crate::codec::usage_count::billed_count( - u, - FIELD_CANDIDATES_TOKEN_COUNT, - ) - .map_err(|e| CodecError::Malformed(e.to_string()))?, - ..Default::default() - }) - }, - ) - .transpose()?; + // Token-reporting image models (gemini `generateContent`-shaped) carry a `usageMetadata` token + // object; Imagen `:predict` per-image responses carry none. The tokens are RECORDED, never + // billed (`ImageResp::billing`, 1.5.5 parity), so they are read leniently: an unreadable count + // is left out and never refuses an answer 1.5.5 served (v1.5.5 + // `crates/busbar/src/handlers/gemini.rs:446-472` read no `usageMetadata` at all). + let usage = v.get(FIELD_USAGE_METADATA).map(|u| { + let count = |k: &str| { + u.get(k) + .and_then(crate::codec::usage_count::read_count_u64) + .unwrap_or(0) + }; + busbar_contract::billing::TokenUsage { + input: count(FIELD_PROMPT_TOKEN_COUNT), + output: count(FIELD_CANDIDATES_TOKEN_COUNT), + ..Default::default() + } + }); // Per-image (Imagen `:predict`) responses carry no `usageMetadata` — record the per-image cost // basis so the op bills as `Billing::Images` rather than nothing. The billable COUNT is // recoverable from the response itself (one image per `predictions` entry). Size/quality tiers diff --git a/crates/busbar-plane-llm/src/codec/gemini/tests/float_usage_tests.rs b/crates/busbar-plane-llm/src/codec/gemini/tests/float_usage_tests.rs index 336c3eb3eb..e326139f22 100644 --- a/crates/busbar-plane-llm/src/codec/gemini/tests/float_usage_tests.rs +++ b/crates/busbar-plane-llm/src/codec/gemini/tests/float_usage_tests.rs @@ -170,27 +170,36 @@ fn additive_sum_is_correct_when_exactly_one_term_is_float_spelled() { // `usageMetadata` field that is there and is not a number busbar understands. The old readers // defaulted it to zero, writing "no work happened" into the ledger for work that did. -/// THE BUFFERED IMAGE READER REFUSES AN UNREADABLE BILLED COUNT. +/// AN IMAGE ANSWER'S TOKENS ARE RECORDED, NOT BILLED, SO AN UNREADABLE ONE NEVER REFUSES IT. +/// +/// 1.5.5's Imagen reader read no `usageMetadata` (v1.5.5 `crates/busbar/src/handlers/gemini.rs:446-472`) +/// and served the answer. The counts are now recorded (residual units), never billed, so an +/// unreadable one is left out of the record rather than refusing a served answer. #[test] -fn an_unreadable_image_count_refuses_instead_of_billing_zero() { +fn an_unreadable_image_count_never_refuses_the_answer() { let wire = br#"{"predictions":[{"bytesBase64Encoded":"aGk="}],"usageMetadata":{"promptTokenCount":"27","candidatesTokenCount":5}}"#; - let err = crate::codec::gemini::handler::read_image_response(wire) - .expect_err("a present, unreadable billed count is a refusal, never a zero"); - assert!( - format!("{err:?}").contains("promptTokenCount"), - "the refusal names the field: {err:?}" - ); + let resp = crate::codec::gemini::handler::read_image_response(wire) + .expect("an image answer is served whatever its usage spelling"); + match resp.billing() { + Some(busbar_contract::billing::Billing::Tokens(t)) => { + assert_eq!((t.input, t.output), (0, 0)); + assert_eq!( + t.residual_units, + std::collections::BTreeMap::from([("image.output_tokens".to_string(), 5)]) + ); + } + other => panic!("expected a zero token line, got {other:?}"), + } - // The same body, float-spelled, still reads: the refusal is about unreadability only. let ok = br#"{"predictions":[{"bytesBase64Encoded":"aGk="}],"usageMetadata":{"promptTokenCount":27.0,"candidatesTokenCount":5}}"#; let resp = crate::codec::gemini::handler::read_image_response(ok) .expect("a float-spelled count reads"); match resp.billing() { Some(busbar_contract::billing::Billing::Tokens(t)) => { - assert_eq!(t.input, 27); - assert_eq!(t.output, 5); + assert_eq!((t.input, t.output), (0, 0)); + assert_eq!(t.residual_units.get("image.input_tokens"), Some(&27)); } - other => panic!("expected token billing, got {other:?}"), + other => panic!("expected a zero token line, got {other:?}"), } } diff --git a/crates/busbar-plane-llm/src/codec/gemini/tests/handler_tests.rs b/crates/busbar-plane-llm/src/codec/gemini/tests/handler_tests.rs index 8bc24feb64..86cda9e135 100644 --- a/crates/busbar-plane-llm/src/codec/gemini/tests/handler_tests.rs +++ b/crates/busbar-plane-llm/src/codec/gemini/tests/handler_tests.rs @@ -308,11 +308,12 @@ fn image_read_response_captures_base64_image_bytes() { assert_eq!(r.images[0].mime_type.as_deref(), Some("image/png")); } -// FIND (money): a token-metered Gemini image response carries a `usageMetadata` token object; the -// reader must surface it so `billing()` token-meters the request instead of billing nothing. Fails -// pre-fix (usage left unset → `billing()` is None). +// A token-reporting Gemini image response carries a `usageMetadata` token object. Its tokens are +// RECORDED (residual units) and bill NOTHING on the four token tiers, as in 1.5.5, whose Imagen +// reader read no `usageMetadata` (v1.5.5 `crates/busbar/src/handlers/gemini.rs:446-472`) and whose +// image op tapped no same-dialect body. RED while the reader token-billed 20 in / 10 out. #[test] -fn image_response_with_usage_metadata_bills_tokens() { +fn image_response_usage_metadata_is_recorded_never_billed() { let wire = serde_json::to_vec(&json!({ "predictions": [{ "bytesBase64Encoded": "AAAA", "mimeType": "image/png" }], "usageMetadata": { "promptTokenCount": 20, "candidatesTokenCount": 10 }, @@ -321,10 +322,18 @@ fn image_response_with_usage_metadata_bills_tokens() { let resp = super::read_image_response(&wire).unwrap(); match resp.billing() { Some(busbar_contract::billing::Billing::Tokens(t)) => { - assert_eq!(t.input, 20); - assert_eq!(t.output, 10); + assert_eq!((t.input, t.output), (0, 0), "no image token bills: {t:?}"); + assert_eq!( + t.residual_units, + std::collections::BTreeMap::from([ + ("image.input_tokens".to_string(), 20), + ("image.output_tokens".to_string(), 10), + ]) + ); + } + other => { + panic!("a token-reporting Gemini image answer is a zero token line, got {other:?}") } - other => panic!("token-metered Gemini image must token-bill, got {other:?}"), } } diff --git a/crates/busbar-plane-llm/src/codec/ir/image.rs b/crates/busbar-plane-llm/src/codec/ir/image.rs index 7c974ced86..3751661639 100644 --- a/crates/busbar-plane-llm/src/codec/ir/image.rs +++ b/crates/busbar-plane-llm/src/codec/ir/image.rs @@ -9,7 +9,9 @@ //! `tier`) are PARALLEL optionals — never collapsed. Response images are additive [`ImageOutput`] //! (b64 AND url may coexist). Common cross-provider fields are typed; provider-unique knobs (Titan //! `controlMode`, SDXL `sampler`/`clip_guidance_preset`, per-prompt weights…) ride source-scoped -//! `extra`. Billing: `Tokens` for gpt-image-1/Gemini, else `Billing::Images` (per-image, no usage body). +//! `extra`. Billing: the token counts a gpt-image-1 or Gemini answer reports are RECORDED, never +//! billed (1.5.5 billed no image tokens; see [`ImageResp::billing`]); else `Billing::Images` +//! (per-image, no usage body). use busbar_contract::billing::{Billing, TokenUsage}; use busbar_contract::ir::SourceScopedExtra; @@ -177,17 +179,43 @@ pub struct CostBasis { pub struct ImageResp { pub created: Option, pub images: Vec, // additive per image (b64 AND url may coexist) - pub usage: Option, // gpt-image-1, Gemini (token-metered) + pub usage: Option, // gpt-image-1, Gemini: the reported tokens (recorded, not billed) pub cost_basis: Option, // per-image providers (dall-e/Imagen/Titan/SDXL) - pub warnings: Vec, // raiFilteredReason, finish_reasons, moderation notes + pub warnings: Vec, // raiFilteredReason, finish_reasons, moderation notes pub extra: SourceScopedExtra, } +/// The residual key an image answer's reported INPUT token count is recorded under. +pub const IMAGE_INPUT_TOKENS_RESIDUAL: &str = "image.input_tokens"; +/// The residual key an image answer's reported OUTPUT token count is recorded under. +pub const IMAGE_OUTPUT_TOKENS_RESIDUAL: &str = "image.output_tokens"; + impl ImageResp { - /// Billing projection: token usage when present (gpt-image-1/Gemini); else per-image `Images`. + /// Billing projection. An answer that reports token usage (gpt-image-1, a Gemini image model) + /// bills NO token: its counts are RECORDED on [`TokenUsage::residual_units`] (the settle's one + /// `usage.residual` audit row), never on the four billed tiers. 1.5.5 billed no image token on + /// any path: its OpenAI, Gemini and Bedrock image readers set neither `usage` nor `cost_basis` + /// (v1.5.5 `crates/busbar/src/handlers/openai.rs:731-758`, `handlers/gemini.rs:446-472`), and + /// no image handler tapped a same-dialect body (the only `taps_usage` overrides were + /// embeddings and chat). Billing the tokens would charge a key, and spend its `tokens:` caps, + /// where 1.5.5 charged nothing, and no owner ruling signs that (LEDGER-100 records a reported + /// unit; it does not sign a new charge). A per-image answer with no usage body bills its + /// `images` count (LEDGER-100, CHANGELOG 1.6.0 Upgrade). pub fn billing(&self) -> Option { if let Some(u) = &self.usage { - Some(Billing::Tokens(u.clone())) + let mut residual_units = std::collections::BTreeMap::new(); + for (class, n) in [ + (IMAGE_INPUT_TOKENS_RESIDUAL, u.input), + (IMAGE_OUTPUT_TOKENS_RESIDUAL, u.output), + ] { + if n != 0 { + residual_units.insert(class.to_string(), n); + } + } + Some(Billing::Tokens(TokenUsage { + residual_units, + ..Default::default() + })) } else { self.cost_basis.as_ref().map(|cb| Billing::Images { count: cb.count, diff --git a/crates/busbar-plane-llm/src/codec/ir/tests/image_tests.rs b/crates/busbar-plane-llm/src/codec/ir/tests/image_tests.rs index 3e40397302..ba16243ade 100644 --- a/crates/busbar-plane-llm/src/codec/ir/tests/image_tests.rs +++ b/crates/busbar-plane-llm/src/codec/ir/tests/image_tests.rs @@ -31,7 +31,15 @@ fn billing_prefers_tokens_else_per_image() { }), ..Default::default() }; - assert!(matches!(tokenized.billing(), Some(Billing::Tokens(_)))); + // Image tokens are recorded, never billed (1.5.5 billed no image token). + match tokenized.billing() { + Some(Billing::Tokens(t)) => { + assert_eq!((t.input, t.output), (0, 0)); + assert_eq!(t.residual_units.get("image.input_tokens"), Some(&5)); + assert_eq!(t.residual_units.get("image.output_tokens"), Some(&272)); + } + other => panic!("expected a zero token line, got {other:?}"), + } let per_image = ImageResp { cost_basis: Some(CostBasis { diff --git a/crates/busbar-plane-llm/src/codec/openai_chat/handler.rs b/crates/busbar-plane-llm/src/codec/openai_chat/handler.rs index cb9b674fd4..200dd8fc72 100644 --- a/crates/busbar-plane-llm/src/codec/openai_chat/handler.rs +++ b/crates/busbar-plane-llm/src/codec/openai_chat/handler.rs @@ -652,11 +652,9 @@ leaf_op! { OpenAiImage: super::VENDOR_NAME, ImageReqHandle = read_image_request, ImageRespHandle = read_image_response; - // Token-metered for gpt-image-1: buffer the same-protocol non-stream 2xx body so the default - // `extract_usage` can read the `usage` object and bill the virtual key's TPM/spend. The - // cross-protocol path already bills via `translate_response`; this closes the same-protocol gap - // (mirrors the embeddings cell). Per-image models return no `usage`, so the tap bills 0 tokens - // and the request still meters once — unchanged for them. + // Buffer the same-protocol non-stream 2xx body so the default `extract_usage` runs this op's + // reader: a gpt-image-1 answer's reported tokens are RECORDED (never billed, as in 1.5.5; see + // `ImageResp::billing`), and a per-image answer with no `usage` bills its `images` count. fn taps_usage(&self) -> bool { true } @@ -1365,24 +1363,22 @@ pub fn read_image_response(wire: &[u8]) -> Result Result { - // BILLED COUNTS: absent is zero, UNREADABLE IS A REFUSAL (#81/#42). - Ok(busbar_contract::billing::TokenUsage { - input: crate::codec::usage_count::billed_count(u, keys::INPUT_TOKENS) - .map_err(|e| CodecError::Malformed(e.to_string()))?, - output: crate::codec::usage_count::billed_count(u, keys::OUTPUT_TOKENS) - .map_err(|e| CodecError::Malformed(e.to_string()))?, - ..Default::default() - }) - }, - ) - .transpose()?; + // per-image models (dall-e, etc.) return no usage body at all. The tokens are RECORDED, never + // billed (`ImageResp::billing`, 1.5.5 parity), so they are read leniently: an unreadable count + // is left out and never refuses an answer 1.5.5 served (v1.5.5 + // `crates/busbar/src/handlers/openai.rs:731-758` read no `usage` at all). + let usage = v.get(keys::USAGE).map(|u| { + let count = |k: &str| { + u.get(k) + .and_then(crate::codec::usage_count::read_count_u64) + .unwrap_or(0) + }; + busbar_contract::billing::TokenUsage { + input: count(keys::INPUT_TOKENS), + output: count(keys::OUTPUT_TOKENS), + ..Default::default() + } + }); // Per-image (dall-e-style) providers carry no `usage` — record the per-image cost basis so the // op is billed as `Billing::Images` rather than nothing. The billable COUNT is recoverable from // the response itself (one image per `data` entry). The size/quality TIERS live on the request diff --git a/crates/busbar-plane-llm/src/codec/openai_chat/tests/float_usage_tests.rs b/crates/busbar-plane-llm/src/codec/openai_chat/tests/float_usage_tests.rs index 9ea21f2d63..d77332ee25 100644 --- a/crates/busbar-plane-llm/src/codec/openai_chat/tests/float_usage_tests.rs +++ b/crates/busbar-plane-llm/src/codec/openai_chat/tests/float_usage_tests.rs @@ -132,30 +132,38 @@ fn buffered_read_response_reads_float_encoded_counts() { // `ledger x ratecard`, so every figure derived from that row was faithfully wrong and nothing // downstream could tell. These pin the live reader paths, which is where the ledger row comes from. -/// THE BUFFERED IMAGE READER REFUSES AN UNREADABLE BILLED COUNT. +/// AN IMAGE ANSWER'S TOKENS ARE RECORDED, NOT BILLED, SO AN UNREADABLE ONE NEVER REFUSES IT. +/// +/// 1.5.5 served every gpt-image-1 answer and read no `usage` (v1.5.5 +/// `crates/busbar/src/handlers/openai.rs:731-758`). The counts are now recorded (residual units), +/// never billed, so an unreadable one is left out of the record rather than refusing a served +/// answer; a float-spelled one is recorded as its count. #[test] -fn an_unreadable_image_count_refuses_instead_of_billing_zero() { - // `"27"` is a string, not a number: present, and not a count. +fn an_unreadable_image_count_never_refuses_the_answer() { let wire = br#"{"data":[{"b64_json":"aGk="}],"usage":{"input_tokens":"27","output_tokens":5}}"#; - let err = crate::codec::openai_chat::handler::read_image_response(wire) - .expect_err("a present, unreadable billed count is a refusal, never a zero"); - let text = format!("{err:?}"); - assert!( - text.contains("input_tokens"), - "the refusal names the field: {text}" - ); + let resp = crate::codec::openai_chat::handler::read_image_response(wire) + .expect("an image answer is served whatever its usage spelling"); + match resp.billing() { + Some(busbar_contract::billing::Billing::Tokens(t)) => { + assert_eq!((t.input, t.output), (0, 0)); + assert_eq!( + t.residual_units, + std::collections::BTreeMap::from([("image.output_tokens".to_string(), 5)]) + ); + } + other => panic!("expected a zero token line, got {other:?}"), + } - // And the same body with a READABLE count still reads, float-spelled included — the refusal is - // about unreadability, not about strictness for its own sake. let ok = br#"{"data":[{"b64_json":"aGk="}],"usage":{"input_tokens":27.0,"output_tokens":5}}"#; let resp = crate::codec::openai_chat::handler::read_image_response(ok) .expect("a float-spelled count reads"); match resp.billing() { Some(busbar_contract::billing::Billing::Tokens(t)) => { - assert_eq!(t.input, 27); - assert_eq!(t.output, 5); + assert_eq!((t.input, t.output), (0, 0)); + assert_eq!(t.residual_units.get("image.input_tokens"), Some(&27)); + assert_eq!(t.residual_units.get("image.output_tokens"), Some(&5)); } - other => panic!("expected token billing, got {other:?}"), + other => panic!("expected a zero token line, got {other:?}"), } } @@ -183,10 +191,14 @@ fn an_absent_count_still_reads_as_zero_and_no_usage_object_still_reads() { .expect("absent output_tokens reads"); match resp.billing() { Some(busbar_contract::billing::Billing::Tokens(t)) => { - assert_eq!(t.input, 27); - assert_eq!(t.output, 0, "an absent count is zero, exactly as before"); + assert_eq!((t.input, t.output), (0, 0), "image tokens never bill"); + assert_eq!( + t.residual_units, + std::collections::BTreeMap::from([("image.input_tokens".to_string(), 27)]), + "an absent count is zero, and a zero is not recorded" + ); } - other => panic!("expected token billing, got {other:?}"), + other => panic!("expected a zero token line, got {other:?}"), } // No `usage` object at all: unchanged, bills via the per-image cost basis. let wire = br#"{"data":[{"b64_json":"aGk="}]}"#; diff --git a/crates/busbar-plane-llm/src/codec/openai_chat/tests/handler_tests.rs b/crates/busbar-plane-llm/src/codec/openai_chat/tests/handler_tests.rs index b4f5ade5a4..7748ca58ca 100644 --- a/crates/busbar-plane-llm/src/codec/openai_chat/tests/handler_tests.rs +++ b/crates/busbar-plane-llm/src/codec/openai_chat/tests/handler_tests.rs @@ -621,10 +621,12 @@ fn multipart_single_char_boundary_parses_correctly() { assert!(r.audio.is_some()); } -// FIND-1 (money): a gpt-image-1 response carries a token `usage` object; the reader must surface it -// so `billing()` token-meters the request instead of billing nothing. Fails pre-fix (usage unset). +// A gpt-image-1 response carries a token `usage` object. Its tokens are RECORDED (residual units, +// the settle's `usage.residual` audit row) and bill NOTHING on the four token tiers, as in 1.5.5, +// whose image reader read no `usage` (v1.5.5 `crates/busbar/src/handlers/openai.rs:731-758`) and +// whose image op tapped no same-dialect body. RED while the reader token-billed 20 in / 10 out. #[test] -fn image_response_with_usage_object_bills_tokens() { +fn image_response_usage_is_recorded_never_billed() { let wire = json!({ "created": 1, "data": [{ "b64_json": "AAAA" }], @@ -633,11 +635,31 @@ fn image_response_with_usage_object_bills_tokens() { let resp = super::read_image_response(&serde_json::to_vec(&wire).unwrap()).unwrap(); match resp.billing() { Some(busbar_contract::billing::Billing::Tokens(t)) => { - assert_eq!(t.input, 20); - assert_eq!(t.output, 10); + assert_eq!( + (t.input, t.output, t.cache_read, t.cache_creation), + (0, 0, None, None), + "no image token bills: {t:?}" + ); + assert!(t.open_units.is_empty(), "no open class bills: {t:?}"); + assert_eq!( + t.residual_units, + std::collections::BTreeMap::from([ + ("image.input_tokens".to_string(), 20), + ("image.output_tokens".to_string(), 10), + ]), + "the reported tokens are recorded" + ); } - other => panic!("gpt-image-1 usage must token-bill, got {other:?}"), + other => panic!("gpt-image-1 usage is recorded on a zero token line, got {other:?}"), } + // The same-dialect tap reads the same zero bill. + let tapped = busbar_contract::codec::OperationHandler::extract_usage( + &super::OpenAiImage, + "openai", + &serde_json::to_vec(&wire).unwrap(), + ) + .expect("the tap reads the answer"); + assert_eq!((tapped.input, tapped.output), (0, 0)); } // FIND-1 (money): a per-image (dall-e-style) response has NO usage object; the reader must record a From eb576967b52627a5547422c2ed6ab3edfc14745d Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:55:57 -0700 Subject: [PATCH 34/59] row 155: the downgrade tests name their pools in neutral words (kind-isolation law0 busbar x instance:secret 37 -> 35) The cycle test's pools were `a`/`b`/`c` and a fixture bound `c`: the bare `c` is the secret kind's instance id (busbar-secret-c), so the root carried two secret-instance hits. The pools are `first`/`second`/`third` and the fixture binding `charged`; nothing the tests assert moves. --- crates/busbar/src/root/tests/door_steps.rs | 51 ++++++++++++---------- 1 file changed, 28 insertions(+), 23 deletions(-) diff --git a/crates/busbar/src/root/tests/door_steps.rs b/crates/busbar/src/root/tests/door_steps.rs index 213620c4c9..af6fd9f026 100644 --- a/crates/busbar/src/root/tests/door_steps.rs +++ b/crates/busbar/src/root/tests/door_steps.rs @@ -475,7 +475,7 @@ fn charge_once( const DOWNGRADE_SECTION: &str = "models:\n big: {}\n small: {}\n tiny: {}\n\ pools:\n frontier:\n members: [big]\n value:\n members: [small]\n\ - \x20 a:\n members: [big]\n b:\n members: [small]\n c:\n members: [tiny]"; + \x20 first:\n members: [big]\n second:\n members: [small]\n third:\n members: [tiny]"; /// BUDGET DOWNGRADE ON THE DOOR PATH (1.5.5 `test_budget_exhaustion_downgrades_pool`): exhausting /// the `frontier` budget re-admits the unit through `value` instead of refusing it; the charge lands @@ -487,51 +487,56 @@ fn an_exhausted_budget_re_pools_the_unit_through_its_downgrade_pool() { let pools = pools(DOWNGRADE_SECTION); // fee 10, cap 25: two frontier admissions spend 20; the third would reach 30. for unit in 1..=2 { - let c = charge_once(&app, &key, &pools, "frontier", unit); - assert_eq!(c.admitted, Ok(()), "unit {unit} is under the frontier cap"); - assert_eq!(c.downgraded, None, "no downgrade while under the cap"); - assert_eq!(c.routed, owned("frontier", &["big"])); + let charged = charge_once(&app, &key, &pools, "frontier", unit); + assert_eq!( + charged.admitted, + Ok(()), + "unit {unit} is under the frontier cap" + ); + assert_eq!(charged.downgraded, None, "no downgrade while under the cap"); + assert_eq!(charged.routed, owned("frontier", &["big"])); } - let c = charge_once(&app, &key, &pools, "frontier", 3); - assert_eq!(c.admitted, Ok(()), "downgraded, not refused"); + let charged = charge_once(&app, &key, &pools, "frontier", 3); + assert_eq!(charged.admitted, Ok(()), "downgraded, not refused"); assert_eq!( - c.downgraded.as_deref(), + charged.downgraded.as_deref(), Some("value"), "the charge landed on the downgrade pool" ); assert_eq!( - c.routed, + charged.routed, owned("value", &["small"]), "the walk follows the charge to the downgrade pool" ); } /// A DOWNGRADE CYCLE TERMINATES (1.5.5 `test_downgrade_cycle_terminates_via_the_revisit_guard`): -/// `a` -> `b` -> `c` -> `b` walks to `c` and stops at the revisit, refusing with `c`'s bucket named -/// in the previous release's words; a guard that refused one hop early names `b`. +/// `first` -> `second` -> `third` -> `second` walks to `third` and stops at the revisit, refusing +/// with `third`'s bucket named in the previous release's words; a guard that refused one hop early +/// names `second`. #[test] fn a_downgrade_cycle_ends_in_the_plain_refusal_naming_the_last_pool_walked() { let (app, key) = downgrade_book( vec![ - downgrading("a", 10, "b"), - downgrading("b", 0, "c"), - downgrading("c", 0, "b"), + downgrading("first", 10, "second"), + downgrading("second", 0, "third"), + downgrading("third", 0, "second"), ], None, ); let pools = pools(DOWNGRADE_SECTION); - let first = charge_once(&app, &key, &pools, "a", 1); - assert_eq!(first.admitted, Ok(()), "under a's cap"); + let first = charge_once(&app, &key, &pools, "first", 1); + assert_eq!(first.admitted, Ok(()), "under the first pool's cap"); assert_eq!(first.downgraded, None); - let second = charge_once(&app, &key, &pools, "a", 2); + let second = charge_once(&app, &key, &pools, "first", 2); let (reason, words) = second .admitted .expect_err("a cyclic downgrade chain ends in a refusal"); assert_eq!(reason, busbar_contract::caps::ReasonCode::OverBudget); let words = words.expect("the plane serving the `pools` map renders 1.5.5's words"); assert!( - words.contains("pool 'c'"), - "the chain walked a -> b -> c before the revisit guard: {words}" + words.contains("pool 'third'"), + "the chain walked first -> second -> third before the revisit guard: {words}" ); assert_eq!( second.downgraded, None, @@ -554,15 +559,15 @@ fn a_downgrade_never_routes_a_key_into_a_pool_it_may_not_use() { Ok(()) ); } - let c = charge_once(&app, &key, &pools, "frontier", 3); + let charged = charge_once(&app, &key, &pools, "frontier", 3); assert_eq!( - c.admitted.map_err(|(reason, _)| reason), + charged.admitted.map_err(|(reason, _)| reason), Err(busbar_contract::caps::ReasonCode::OverBudget), "the plain budget refusal, not a leaked pool" ); - assert_eq!(c.downgraded, None); + assert_eq!(charged.downgraded, None); assert_eq!( - c.routed, + charged.routed, owned("frontier", &["big"]), "the route is unchanged" ); From a1003c625e8db0ea0a4786cbacc890ba661980bd Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:35:32 -0700 Subject: [PATCH 35/59] row 155 (L1#1): the llm door keeps a retried unit for the kernel's next attempt, and ends a final one Row-159 audit plane-llm L1#1 (HIGH): the door ended every unit whose answer was done, a RETRY verdict included, so the kernel's next ATTEMPT found no unit and faulted: no plane-judged failover (context length, an error-map class, a billing hard-down) ever reached a second member. The door now keeps a unit whose done answer carries VERDICT_RETRY, unless the kernel stated the answer is final (`PIECE_FINAL`, #840): then the unit ends with it, so nothing is left behind. A piece whose flags the contract refuses (`check_piece_in`) is a FAULT. 1.5.5 served the next member (v1.5.5 ingress/tests/tests.rs test_real_failover_serves_second_member_after_first_5xx). RED-able (busbar-plane-llm/tests/door_retry.rs): a_retry_verdict_keeps_the_unit_and_the_failover_reaches_the_next_member, a_final_answer_ends_the_unit_whatever_its_verdict (the leak check: the ended unit's next piece is a FAULT), a_final_mark_off_the_status_piece_is_a_fault. --- crates/busbar-plane-llm/src/plane_door.rs | 25 +- crates/busbar-plane-llm/tests/door_retry.rs | 276 ++++++++++++++++++++ 2 files changed, 297 insertions(+), 4 deletions(-) create mode 100644 crates/busbar-plane-llm/tests/door_retry.rs diff --git a/crates/busbar-plane-llm/src/plane_door.rs b/crates/busbar-plane-llm/src/plane_door.rs index 3692271252..8869df61be 100644 --- a/crates/busbar-plane-llm/src/plane_door.rs +++ b/crates/busbar-plane-llm/src/plane_door.rs @@ -58,15 +58,16 @@ use busbar_contract::abi::mechanism::lifecycle::{ GenIn, RefreshIn, ReleaseIn, TickIn, TickOut, ValidateIn, }; use busbar_contract::abi::mechanism::ticket::{CompletionHandle, Ticket}; +use busbar_contract::abi::plane::check::check_piece_in; use busbar_contract::abi::plane::{ ArriveIn, ArriveOut, BillableClass, DialectAuth, OnPieceIn, OnPieceOut, OpClass, OutField, PlaneDriveIn, PlaneDriveOut, PlaneOpenIn, PlaneOpenOut, PlaneRefreshOut, PlaneSnapshot, PlaneTail, ProjectIn, ProjectOut, RefusalIn, RefusalOut, ServeIn, ServeOut, UnitCount, CANCEL_ABORTED, CANCEL_OK_PARTIAL, CLAIM_EXACT, CLAIM_PROBE, EMIT_DONE, EMIT_TO_FAR_END, FROM_CALLER, FROM_FAR_END, FROM_KERNEL, INGRESS_REQUEST_RESPONSE, INGRESS_RESPONSE_STREAM, - PIECE_HAS_STATUS, PIECE_LAST, PRINCIPAL_NONE, PRINCIPAL_REQUIRED, REFUSAL_GATE, ROUTE_DIRECT, - ROUTE_POOL, SHAPE_PIECEWISE, SPAN_ABSENT, TAIL_FALLBACK, TAIL_PROBES, UNITS_FLOOR, - UNITS_REPORTED, VERDICT_HARD, VERDICT_NONE, VERDICT_OK, VERDICT_RETRY, + PIECE_FINAL, PIECE_HAS_STATUS, PIECE_LAST, PRINCIPAL_NONE, PRINCIPAL_REQUIRED, REFUSAL_GATE, + ROUTE_DIRECT, ROUTE_POOL, SHAPE_PIECEWISE, SPAN_ABSENT, TAIL_FALLBACK, TAIL_PROBES, + UNITS_FLOOR, UNITS_REPORTED, VERDICT_HARD, VERDICT_NONE, VERDICT_OK, VERDICT_RETRY, }; use busbar_contract::abi::plane::{PlaneCancelIn, PlaneCancelOut}; use busbar_contract::abi::plane::{RecordWrite, AUDIT_DEGRADED, RECORD_AUDIT}; @@ -502,6 +503,9 @@ struct UnitState { reported: Vec, /// The caller was answered under a success status: the request's fee unit was incurred. fee: bool, + /// The kernel stated the current attempt's answer will not be failed over (`PIECE_FINAL`): a + /// retry verdict on it ends the unit like any other done answer. + final_answer: bool, } impl UnitState { @@ -520,6 +524,7 @@ impl UnitState { unreadable: false, reported: Vec::new(), fee: false, + final_answer: false, } } @@ -837,6 +842,7 @@ struct PieceIn<'a> { /// An ATTEMPT: the far-end request for the member the walk picked. fn attempt(unit: &mut UnitState, piece: &PieceIn<'_>) -> Answer { unit.member = String::from_utf8_lossy(piece.member).into_owned(); + unit.final_answer = false; unit.reply = None; unit.request = None; unit.started = piece.clock.map(|c| c.mono_ns); @@ -1044,6 +1050,9 @@ fn deliver( let more = p.sent < p.answer.bytes.len(); let to_far_end = p.answer.to_far_end; let done = p.answer.done && !more; + // A RETRY verdict the kernel may still fail over keeps the unit for its next ATTEMPT; a done + // answer of any other verdict, or one the kernel stated is final (`PIECE_FINAL`), ends it. + let retried = p.answer.verdict == VERDICT_RETRY && !unit.final_answer; let far = if to_far_end { EMIT_TO_FAR_END } else { 0 }; out.set(|o| &o.emitted, n as u64); out.set(|o| &o.more, u32::from(more)); @@ -1054,7 +1063,7 @@ fn deliver( if !more { unit.pending = None; } - (Outcome::Ready, done && !to_far_end) + (Outcome::Ready, done && !to_far_end && !retried) } // ── the slots ──────────────────────────────────────────────────────────────────────────────────── @@ -1260,12 +1269,20 @@ slot!( }; let given = input.get(); let key = given.unit; + // A piece whose flags the contract refuses (a cut or a final mark off the far end's own + // piece) is a FAULT before anything of the unit moves. + if check_piece_in(given.from, given.flags).is_err() { + return Outcome::Fault; + } // The unit leaves the map for the call, so its answer is computed with no lock held. let Some(mut unit) = guard(&door.units).remove(&key) else { return Outcome::Fault; }; guard(&door.tickets).insert(given.head.ticket, key); if unit.pending.is_none() { + if given.from == FROM_FAR_END && given.flags & PIECE_FINAL != 0 { + unit.final_answer = true; + } let piece = PieceIn { last: given.flags & PIECE_LAST != 0, status: (given.flags & PIECE_HAS_STATUS != 0).then_some(given.status_code), diff --git a/crates/busbar-plane-llm/tests/door_retry.rs b/crates/busbar-plane-llm/tests/door_retry.rs new file mode 100644 index 0000000000..2f740efa6e --- /dev/null +++ b/crates/busbar-plane-llm/tests/door_retry.rs @@ -0,0 +1,276 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! A RETRY VERDICT, AS THE DOOR SERVES IT (row-159 audit plane-llm L1#1; status row 155): a far-end +//! error the plane judges retryable (here a context-length 400, the error map's class) keeps the +//! unit for the kernel's next ATTEMPT, so the failover reaches the next member, as 1.5.5 served the +//! second member after the first failed (v1.5.5 `crates/busbar/src/ingress/tests/tests.rs` +//! `test_real_failover_serves_second_member_after_first_5xx`, and the context-length exclusion of +//! `proxy`'s failover walk). An answer the kernel states it will not fail over (`PIECE_FINAL`) ends +//! the unit whatever its verdict, so the door keeps nothing behind it: a later piece of that unit is +//! a FAULT, the unit is gone. + +use std::mem::zeroed; +use std::sync::Arc; + +use busbar_contract::abi::mechanism::call::{AbiStr, Blob, Field, Outcome, BLOB_OCTETS}; +use busbar_contract::abi::mechanism::lifecycle::slot as life; +use busbar_contract::abi::plane::{ + slot, ArriveIn, ArriveOut, OnPieceIn, OnPieceOut, OutField, PlaneOpenIn, PlaneOpenOut, + UnitCount, EMIT_DONE, FROM_CALLER, FROM_FAR_END, FROM_KERNEL, PIECE_FINAL, PIECE_HAS_STATUS, + PIECE_LAST, VERDICT_OK, VERDICT_RETRY, +}; +use busbar_plane_llm::plane_door; +use busbar_plugin_loader::dispatch::kinds::plane::Plane; +use busbar_plugin_loader::dispatch::{ + in_head, load_linked, out_head, Bind, DispatchConfig, Dispatcher, Frame, LinkedRow, NoSink, + Plugin, +}; + +fn z() -> T { + // SAFETY: every `in`/`out` here is plain C data; all-zero is a valid value of each. + unsafe { zeroed() } +} + +fn octets(b: &'static [u8]) -> Blob { + Blob { + ptr: b.as_ptr(), + len: b.len(), + fmt: BLOB_OCTETS, + flags: 0, + } +} + +const fn text(b: &'static [u8]) -> AbiStr { + AbiStr { + ptr: b.as_ptr(), + len: b.len(), + } +} + +const fn field(name: &'static [u8], value: &'static [u8]) -> Field { + Field { + name: text(name), + value: text(value), + } +} + +/// Two anthropic far ends in one pool; the error map reads `invalid_request_error` as context length. +const SETTINGS: &[u8] = br#"{ + "providers": { + "ant-a": { "protocol": "anthropic", "base_url": "https://a.example", + "error_map": { "invalid_request_error": "context_length" } }, + "ant-b": { "protocol": "anthropic", "base_url": "https://b.example" } + }, + "models": { "small": { "provider": "ant-a" }, "large": { "provider": "ant-b" } }, + "pools": { "p": { "members": ["small", "large"] } } +}"#; +const CHAT: &[u8] = br#"{"model":"p","max_tokens":16,"messages":[{"role":"user","content":"hi"}]}"#; +const CALLER: &[Field] = &[ + field(b"content-type", b"application/json"), + field(b"anthropic-version", b"2023-06-01"), +]; +const TOO_LONG: &[u8] = + br#"{"type":"error","error":{"type":"invalid_request_error","message":"prompt is too long"}}"#; +const ANSWER: &[u8] = br#"{"id":"msg_1","type":"message","role":"assistant","model":"large","content":[{"type":"text","text":"hello"}],"stop_reason":"end_turn","usage":{"input_tokens":7,"output_tokens":3}}"#; +const HEAD: &[Field] = &[field(b"content-type", b"application/json")]; + +fn door() -> (Dispatcher, Plugin) { + let d = Dispatcher::new(DispatchConfig::default()); + let row = LinkedRow::of(plane_door::door).expect("the linked door states its Statement"); + let p = load_linked( + &row, + Bind { + instance: Arc::from("the-instance"), + max_inflight_cap: 64, + sink: Arc::new(NoSink), + dispatcher: d.adopter(), + conns: busbar_plugin_loader::dispatch::ConnTable::Probe, + }, + ) + .expect("the linked door loads"); + let mut i: PlaneOpenIn = z(); + i.open.head = in_head(); + (i.open.generation, i.open.settings) = (1, octets(SETTINGS)); + let mut o: PlaneOpenOut = z(); + o.open.head = out_head(); + let (opened, _) = p.open(&mut Frame::new(i, o)); + assert_eq!(opened.outcome, Outcome::Ready, "the door opens"); + for s in [life::HYDRATE, life::START] { + let mut g = Frame::new( + busbar_contract::abi::mechanism::lifecycle::GenIn { + head: in_head(), + generation: 1, + }, + out_head(), + ); + assert_eq!(p.call(s, &mut g).outcome, Outcome::Ready); + } + (d, p) +} + +fn arrive(p: &Plugin, unit: u64) { + let mut a: Frame = Frame::new(z(), z()); + (a.input.head, a.out.head) = (in_head(), out_head()); + (a.input.unit, a.input.method, a.input.target) = (unit, text(b"POST"), text(b"/v1/messages")); + (a.input.fields, a.input.fields_len) = (CALLER.as_ptr(), CALLER.len()); + a.input.body = octets(CHAT); + assert_eq!(p.call(slot::ARRIVE, &mut a).outcome, Outcome::Ready); +} + +/// One piece's answer: its outcome, verdict and whether it closed the reply. +#[derive(Debug, PartialEq, Eq)] +struct Answer { + outcome: Outcome, + verdict: u32, + done: bool, + emitted: String, +} + +#[allow(clippy::too_many_arguments)] +fn piece( + p: &Plugin, + unit: u64, + from: u32, + flags: u32, + status: u32, + bytes: &'static [u8], + member: &'static [u8], + attempt_no: u32, +) -> Answer { + let (mut reply, mut units, mut fields, mut arena) = ( + vec![0_u8; 4096], + [z::(); 8], + [z::(); 16], + [0_u8; 1024], + ); + let mut i: OnPieceIn = z(); + i.head = in_head(); + (i.unit, i.from, i.flags, i.bytes) = (unit, from, flags, octets(bytes)); + (i.attempt_no, i.member, i.pool) = (attempt_no, text(member), text(b"p")); + if flags & PIECE_HAS_STATUS != 0 { + i.status_code = status; + (i.head_fields, i.head_fields_len) = (HEAD.as_ptr(), HEAD.len()); + } + (i.reply_buf, i.reply_cap) = (reply.as_mut_ptr(), reply.len()); + (i.units_buf, i.units_cap) = (units.as_mut_ptr(), units.len()); + (i.fields_buf, i.fields_cap) = (fields.as_mut_ptr(), fields.len()); + (i.arena_buf, i.arena_cap) = (arena.as_mut_ptr(), arena.len()); + let mut o: OnPieceOut = z(); + o.head = out_head(); + let mut f = Frame::new(i, o); + let called = p.call(slot::ON_PIECE, &mut f); + Answer { + outcome: called.outcome, + verdict: f.out.verdict, + done: f.out.flags & EMIT_DONE != 0, + emitted: String::from_utf8_lossy(&reply[..f.out.emitted as usize]).into_owned(), + } +} + +/// One attempt of `unit` on `member`: its ATTEMPT piece and the caller's body. +fn attempt(p: &Plugin, unit: u64, member: &'static [u8], attempt_no: u32) { + let a = piece(p, unit, FROM_KERNEL, 0, 0, b"", member, attempt_no); + assert_eq!( + a.outcome, + Outcome::Ready, + "the ATTEMPT on {member:?}: {a:?}" + ); + let b = piece(p, unit, FROM_CALLER, PIECE_LAST, 0, CHAT, member, 0); + assert_eq!(b.outcome, Outcome::Ready, "the body on {member:?}: {b:?}"); +} + +/// RED: the door forgot the unit on its retry verdict, so the second ATTEMPT was a FAULT and the +/// failover never reached `large`. +#[test] +fn a_retry_verdict_keeps_the_unit_and_the_failover_reaches_the_next_member() { + let (_d, p) = door(); + arrive(&p, 7); + attempt(&p, 7, b"small", 1); + let failed = piece( + &p, + 7, + FROM_FAR_END, + PIECE_HAS_STATUS | PIECE_LAST, + 400, + TOO_LONG, + b"small", + 0, + ); + assert_eq!(failed.outcome, Outcome::Ready, "{failed:?}"); + assert_eq!( + failed.verdict, VERDICT_RETRY, + "context length is retried: {failed:?}" + ); + attempt(&p, 7, b"large", 2); + let served = piece( + &p, + 7, + FROM_FAR_END, + PIECE_HAS_STATUS | PIECE_LAST, + 200, + ANSWER, + b"large", + 0, + ); + assert_eq!(served.outcome, Outcome::Ready, "{served:?}"); + assert_eq!(served.verdict, VERDICT_OK); + assert!( + served.done && served.emitted.contains("hello"), + "{served:?}" + ); + // The unit ended with its answer: nothing of it is left behind. + let after = piece(&p, 7, FROM_KERNEL, 0, 0, b"", b"small", 3); + assert_eq!( + after.outcome, + Outcome::Fault, + "the ended unit is gone: {after:?}" + ); +} + +/// RED: a door that keeps every retried unit leaves one behind when the kernel will not fail the +/// answer over (a relayed answer): the unit's later piece is answered instead of faulted. +#[test] +fn a_final_answer_ends_the_unit_whatever_its_verdict() { + let (_d, p) = door(); + arrive(&p, 9); + attempt(&p, 9, b"small", 1); + let last = piece( + &p, + 9, + FROM_FAR_END, + PIECE_HAS_STATUS | PIECE_LAST | PIECE_FINAL, + 400, + TOO_LONG, + b"small", + 0, + ); + assert_eq!(last.outcome, Outcome::Ready, "{last:?}"); + assert_eq!(last.verdict, VERDICT_RETRY); + assert!(last.done, "the final answer closes the reply: {last:?}"); + let after = piece(&p, 9, FROM_KERNEL, 0, 0, b"", b"large", 2); + assert_eq!( + after.outcome, + Outcome::Fault, + "nothing left behind: {after:?}" + ); +} + +/// A piece whose flags the contract refuses (`PIECE_FINAL` without a status) is a FAULT. +#[test] +fn a_final_mark_off_the_status_piece_is_a_fault() { + let (_d, p) = door(); + arrive(&p, 11); + attempt(&p, 11, b"small", 1); + let bad = piece( + &p, + 11, + FROM_FAR_END, + PIECE_LAST | PIECE_FINAL, + 0, + ANSWER, + b"small", + 0, + ); + assert_eq!(bad.outcome, Outcome::Fault, "{bad:?}"); +} From 54bc2ccd88937221da0f7f6253247a6307f796db Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:10:31 -0700 Subject: [PATCH 36/59] =?UTF-8?q?spec=20=C2=A711.11=20R4:=20the=20secret?= =?UTF-8?q?=20kind's=20file=20source=20may=20use=20the=20bounded=20disk=20?= =?UTF-8?q?lane,=20config=20time=20only=20(OWNER=202026-10-13,=20row=20166?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner amended R4: the host-owned bounded disk lane serves the SQLite store, the file export sink, and the secret kind's file source, the last only at config time (boot, config apply, refresh) and never from a request path, which a gate holds. Audit pinned secret-file #1 (blocking reads inside resolve) is fixed against this text. --- docs/design/BUSBAR-1.6.0.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/design/BUSBAR-1.6.0.md b/docs/design/BUSBAR-1.6.0.md index 5178d9ec6f..7abe0f3b06 100644 --- a/docs/design/BUSBAR-1.6.0.md +++ b/docs/design/BUSBAR-1.6.0.md @@ -1462,9 +1462,11 @@ completes) — no verified-credential cache of its own. This supersedes the "sta unresolved until Q-INCACHE was answered" line above and closes Q-INCACHE. **R4 — Q-DISK.** A host-owned bounded disk lane is the ONE exception to "no blocking" (§11.2's rule -otherwise holds absolutely): it exists solely for the SQLite store and the file export sink, both of -which are inherently local-disk-bound and cannot be made sans-IO without becoming a different product. -No other kind may claim this exception. +otherwise holds absolutely): it exists solely for the SQLite store, the file export sink, and the +secret kind's file source (config-time only; OWNER 2026-10-13), each inherently local-disk-bound and +not sans-IO without becoming a different product. The secret kind's file source reads on the lane only +at config time (boot, config apply, refresh) and is never reachable from a request path; a gate holds +that. No other kind may claim this exception. **R5 — Q-MYSQL.** A sans-IO mysql store and a sans-IO ldap auth plugin are built (the `mysql-ldap-stream` ruling, Q82, patches both drivers to take a host-supplied stream); both ship in the default distribution. From 561901f9ceb73fbe7557b106975e1520960fe807 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:24:05 -0700 Subject: [PATCH 37/59] row 159: the shared comment scanner keeps string state across lines, so no gate loses the code after a /* in a string (audit xtask A2 HIGH 1, H1 HIGH 2) The hole: scan::split_comment_line reset its in-string flag on every line and carried only a block-comment bool. On the continuation line of a multi-line string (crates/busbar-kernel/src/plugin_routes.rs:273, `{or_metrics}{root}/*"`) the `/*` opened a block comment that never closed, so lines 274-564 of that file read as comment to kernel-token-wire-purity, no-deferral, plane-transport-neutrality, kind-isolation and every production_lines reader; xtask/src/cli.rs, audit_cmd.rs, gates/no_float_money.rs and xtask/tests/construction_verdict.rs ended the same way. The fix: split_comment_line now reads THE ONE LEXER (lex_line, the classification blank_code already used) and carries a LexState, so an open literal (normal, raw r#"..."#, byte, escaped) or a nested block comment survives the line end, char literals ('"') and lifetimes are told apart, and the code/comment split agrees with blank_code by construction. The carrier is a CommentCarry trait: LexState carries everything; a bool still compiles and carries only the block flag. production_lines, kernel-token-wire-purity, no-deferral, seal-witness and instance-noun-neutrality now carry LexState. blank_code's output is unchanged (checked line for line over every tracked .rs on predev 5c99befcfa). RED plants: - kernel-token-wire-purity selftest "a raw wire field after a `/*` inside a continued string is still read": a kernel file whose continued string ends `{root}/*")`, then `v["input_tokens"]` on line 6. The old scanner opened a block comment at the `/*` and read lines 4-7 as empty, so the gate stayed green and the case expecting planted_wire_read.rs:6 went red. - no-deferral selftest "a deferral after a `/*` inside a continued string is still a marker": the same string shape, then `todo!()` on line 5; the old scanner read it as comment, so no marker. - xtask/tests/infra.rs: a_comment_opener_inside_a_continued_string_is_string (strip_comment_line and production_lines both return the line after the string) and the_comment_splitter_reads_raw_strings_chars_lifetimes_and_nesting. Predev: no gate figure moves. Every line whose reading changes (327 under crates/, the rest under xtask/src) was checked against each consumer's needles. None trips a gate, and no ledger or ceiling row changes. --- xtask/src/gates/instance_noun_neutrality.rs | 2 +- xtask/src/gates/kernel_token_wire_purity.rs | 20 +- xtask/src/gates/no_deferral.rs | 22 +- xtask/src/gates/seal_witness.rs | 4 +- xtask/src/scan.rs | 219 ++++++++++++-------- xtask/tests/infra.rs | 60 ++++++ 6 files changed, 230 insertions(+), 97 deletions(-) diff --git a/xtask/src/gates/instance_noun_neutrality.rs b/xtask/src/gates/instance_noun_neutrality.rs index 3ce65a4cab..1d3abb3e52 100644 --- a/xtask/src/gates/instance_noun_neutrality.rs +++ b/xtask/src/gates/instance_noun_neutrality.rs @@ -872,7 +872,7 @@ fn census_file(cx: &Ctx, f: &SourceFile) -> (Vec, Vec) { // file, and literals anywhere outside a dialect module (kernel, contract, root, loader, a // plane's non-dialect code). let blank_literals = in_dialect_module(cx, krate, &rel); - let mut in_block = false; + let mut in_block = crate::scan::LexState::default(); let mut lex = crate::scan::LexState::default(); let lines: Vec<(String, String)> = f .text diff --git a/xtask/src/gates/kernel_token_wire_purity.rs b/xtask/src/gates/kernel_token_wire_purity.rs index b1f358d985..d9dc960ab2 100644 --- a/xtask/src/gates/kernel_token_wire_purity.rs +++ b/xtask/src/gates/kernel_token_wire_purity.rs @@ -233,7 +233,7 @@ impl Gate for KernelTokenWirePurityGate { let mut offenders = Vec::new(); for f in &files { - let mut in_block = false; + let mut in_block = scan::LexState::default(); for (i, raw) in f.text.lines().enumerate() { // Comments stripped, string literals INTACT — the same rule the shell's // `strip_comments` awk applied. A wire field name inside a literal is still the @@ -293,6 +293,24 @@ impl Gate for KernelTokenWirePurityGate { &[&raw_field], )); + // A `/*` INSIDE A CONTINUED STRING IS STRING. The scanner reset its in-string flag on every + // line, so the `{root}/*"` continuation opened a block comment that never closed and every + // later line of the file read as comment: `crates/busbar-kernel/src/plugin_routes.rs` hid + // 292 of its 564 lines from this gate that way (audit xtask A2 HIGH 1, H1 HIGH 2). + report.push(plant( + cx, + self, + "a raw wire field after a `/*` inside a continued string is still read", + &[ROW_NO_RAW_FIELD], + &format!("{KERNEL_SRC}/planted_wire_read.rs"), + Edit::Create(format!( + "pub fn confine(root: &str) -> String {{\n format!(\"routes are confined to \ + \\\n {{root}}/*\")\n}}\npub fn class_of(v: &Value) -> i64 {{\n \ + v[\"{raw_field}\"].as_i64()\n}}\n" + )), + &["planted_wire_read.rs:6", &raw_field], + )); + // A COMMENT-ONLY MENTION IS PROSE. The rule that exempts it is the rule that lets the // kernel document the ban, and a gate its own explanation fails is a gate people delete. let mut ov = Overlay::new(); diff --git a/xtask/src/gates/no_deferral.rs b/xtask/src/gates/no_deferral.rs index 53cbc8a412..90e7a85c66 100644 --- a/xtask/src/gates/no_deferral.rs +++ b/xtask/src/gates/no_deferral.rs @@ -287,7 +287,7 @@ fn negated_test_pred(code: &str) -> bool { /// the shell's marker file does. `#[cfg(test)] mod { … }` bodies are excluded from BOTH classes. fn markers_in(rel: &str, text: &str) -> Vec { let mut out = Vec::new(); - let mut in_block = false; + let mut in_block = scan::LexState::default(); let mut testdepth: i32 = 0; let mut pend = false; let mut lex = scan::LexState::default(); @@ -913,6 +913,26 @@ impl Gate for NoDeferralGate { ], )); + // ── CLASS A AFTER A `/*` INSIDE A CONTINUED STRING. The shared scanner reset its in-string + // flag on every line, so the continuation's `/*` opened a block comment that never + // closed and the `todo!()` below it read as comment: the rest of + // `crates/busbar-kernel/src/plugin_routes.rs` was unread that way (audit xtask A2 HIGH 1, + // H1 HIGH 2). + let mut ov = Overlay::new(); + ov.set( + "crates/busbar-core/src/xtask_no_deferral_plant.rs", + "pub fn confine(root: &str) -> String {\n format!(\"routes are confined to \\\n \ + {root}/*\")\n}\npub fn arm() -> u8 { todo!() }\n", + ); + report.push(prove_red( + base, + self, + "a deferral after a `/*` inside a continued string is still a marker", + &[ROW_UNWAIVED], + ov, + &["xtask_no_deferral_plant.rs:5"], + )); + // ── CLASS B, on the raw line. let mut ov = Overlay::new(); ov.set( diff --git a/xtask/src/gates/seal_witness.rs b/xtask/src/gates/seal_witness.rs index 2fe639460d..f0298de671 100644 --- a/xtask/src/gates/seal_witness.rs +++ b/xtask/src/gates/seal_witness.rs @@ -626,7 +626,7 @@ fn scan_tree(cx: &Ctx) -> Result { // whose raw text holds the minter's name can mint: the lexers run on those alone, which is // what keeps a whole-tree scan per selftest plant cheap. if ZOO.iter().any(|z| f.text.contains(z)) { - let mut in_block = false; + let mut in_block = scan::LexState::default(); for (i, raw) in f.text.lines().enumerate() { let code = scan::strip_comment_line(raw, &mut in_block); for name in ZOO { @@ -643,7 +643,7 @@ fn scan_tree(cx: &Ctx) -> Result { // THE ONE TEST-SCOPE ANSWER every scanner in this crate uses: `gated` is true inside a // `#[cfg(test)]` item. let lines = scan::test_scope(&f.text); - let mut in_block = false; + let mut in_block = scan::LexState::default(); let code: Vec = f .text .lines() diff --git a/xtask/src/scan.rs b/xtask/src/scan.rs index 52e5569789..04eebb6684 100644 --- a/xtask/src/scan.rs +++ b/xtask/src/scan.rs @@ -12,7 +12,7 @@ /// dropped, one production-code line per output entry, 1-based line numbers. pub fn production_lines(src: &str) -> Vec<(usize, String)> { let mut out = Vec::new(); - let mut in_block_comment = false; + let mut in_block_comment = LexState::default(); let mut pending_test_attr = false; let mut test_mod_depth: Option = None; let mut depth: i32 = 0; @@ -346,8 +346,39 @@ pub fn blank_code(line: &str, st: &mut LexState) -> String { /// lets [`code_of`] strip a trailer off a line that also holds a string. pub fn blank_code_marking(line: &str, st: &mut LexState) -> (String, Option) { let chars: Vec = line.chars().collect(); - let mut comment_at: Option = None; let mut out = String::with_capacity(line.len()); + let mut at = 0; + let comment_at = lex_line(&chars, st, |class, n| { + for c in &chars[at..at + n] { + out.push(if class == Class::Code { *c } else { ' ' }); + } + at += n; + }); + (out, comment_at) +} + +/// What one char of a line is, to THE ONE LEXER. [`blank_code_marking`] keeps only [`Class::Code`]; +/// [`split_comment_line`] keeps code AND literal text in its code half and [`Class::Comment`] in +/// its comment half. One classification, two readers, so the comment stripper and the delimiter +/// counter cannot disagree about where a string or a comment ends. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Class { + /// Code, including every literal's quote delimiters and a byte char's `b`. + Code, + /// The inside of a literal: its body, a raw or byte prefix, a raw literal's closing `#`s. + Literal, + /// The text of a comment. + Comment, + /// A comment's own `//`, `/*` or `*/`. + CommentMark, +} + +/// Classify every char of a line, in order, carrying `st` from the previous line: `put(class, n)` +/// says the next `n` chars are `class`, and the calls cover the line exactly once. Answers where +/// this line's `//` comment began, if it has one. A sink rather than a returned list, so the hot +/// reader ([`blank_code`], on every line of every scan) still allocates only its output. +fn lex_line(chars: &[char], st: &mut LexState, mut put: impl FnMut(Class, usize)) -> Option { + let mut comment_at: Option = None; let mut i = 0; while i < chars.len() { @@ -356,14 +387,14 @@ pub fn blank_code_marking(line: &str, st: &mut LexState) -> (String, Option 0 { if c == '/' && chars.get(i + 1) == Some(&'*') { st.block += 1; - out.push_str(" "); + put(Class::CommentMark, 2); i += 2; } else if c == '*' && chars.get(i + 1) == Some(&'/') { st.block -= 1; - out.push_str(" "); + put(Class::CommentMark, 2); i += 2; } else { - out.push(' '); + put(Class::Comment, 1); i += 1; } continue; @@ -372,29 +403,26 @@ pub fn blank_code_marking(line: &str, st: &mut LexState) -> (String, Option { if c == '\\' { - out.push(' '); - if i + 1 < chars.len() { - out.push(' '); - } + put(Class::Literal, if i + 1 < chars.len() { 2 } else { 1 }); i += 2; } else if c == '"' { st.open = None; - out.push('"'); + put(Class::Code, 1); i += 1; } else { - out.push(' '); + put(Class::Literal, 1); i += 1; } continue; } Some(OpenLit::Raw(hashes)) => { - if c == '"' && closes_raw(&chars, i + 1, hashes) { + if c == '"' && closes_raw(chars, i + 1, hashes) { st.open = None; - out.push('"'); - out.extend(std::iter::repeat_n(' ', hashes)); + put(Class::Code, 1); + put(Class::Literal, hashes); i += 1 + hashes; } else { - out.push(' '); + put(Class::Literal, 1); i += 1; } continue; @@ -404,53 +432,53 @@ pub fn blank_code_marking(line: &str, st: &mut LexState) -> (String, Option Option { (chars.get(i + 1).is_some() && chars.get(i + 2) == Some(&'\'')).then_some(i + 2) } -/// One line split into **(code, comment)** — the same single pass as [`strip_comment_line`], which -/// now delegates here, except the comment text is HANDED BACK rather than dropped on the floor. +/// What a line-by-line comment stripper carries from one line to the next. /// -/// There is one lexer so the two halves cannot disagree: anything absent from `code` is present in -/// `comment` and vice versa. String literals stay verbatim in the CODE half, so a `//` inside a -/// `"…"` is not a comment — which is precisely the distinction -/// [`crate::gates::no_deferral`]'s Class-B comment tags stand on. A `TODO(migrate):` inside a -/// `format!("# TODO(migrate): {t}")` is **data the program emits**, not a label its author attached -/// to this code, and only the second of those is a deferral marker. -pub fn split_comment_line(line: &str, in_block: &mut bool) -> (String, String) { - let mut code = String::new(); - let mut comment = String::new(); - let bytes: Vec = line.chars().collect(); - let mut i = 0; - let mut in_str = false; - while i < bytes.len() { - if *in_block { - if bytes[i] == '*' && bytes.get(i + 1) == Some(&'/') { - *in_block = false; - i += 2; - } else { - comment.push(bytes[i]); - i += 1; - } - continue; - } - if in_str { - code.push(bytes[i]); - if bytes[i] == '\\' { - if let Some(c) = bytes.get(i + 1) { - code.push(*c); - } - i += 2; - continue; - } - if bytes[i] == '"' { - in_str = false; - } - i += 1; - continue; - } - if bytes[i] == '/' && bytes.get(i + 1) == Some(&'*') { - *in_block = true; - i += 2; - continue; - } - if bytes[i] == '/' && bytes.get(i + 1) == Some(&'/') { - comment.extend(bytes[i + 2..].iter()); - break; - } - if bytes[i] == '"' { - in_str = true; - code.push(bytes[i]); - i += 1; - continue; +/// [`LexState`] is the whole answer: an open block comment AND an open literal. Carrying only the +/// block flag is the defect this trait exists to end — the continuation line of a multi-line +/// string (`"{root}/*"` after a `\` line end) was read from a clean state, its `/*` opened a block +/// comment, and no `*/` ever came: the rest of `crates/busbar-kernel/src/plugin_routes.rs` (292 of +/// its 564 lines) read as comment to every gate built on this scanner. +/// +/// `bool` remains a carrier only so a caller not yet moved to [`LexState`] still compiles. It holds +/// the block comment and NOTHING ELSE, so it still loses an open literal at a line end and still +/// inherits the defect; every caller that can say `LexState::default()` should. +pub trait CommentCarry { + fn load(&self) -> LexState; + fn store(&mut self, st: LexState); +} + +impl CommentCarry for LexState { + fn load(&self) -> LexState { + *self + } + fn store(&mut self, st: LexState) { + *self = st; + } +} + +impl CommentCarry for bool { + fn load(&self) -> LexState { + LexState { + block: u32::from(*self), + open: None, } - code.push(bytes[i]); - i += 1; } + fn store(&mut self, st: LexState) { + *self = st.block > 0; + } +} + +/// One line split into **(code, comment)**, read by THE ONE LEXER (`lex_line`) — the same +/// classification [`blank_code`] reads — with the comment text HANDED BACK rather than dropped. +/// +/// Anything absent from `code` is present in `comment` or is a comment's own `//`, `/*` or `*/`. +/// String literals stay verbatim in the CODE half, so a `//` inside a `"…"` is not a comment — which +/// is precisely the distinction [`crate::gates::no_deferral`]'s Class-B comment tags stand on. A +/// `TODO(migrate):` inside a `format!("# TODO(migrate): {t}")` is **data the program emits**, not a +/// label its author attached to this code, and only the second of those is a deferral marker. +/// +/// `carry` holds what the previous line left open; see [`CommentCarry`] for why it must be a +/// [`LexState`]. Raw strings (`r#"…"#`), byte strings, escapes, char literals (`'"'`) and lifetimes +/// (`'a`) are told apart exactly as [`blank_code`] tells them apart, and block comments nest. +pub fn split_comment_line(line: &str, carry: &mut impl CommentCarry) -> (String, String) { + let chars: Vec = line.chars().collect(); + let mut code = String::new(); + let mut comment = String::new(); + let mut at = 0; + let mut st = carry.load(); + lex_line(&chars, &mut st, |class, n| { + let run = &chars[at..at + n]; + match class { + Class::Code | Class::Literal => code.extend(run), + Class::Comment => comment.extend(run), + Class::CommentMark => {} + } + at += n; + }); + carry.store(st); (code, comment) } -/// One line with `//` and `/* */` comments removed. `in_block` carries block-comment state across -/// lines. String literals are preserved verbatim, so a `//` inside a `"…"` is not a comment. -pub fn strip_comment_line(line: &str, in_block: &mut bool) -> String { - split_comment_line(line, in_block).0 +/// One line with `//` and `/* */` comments removed. `carry` carries the comment and literal state +/// across lines ([`CommentCarry`]). String literals are preserved verbatim, so a `//` inside a +/// `"…"` is not a comment. +pub fn strip_comment_line(line: &str, carry: &mut impl CommentCarry) -> String { + split_comment_line(line, carry).0 } /// EVERY FILE WHOSE MODULE IS DECLARED UNDER A TEST-ONLY `cfg`, among `files` (repo-relative path, diff --git a/xtask/tests/infra.rs b/xtask/tests/infra.rs index 61f9dee334..63dbe24489 100644 --- a/xtask/tests/infra.rs +++ b/xtask/tests/infra.rs @@ -490,6 +490,66 @@ fn strip_comment_line_keeps_string_literals_intact() { ); } +#[test] +fn a_comment_opener_inside_a_continued_string_is_string() { + // `crates/busbar-kernel/src/plugin_routes.rs`'s shape: a string continued with `\`, whose next + // line holds `/*`. The stripper reset its in-string flag per line, read that `/*` as a block + // comment that never closed, and every later line of the file came back empty (audit xtask + // A2 HIGH 1, H1 HIGH 2). + let src = "fn confine(root: &str) -> String {\n format!(\"confined to \\\n \ + {root}/*\")\n}\nfn after() { todo!() }\n"; + let mut st = scan::LexState::default(); + let code: Vec = src + .lines() + .map(|l| scan::strip_comment_line(l, &mut st)) + .collect(); + assert_eq!( + code[2], " {root}/*\")", + "the continuation is string, not comment" + ); + assert_eq!( + code[4], "fn after() { todo!() }", + "the code after it is still code" + ); + assert_eq!( + st, + scan::LexState::default(), + "nothing is left open at end of file" + ); + let prod = scan::production_lines(src); + assert!( + prod.contains(&(5, "fn after() { todo!() }".to_string())), + "production_lines must hand back the line after the string: {prod:?}" + ); +} + +#[test] +fn the_comment_splitter_reads_raw_strings_chars_lifetimes_and_nesting() { + let mut st = scan::LexState::default(); + let mut split = |l: &str| scan::split_comment_line(l, &mut st); + // A raw string: `"` does not close it, `\` is not an escape, `/*` and `//` inside are string. + assert_eq!( + split("let r = r#\"a \\\" /* \"# ; // c"), + ("let r = r#\"a \\\" /* \"# ; ".to_string(), " c".to_string()) + ); + // ...and it spans lines. + assert_eq!(split("let r = r#\"one \"").0, "let r = r#\"one \""); + assert_eq!(split("/* still raw \"# ; x").0, "/* still raw \"# ; x"); + // A char literal holding `"` opens no string; a lifetime is not a char literal. + assert_eq!( + split("let q = '\"'; f::<'a>(); // c").0, + "let q = '\"'; f::<'a>(); " + ); + // A byte string with an escaped quote, then a comment. + assert_eq!( + split("let b = b\"\\\"/*\"; // c").0, + "let b = b\"\\\"/*\"; " + ); + // Block comments nest: the first `*/` closes only the inner one. + assert_eq!(split("a /* x /* y */ z").0, "a "); + assert_eq!(split("w */ b"), (" b".to_string(), "w ".to_string())); +} + // ── planes ────────────────────────────────────────────────────────────────────────────────────── #[test] From 13cb80a26172264743d2e4495e45c939b18d4355 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:52:24 -0700 Subject: [PATCH 38/59] register: the fidelity-rule byte moves of the Chat, Responses and Gemini codecs (row-159 L3#10) Three accepted-differences entries (FID-CHAT, FID-RESPONSES, FID-GEMINI), each citing the LLM dialect fidelity rule (docs/design/BUSBAR-1.6.0.md:4769, :4771, :4774) and, per move, the 1.5.5 and served path:line, with their CHANGELOG lines. Two moves the rule does not cover are restored to 1.5.5 instead: a refusal reaches a Gemini client as OTHER (v1.5.5 proto/gemini/mod.rs:771), and an Imagen :predict answer carries no usageMetadata (v1.5.5 handlers/gemini.rs:474-492). --- CHANGELOG.md | 10 +++ .../src/codec/gemini/handler.rs | 17 ++--- .../busbar-plane-llm/src/codec/gemini/mod.rs | 10 ++- .../src/codec/gemini/tests/handler_tests.rs | 28 ++----- .../codec/gemini/tests/ir_mapping_tests.rs | 8 +- .../src/codec/gemini/tests/tests.rs | 7 +- .../shadow-oracle/accepted-differences.json | 75 +++++++++++++++++++ 7 files changed, 112 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f12b2eac8..75af599f25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -950,6 +950,16 @@ each dialect translates, field by field, is listed in the generated ToolUse input and ToolResult content); 1.5.5 showed only text blocks. - A Bedrock response served from an Anthropic upstream carries `usage.cacheDetails`, the per-TTL split of the cache-write tokens (additive: billed counts are unchanged). +- A translated OpenAI Chat answer carries a model refusal in `message.refusal` / `delta.refusal` + and the serving `service_tier`, an image answer omits `created` when the upstream sent none, and + a translated request keeps a `developer` system prompt in the `developer` role. +- A translated OpenAI Responses request keeps a `developer` system prompt as a `developer` input + item and sends a JSON tool result as the `function_call_output` text, and a translated Responses + stream ends at `response.failed` and names the serving `service_tier`. +- A translated Gemini request or answer carries `functionCall.id` / `functionResponse.id`, a failed + tool result as `functionResponse.response.error`, a `fileData.mimeType`, the caller's + transcription prompt and temperature, and reads Vertex `createTime`, a `functionResponse.id` as + the call id, and the image content-policy stops as safety stops. - A buffered OpenAI chat response carries its citations as nested `url_citation` annotations, the shape the published Chat Completions schema defines. - On a same-dialect route the caller receives every upstream response header except hop-by-hop diff --git a/crates/busbar-plane-llm/src/codec/gemini/handler.rs b/crates/busbar-plane-llm/src/codec/gemini/handler.rs index d917387928..9366754fcc 100644 --- a/crates/busbar-plane-llm/src/codec/gemini/handler.rs +++ b/crates/busbar-plane-llm/src/codec/gemini/handler.rs @@ -407,18 +407,11 @@ pub fn write_image_response(r: &crate::codec::ir::image::ImageResp) -> WireBody p }) .collect(); - let mut body = json!({ (FIELD_PREDICTIONS): predictions }); - // Re-emit the token usage the response reader parses (`read_image_response` captures - // `usageMetadata` for token-metered gemini image models). The old writer dropped it, so a - // token-metered gemini->gemini image response lost its usage in the client-facing body — - // asymmetric with the OpenAI image writer. Emitted only when the IR carries usage. - if let Some(u) = &r.usage { - body[FIELD_USAGE_METADATA] = json!({ - (FIELD_PROMPT_TOKEN_COUNT): u.input, - (FIELD_CANDIDATES_TOKEN_COUNT): u.output, - (FIELD_TOTAL_TOKEN_COUNT): u.input.saturating_add(u.output), - }); - } + // Imagen's `:predict` response is `{predictions}` alone: its `PredictResponse` has no + // `usageMetadata` member, so none is written, exactly as 1.5.5 wrote it (v1.5.5 + // `crates/busbar/src/handlers/gemini.rs:474-492`). A foreign answer's token usage has no Imagen + // member to map to. + let body = json!({ (FIELD_PREDICTIONS): predictions }); WireBody::json(SlabBytes::from( serde_json::to_vec(&body).unwrap_or_default(), )) diff --git a/crates/busbar-plane-llm/src/codec/gemini/mod.rs b/crates/busbar-plane-llm/src/codec/gemini/mod.rs index e11c04400e..f8f550b789 100644 --- a/crates/busbar-plane-llm/src/codec/gemini/mod.rs +++ b/crates/busbar-plane-llm/src/codec/gemini/mod.rs @@ -1592,10 +1592,12 @@ fn write_gemini_stop_reason(reason: crate::codec::ir::IrStopReason) -> &'static match reason { S::EndTurn | S::StopSequence | S::ToolUse => GEMINI_FINISH_STOP, S::MaxTokens => GEMINI_FINISH_MAX_TOKENS, - // A refusal is the model declining on policy grounds — Gemini's SAFETY stop, not an - // unenumerated OTHER (IR audit GEM-14). - S::Safety | S::Refusal => GEMINI_FINISH_SAFETY, - S::Error | S::PauseTurn | S::Other => GEMINI_FINISH_OTHER, + S::Safety => GEMINI_FINISH_SAFETY, + // A refusal (the model declining) has no Gemini finishReason: it is written as the native + // unenumerated `OTHER`, exactly as 1.5.5 wrote it (v1.5.5 + // `crates/busbar/src/proto/gemini/mod.rs:771`). `SAFETY` names a safety filter, which a + // refusal is not, so writing it would substitute a value the source never stated. + S::Refusal | S::Error | S::PauseTurn | S::Other => GEMINI_FINISH_OTHER, } } diff --git a/crates/busbar-plane-llm/src/codec/gemini/tests/handler_tests.rs b/crates/busbar-plane-llm/src/codec/gemini/tests/handler_tests.rs index 8bc24feb64..76f5f1f1e2 100644 --- a/crates/busbar-plane-llm/src/codec/gemini/tests/handler_tests.rs +++ b/crates/busbar-plane-llm/src/codec/gemini/tests/handler_tests.rs @@ -838,10 +838,11 @@ fn transcription_response_reads_token_usage() { ); } -// L1: a token-metered gemini image response must re-emit `usageMetadata` in the client-facing body -// (asymmetric with the OpenAI image writer before). Fails pre-fix: the writer dropped usage. +// An Imagen `:predict` response is `{predictions}` alone, as 1.5.5 wrote it (v1.5.5 +// `crates/busbar/src/handlers/gemini.rs:474-492`): Imagen's `PredictResponse` has no `usageMetadata`, +// so a foreign answer's token usage is not written into one. RED while the writer invented it. #[test] -fn image_response_writer_round_trips_usage() { +fn image_response_writer_writes_no_usage_metadata() { let resp = crate::codec::ir::image::ImageResp { images: vec![busbar_contract::media::ImageOutput { b64: Some("aGVsbG8=".into()), @@ -856,25 +857,10 @@ fn image_response_writer_round_trips_usage() { }; let wb = write_image_response(&resp); let v: Value = serde_json::from_slice(&wb.bytes).unwrap(); + assert!(v.get("usageMetadata").is_none(), "{v}"); assert_eq!( - v.pointer("/usageMetadata/promptTokenCount"), - Some(&json!(10)), - "image usage must be emitted: {v}" - ); - assert_eq!( - v.pointer("/usageMetadata/candidatesTokenCount"), - Some(&json!(5)) - ); - // And it round-trips back through the response reader instead of being dropped. - let back = read_image_response(&wb.bytes).expect("re-read"); - assert_eq!( - back.usage, - Some(busbar_contract::billing::TokenUsage { - input: 10, - output: 5, - ..Default::default() - }), - "image usage must round-trip through write->read" + v.pointer("/predictions/0/bytesBase64Encoded"), + Some(&json!("aGVsbG8=")) ); } diff --git a/crates/busbar-plane-llm/src/codec/gemini/tests/ir_mapping_tests.rs b/crates/busbar-plane-llm/src/codec/gemini/tests/ir_mapping_tests.rs index 7a86b6b1ad..8c488093c7 100644 --- a/crates/busbar-plane-llm/src/codec/gemini/tests/ir_mapping_tests.rs +++ b/crates/busbar-plane-llm/src/codec/gemini/tests/ir_mapping_tests.rs @@ -328,16 +328,18 @@ fn gem12_audio_token_details_reach_openai() { assert_eq!(out["usage"]["completion_tokens"], json!(8), "{out}"); } -/// GEM-14: a refusal reaches a Gemini client as its policy stop `SAFETY`, not `OTHER`. +/// A refusal reaches a Gemini client as the native unenumerated `OTHER`, as in 1.5.5 (v1.5.5 +/// `crates/busbar/src/proto/gemini/mod.rs:771`): Gemini has no refusal stop, and `SAFETY` names a +/// safety filter. RED while the writer wrote `SAFETY`. #[test] -fn gem14_refusal_is_safety_for_a_gemini_client() { +fn a_refusal_is_other_for_a_gemini_client() { let body = json!({"id": "msg_1", "type": "message", "role": "assistant", "model": "m", "content": [{"type": "text", "text": "no"}], "stop_reason": "refusal", "stop_sequence": null, "usage": {"input_tokens": 1, "output_tokens": 1}}); let out = xresp("anthropic", "gemini", &body); assert_eq!( out["candidates"][0]["finishReason"], - json!("SAFETY"), + json!("OTHER"), "{out}" ); } diff --git a/crates/busbar-plane-llm/src/codec/gemini/tests/tests.rs b/crates/busbar-plane-llm/src/codec/gemini/tests/tests.rs index a7df7cfc21..2072b7c1c4 100644 --- a/crates/busbar-plane-llm/src/codec/gemini/tests/tests.rs +++ b/crates/busbar-plane-llm/src/codec/gemini/tests/tests.rs @@ -1498,14 +1498,15 @@ fn test_response_identity_roundtrip_preserves_id_and_model() { /// F2 conformance: a foreign / novel IR stop_reason (`refusal` from Responses, `error` from /// Cohere) must NOT upper-case-leak into `finishReason` (e.g. "REFUSAL"/"ERROR" are outside /// Gemini's `FinishReason` enum and a strict google-genai client rejects them). `error`/`other` map -/// to the native `OTHER` member and a `refusal` to its policy stop `SAFETY` (IR audit GEM-14), on -/// both the whole-body and streamed paths. +/// to the native `OTHER` member, and so does a `refusal` (Gemini has no refusal stop; 1.5.5 wrote +/// `OTHER`, v1.5.5 `crates/busbar/src/proto/gemini/mod.rs:771`), on both the whole-body and streamed +/// paths. #[test] fn foreign_stop_reason_maps_to_other_not_verbatim() { use crate::codec::ir::IrStopReason as S; let writer = GeminiWriter; for (foreign, native) in [ - (S::Refusal, "SAFETY"), + (S::Refusal, "OTHER"), (S::Error, "OTHER"), (S::Other, "OTHER"), ] { diff --git a/testing/shadow-oracle/accepted-differences.json b/testing/shadow-oracle/accepted-differences.json index 413c090ce6..4832169bb9 100644 --- a/testing/shadow-oracle/accepted-differences.json +++ b/testing/shadow-oracle/accepted-differences.json @@ -2944,6 +2944,81 @@ "by": "2026-12-31" }, "rationale": "DECISION: Part 2 #34, every plugin instance is named busbar--, so `busbar-webrequest` ships as `busbar-hook-webrequest`. CELL: boot.warning|BOOT-W22|boot. BYTES MOVED (golden -> candidate): body.text trust WARN line `plugin=busbar-webrequest file=busbar-webrequest-` -> `plugin=busbar-hook-webrequest file=busbar-hook-webrequest-`. The transform rewrites only those name bytes back to the 1.5.5 name." + }, + { + "id": "FID-CHAT translated Chat answers and requests carry the Chat members the IR maps", + "kind": "improvement", + "changelog": "A translated OpenAI Chat answer carries a model refusal in `message.refusal` / `delta.refusal` and the serving `service_tier`, an image answer omits `created` when the upstream sent none, and a translated request keeps a `developer` system prompt in the `developer` role.", + "by": "owner (Matthew Jackson), 2026-10-02: THE LLM DIALECT FIDELITY RULE (docs/design/BUSBAR-1.6.0.md:4768-4774)", + "cells": "^llm\\.fidelity\\|openai\\|(refusal|service-tier|image-created|developer-role)$", + "expected_cells": 0, + "classes": [ + "body" + ], + "rationale": "Pre-approved by BUSBAR-1.6.0.md:4769 (\"Every LLM dialect covers 100% of its own protocol ... Across dialects, we translate what maps and drop what doesn't\"), :4771 (TRANSLATE: a pair translates every slot both sides map) and :4774 (\"Byte moves vs 1.5.5 that follow from this rule are pre-approved; each gets a register entry citing it. Billing never moves.\"). Each move is a Chat member the IR maps, written where 1.5.5 substituted or dropped it; none moves a billed count (the ledger reads the egress reader, never these bytes). (1) Refusal: an IR refusal stop's text is written to the Chat `refusal` member, `content: null` (served crates/busbar-plane-llm/src/codec/openai_chat/writer.rs:1137-1147 buffered, :729-733 stream); 1.5.5 wrote the text into `content` (v1.5.5 crates/busbar/src/proto/openai_chat/writer.rs:826-833). (2) `service_tier`: the tier that served the answer (served writer.rs:890-898 stream, :1338-1346 buffered); 1.5.5 wrote none. (3) Image `created`: emitted only when the upstream sent one (served crates/busbar-plane-llm/src/codec/openai_chat/handler.rs:734-738); 1.5.5 wrote `r.created.unwrap_or(0)`, a 1970 timestamp (v1.5.5 crates/busbar/src/handlers/openai.rs:782). (4) `developer` role: a Responses/Chat `developer` system prompt reaches a Chat upstream as `role: developer` (served writer.rs:75-90, IR-14); 1.5.5 wrote `role: system` (v1.5.5 crates/busbar/src/proto/openai_chat/writer.rs:28). No oracle cell sends any of these to a Chat door or upstream: the cells are OWED, `cells` names them and expected_cells is 0. The premise probes the existing Chat-door llm cell.", + "premise": { + "cell": "llm|openai|anthropic|request|ok", + "status": 200 + }, + "signoff": { + "who": "owner (Matthew Jackson)", + "when": "2026-10-02", + "ruling": "THE LLM DIALECT FIDELITY RULE (owner 2026-10-02, standing, no per-item sign-off), docs/design/BUSBAR-1.6.0.md:4768-4774" + }, + "expires": { + "baseline": "1.5.5", + "by": "2026-12-31" + } + }, + { + "id": "FID-RESPONSES translated Responses requests and answers carry the Responses members the IR maps", + "kind": "improvement", + "changelog": "A translated OpenAI Responses request keeps a `developer` system prompt as a `developer` input item and sends a JSON tool result as the `function_call_output` text, and a translated Responses stream ends at `response.failed` and names the serving `service_tier`.", + "by": "owner (Matthew Jackson), 2026-10-02: THE LLM DIALECT FIDELITY RULE (docs/design/BUSBAR-1.6.0.md:4768-4774)", + "cells": "^llm\\.fidelity\\|responses\\|(developer-item|json-tool-output|failed-terminal|service-tier)$", + "expected_cells": 0, + "classes": [ + "body" + ], + "rationale": "Pre-approved by BUSBAR-1.6.0.md:4769 (\"Every LLM dialect covers 100% of its own protocol ... Across dialects, we translate what maps and drop what doesn't\"), :4771 (TRANSLATE: a pair translates every slot both sides map) and :4774 (\"Byte moves vs 1.5.5 that follow from this rule are pre-approved; each gets a register entry citing it. Billing never moves.\"). Each move is the Responses protocol's own shape for a member the IR maps; none moves a billed count. (1) `developer` system prompt: written as a leading `{role: developer}` input item (served crates/busbar-plane-llm/src/codec/openai_responses/writer.rs:101-116, IR-14); 1.5.5 always wrote `instructions` (v1.5.5 crates/busbar/src/proto/openai_responses/writer.rs:22). (2) A JSON tool result (a Bedrock `{json}` block) is serialized into `function_call_output.output` (served writer.rs:1989-1997); 1.5.5 dropped it (v1.5.5 writer.rs:140, :265 wrote text parts only). (3) After `response.failed` the writer writes nothing (served writer.rs:1320-1322): `response.failed` is the Responses terminal event, and 1.5.5 wrote a `response.completed` after it. (4) `service_tier` on the response object (served writer.rs:1425-1429 stream, :1758-1766 buffered); 1.5.5 wrote none. These four stream/request bytes previously passed only through the blanket `body` class of S-6/S-15, whose rationales do not name them. No oracle cell exercises them: the cells are OWED, `cells` names them and expected_cells is 0. The premise probes the existing Responses-door llm cell.", + "premise": { + "cell": "llm|responses|anthropic|request|ok", + "status": 200 + }, + "signoff": { + "who": "owner (Matthew Jackson)", + "when": "2026-10-02", + "ruling": "THE LLM DIALECT FIDELITY RULE (owner 2026-10-02, standing, no per-item sign-off), docs/design/BUSBAR-1.6.0.md:4768-4774" + }, + "expires": { + "baseline": "1.5.5", + "by": "2026-12-31" + } + }, + { + "id": "FID-GEMINI translated Gemini requests and answers carry the Gemini members the IR maps", + "kind": "improvement", + "changelog": "A translated Gemini request or answer carries `functionCall.id` / `functionResponse.id`, a failed tool result as `functionResponse.response.error`, a `fileData.mimeType`, the caller's transcription prompt and temperature, and reads Vertex `createTime`, a `functionResponse.id` as the call id, and the image content-policy stops as safety stops.", + "by": "owner (Matthew Jackson), 2026-10-02: THE LLM DIALECT FIDELITY RULE (docs/design/BUSBAR-1.6.0.md:4768-4774)", + "cells": "^llm\\.fidelity\\|gemini\\|(call-ids|response-error|filedata-mime|transcription-prompt|create-time|image-policy-stops)$", + "expected_cells": 0, + "classes": [ + "body" + ], + "rationale": "Pre-approved by BUSBAR-1.6.0.md:4769 (\"Every LLM dialect covers 100% of its own protocol ... Across dialects, we translate what maps and drop what doesn't\"), :4771 (TRANSLATE: a pair translates every slot both sides map) and :4774 (\"Byte moves vs 1.5.5 that follow from this rule are pre-approved; each gets a register entry citing it. Billing never moves.\"). Each is a member of Gemini's published GenerateContent shape the IR maps; none moves a billed count. (1) `functionCall.id` and `functionResponse.id` are written (served crates/busbar-plane-llm/src/codec/gemini/writer.rs:219-221, :325-327, GEM-08); 1.5.5 wrote `{name, args}` / `{name, response}` only (v1.5.5 crates/busbar/src/proto/gemini/writer.rs:199). (2) A failed tool result is `functionResponse.response.error` (served writer.rs:305-314, GEM-05); 1.5.5 wrote it as an ordinary response. (3) A URL image is `fileData{fileUri, mimeType}` (served writer.rs:350); 1.5.5 wrote `fileData{fileUri}` (v1.5.5 writer.rs:205). (4) A transcription request carries the caller's `prompt` as its own text part and `temperature` in `generationConfig` (served crates/busbar-plane-llm/src/codec/gemini/handler.rs:196-210); 1.5.5 sent only its fixed instruction (v1.5.5 crates/busbar/src/handlers/gemini.rs:205). (5) Reader: Vertex `createTime` becomes the answer's `created` (served crates/busbar-plane-llm/src/codec/gemini/reader.rs:1552-1577); 1.5.5 set `created: None` (v1.5.5 crates/busbar/src/proto/gemini/reader.rs:1138). (6) Reader: a `functionResponse` pairs to its call's id (served reader.rs:438-443, GEM-01); 1.5.5 used the function name as `tool_use_id` (v1.5.5 reader.rs:362-365). (7) Reader: `IMAGE_PROHIBITED_CONTENT` / `IMAGE_RECITATION` stops and an `IMAGE_SAFETY` prompt block read as safety stops (served crates/busbar-plane-llm/src/codec/gemini/mod.rs:1556-1557, :1581), siblings of the `IMAGE_SAFETY` stop 1.5.5 already read as safety (v1.5.5 crates/busbar/src/proto/gemini/mod.rs:733). Not covered by this entry, and restored to 1.5.5 instead: a refusal is written `OTHER` again, not `SAFETY`, and an Imagen `:predict` answer carries no `usageMetadata`. No oracle cell exercises these: the cells are OWED, `cells` names them and expected_cells is 0. The premise probes the existing Gemini-door llm cell.", + "premise": { + "cell": "llm|gemini|anthropic|request|ok", + "status": 200 + }, + "signoff": { + "who": "owner (Matthew Jackson)", + "when": "2026-10-02", + "ruling": "THE LLM DIALECT FIDELITY RULE (owner 2026-10-02, standing, no per-item sign-off), docs/design/BUSBAR-1.6.0.md:4768-4774" + }, + "expires": { + "baseline": "1.5.5", + "by": "2026-12-31" + } } ] } From b89d432b661336087630e2a1afdaf8d0ffee9be1 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:55:26 -0700 Subject: [PATCH 39/59] llm codec: no process-global or per-thread state in the entropy seam and the transcription boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - synth_rng kept a 4 KiB thread_local! entropy pool shared by every instance on a thread. Each draw now goes straight to the host's source (busbar_contract::codec::fill_entropy); the module holds nothing. - The OpenAI transcription boundary and its Content-Type were per-process OnceLock draws (with a wall-clock fallback). The boundary is 1.5.5's literal again (v1.5.5 crates/busbar/src/handlers/openai.rs:238, :281), a constant; part_carries_delimiter still fails a body carrying it closed, which is the guarantee against smuggled parts. BUSBAR-1.6.0.md THE DESIGN §2: a plugin holds no process-global state; the one approved per-thread slot is the door's log-capture slot. Row-159 plane-llm L1#4 / L2#6 (entropy pool) and L3#1. --- .../src/codec/openai_chat/handler.rs | 67 +++------- .../codec/openai_chat/tests/handler_tests.rs | 47 +++---- .../busbar-plane-llm/src/codec/synth_rng.rs | 121 +++--------------- .../src/codec/tests/synth_rng_tests.rs | 22 +++- 4 files changed, 71 insertions(+), 186 deletions(-) diff --git a/crates/busbar-plane-llm/src/codec/openai_chat/handler.rs b/crates/busbar-plane-llm/src/codec/openai_chat/handler.rs index cb9b674fd4..afa2c15dd6 100644 --- a/crates/busbar-plane-llm/src/codec/openai_chat/handler.rs +++ b/crates/busbar-plane-llm/src/codec/openai_chat/handler.rs @@ -249,58 +249,29 @@ leaf_op! { TranscriptionReqHandle = read_transcription_request, TranscriptionRespHandle = read_transcription_response; fn egress_request_content_type(&self) -> &'static str { - // write_request rebuilds the multipart form around `transcription_boundary()`, which is - // drawn from entropy rather than hard-coded. Both sides read the same value, so the header - // always names the boundary the body is actually framed with. - static CT: std::sync::OnceLock = std::sync::OnceLock::new(); - CT.get_or_init(|| format!("multipart/form-data; boundary={}", transcription_boundary())) - .as_str() + // write_request rebuilds the multipart form around `TRANSCRIPTION_BOUNDARY`. + TRANSCRIPTION_CONTENT_TYPE } } -/// The multipart boundary the transcription egress form is framed with. +/// The multipart boundary the transcription egress form is framed with: 1.5.5's literal (v1.5.5 +/// `crates/busbar/src/handlers/openai.rs:238`, `:281`), a constant, so the plugin keeps no +/// process-global state for it (BUSBAR-1.6.0.md THE DESIGN §2: "A plugin holds no process-global +/// state"; the per-process `OnceLock` draw it replaces was such state) and the upstream receives the +/// bytes 1.5.5 sent. /// -/// It CANNOT be a literal in the source. The audio part carries raw client-controlled bytes, so a -/// client that knows the delimiter can embed `\r\n--` in its audio, close the file part -/// early, and append parts of its own — e.g. a second `model` field, which a last-occurrence-wins -/// multipart parser upstream honours while busbar bills the model the lane resolved. That reaches -/// busbar cross-protocol (a Gemini-dialect transcription ingress accepts base64 audio and this -/// writer re-frames it for OpenAI egress), so the bytes are attacker-chosen in practice. -/// -/// So the suffix is drawn from the crate's entropy seam ([`crate::codec::synth_rng::fill_entropy`], the -/// same OS-CSPRNG pool the synthesized wire ids use). 16 random bytes → 32 hex chars, giving a -/// 42-char boundary: inside RFC 2046's 70-char limit and drawn only from its `bchars` set. -/// -/// PER PROCESS, NOT PER REQUEST. [`OperationHandler::egress_request_content_type`] declares the -/// Content-Type as a `&'static str` and is called by the engine independently of the body write, so -/// the two can only agree on a boundary that outlives the request — and if they disagree the -/// upstream cannot parse the form at all. A per-process draw already denies the attacker a -/// delimiter they can compute from the source; unpredictability is not the guarantee, though — -/// [`part_carries_delimiter`] fails the request closed even if the value were somehow known. +/// A client that knows the delimiter cannot use it: the audio part carries raw client-controlled +/// bytes, so a payload embedding `\r\n--` could close the file part early and append a +/// second `model` field — and [`part_carries_delimiter`] refuses exactly that, failing the whole body +/// closed. That check, not the delimiter's secrecy, is the guarantee. +pub const TRANSCRIPTION_BOUNDARY: &str = "----busbaraudioMIME"; + +/// The Content-Type the transcription egress form is declared with; names [`TRANSCRIPTION_BOUNDARY`]. +const TRANSCRIPTION_CONTENT_TYPE: &str = "multipart/form-data; boundary=----busbaraudioMIME"; + +/// The transcription egress boundary ([`TRANSCRIPTION_BOUNDARY`]). pub fn transcription_boundary() -> &'static str { - static BOUNDARY: std::sync::OnceLock = std::sync::OnceLock::new(); - BOUNDARY - .get_or_init(|| { - let mut raw = [0u8; 16]; - // Entropy failure is not fatal here: the delimiter check below is what makes the body - // safe, and this value's only other job is to be a legal, unlikely-to-collide token. - // Mix the clock in so a broken CSPRNG does not pin every process to one known string. - if !crate::codec::synth_rng::fill_entropy(&mut raw) { - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0); - raw.copy_from_slice(&nanos.to_le_bytes()); - } - let mut s = String::with_capacity(42); - s.push_str("----busbar"); - for b in raw { - s.push(char::from_digit((b >> 4) as u32, 16).unwrap_or('0')); - s.push(char::from_digit((b & 0x0f) as u32, 16).unwrap_or('0')); - } - s - }) - .as_str() + TRANSCRIPTION_BOUNDARY } /// Does `part` contain the multipart delimiter, i.e. can splicing it into a body framed with @@ -321,7 +292,7 @@ fn part_carries_delimiter(part: &[u8], boundary: &str) -> bool { /// IR → openai multipart transcription request wire (the body of /// [`OpenAiTranscription::write_request`], moved behind the `(transcription, openai)` key — G6 A4b -/// option-a). OpenAI-as-egress rebuilds the multipart form around [`transcription_boundary`]; not on +/// option-a). OpenAI-as-egress rebuilds the multipart form around [`TRANSCRIPTION_BOUNDARY`]; not on /// the harness path (openai is always ingress there), kept for cross-protocol symmetry. /// /// Returns an EMPTY body if any part carries the delimiter. The seam types this write as infallible diff --git a/crates/busbar-plane-llm/src/codec/openai_chat/tests/handler_tests.rs b/crates/busbar-plane-llm/src/codec/openai_chat/tests/handler_tests.rs index b4f5ade5a4..e899d595a0 100644 --- a/crates/busbar-plane-llm/src/codec/openai_chat/tests/handler_tests.rs +++ b/crates/busbar-plane-llm/src/codec/openai_chat/tests/handler_tests.rs @@ -315,11 +315,11 @@ fn transcription_egress_field_strips_crlf_injection() { assert_eq!(text.matches(&delim).count(), 3); } -/// The audio part carries raw client-controlled bytes. With a boundary the client can predict, a -/// payload that embeds `\r\n--` closes the file part early and appends parts of its own — -/// here a second `model` field. Parsers that take the LAST occurrence then run the request against -/// the client's model while the lane bills the model busbar chose. Fails pre-fix (the writer used a -/// hard-coded boundary and spliced the audio bytes in unchecked): the body carried two `model` parts. +/// The audio part carries raw client-controlled bytes. A payload that embeds `\r\n--` +/// would close the file part early and append parts of its own — here a second `model` field, which +/// a last-occurrence-wins parser upstream honours while busbar bills the model it chose. The +/// boundary is 1.5.5's literal, so the client knows it: the writer refuses the body outright (an +/// empty, fail-closed body), and no second `model` part is ever framed. #[test] fn transcription_egress_audio_cannot_smuggle_a_second_model_part() { let smuggled = b"RIFF\r\n------busbaraudioMIME\r\nContent-Disposition: form-data; \ @@ -334,35 +334,20 @@ fn transcription_egress_audio_cannot_smuggle_a_second_model_part() { ..Default::default() }; let out = super::super::super::leaf_codec::transcription_write_request("openai", &ir); - let text = String::from_utf8_lossy(&out); - // The smuggled text may still sit INSIDE the audio bytes — harmless, since it is no longer - // introduced by a delimiter the upstream parser recognises. What must not exist is a SECOND - // part: exactly one `model` field may be framed by the live boundary. - let model_part = format!( - "--{}\r\nContent-Disposition: form-data; name=\"model\"", - transcription_boundary() - ); - assert_eq!( - text.matches(&model_part).count(), - 1, - "audio bytes must not be able to append a second model part: {text}" + assert!( + out.is_empty(), + "a payload carrying the live delimiter fails the body closed: {}", + String::from_utf8_lossy(&out) ); } -/// The boundary must not be the hard-coded literal a client could copy out of the source: it is -/// drawn from the crate's entropy seam, is a legal RFC 2046 boundary, and the Content-Type the -/// engine sends upstream must name the SAME boundary the body actually uses (otherwise the upstream -/// cannot parse the form at all). +/// The boundary is 1.5.5's literal (v1.5.5 `crates/busbar/src/handlers/openai.rs:238`, `:281`), a +/// constant the plugin holds no state for, and the Content-Type the engine sends upstream names the +/// SAME boundary the body is framed with. RED while it was a per-process `OnceLock` draw. #[test] -fn transcription_boundary_is_entropy_drawn_and_matches_the_declared_content_type() { +fn transcription_boundary_is_1_5_5_s_literal_and_matches_the_declared_content_type() { let b = transcription_boundary(); - assert_ne!(b, "----busbaraudioMIME", "boundary must not be hard-coded"); - assert!(b.len() >= 32 && b.len() <= 70, "boundary length: {b}"); - assert!( - b.bytes() - .all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_'), - "boundary charset: {b}" - ); + assert_eq!(b, "----busbaraudioMIME"); let ct = OpenAiTranscription.egress_request_content_type(); assert_eq!(ct, format!("multipart/form-data; boundary={b}")); @@ -381,8 +366,8 @@ fn transcription_boundary_is_entropy_drawn_and_matches_the_declared_content_type assert!(text.ends_with(&format!("--{b}--\r\n")), "body tail: {text}"); } -/// Unpredictability alone is not the guarantee: a payload that DOES carry the live delimiter (a -/// guess, or a broken CSPRNG) must be refused outright rather than emitted with the parts it +/// The delimiter check is the guarantee: a payload that DOES carry the live delimiter must be +/// refused outright rather than emitted with the parts it /// smuggled. Both framings count — a delimiter mid-payload, and one at the very start (the `\r\n` /// that ends the part headers completes it). #[test] diff --git a/crates/busbar-plane-llm/src/codec/synth_rng.rs b/crates/busbar-plane-llm/src/codec/synth_rng.rs index 6a654d05cc..ebb78bba06 100644 --- a/crates/busbar-plane-llm/src/codec/synth_rng.rs +++ b/crates/busbar-plane-llm/src/codec/synth_rng.rs @@ -1,110 +1,29 @@ -//! Thread-local ENTROPY POOL for synthesized wire ids, filled from the HOST's entropy source. +//! ENTROPY for synthesized wire ids, drawn from the HOST's entropy source on every draw. //! //! Every protocol writer that must invent a shape-correct id when the upstream omitted one //! (`anthropic` `req_…`, `bedrock`/`gemini` request ids, `cohere` v4 uuids, `responses` `resp_…`) -//! needs a fistful of random bytes on the HOT response path. Asking the host for them once PER id -//! costs an OS CSPRNG syscall per response — ~1–2 µs on macOS, and on the anthropic-ingress benchmark -//! that single syscall was the ENTIRE `rb_finish` cost (~2.5 µs p50 of a ~6.6 µs `busbar;dur`). The -//! synthesized ids are non-secret response-correlation strings returned only to the client that made -//! the request; on a single-operator proxy they never cross a trust boundary. So there is no reason -//! to pay a syscall each. +//! asks here for a fistful of random bytes. THE BYTES ARE THE HOST'S (#83a O10; the entropy arrives +//! through the host, never drawn by the plane itself): each draw goes straight to +//! `busbar_contract::codec::fill_entropy`, the host-installed source (the OS CSPRNG, armed by the host +//! where it installs the protocols). A process whose host installed no source serves nothing: the +//! draw reports failure and each caller takes its own entropy-unavailable branch. //! -//! THE BYTES ARE THE HOST'S (#83a O10; the entropy arrives through the host, never drawn by the plane -//! itself). The pool refills through `busbar_contract::codec::fill_entropy`, the host-installed -//! source (the OS CSPRNG, armed by the host where it installs the protocols), [`POOL_BYTES`] at a time, -//! and hands out slices of it. Every byte served is still a fresh host-CSPRNG byte — this is NOT a -//! userspace PRNG substituting for the host generator, it is the exact same entropy source with the -//! call AMORTISED (~one fill per `POOL_BYTES` bytes instead of per id). A process whose host installed -//! no source serves nothing: every draw reports failure and each caller takes its own -//! entropy-unavailable branch. -//! -//! Thread-local so there is no lock and no cross-thread contention on the hot path. A worker thread -//! amortises its first-request fill across the next few hundred requests it serves. -//! -//! FORK SAFETY. A userspace entropy buffer is not fork-safe: a child that inherits this thread's -//! buffer at the same read position would serve the SAME "random" bytes as the parent until its next -//! refill, so both could emit duplicate ids. This is SOUND for busbar as it exists: busbar runs a -//! tokio multi-thread runtime and never `fork()`s a worker that then synthesizes ids (subprocess -//! egress immediately `exec`s a different binary, which does not run this code). The values are -//! non-secret response-correlation strings, so even a post-fork collision is low-harm. If busbar ever -//! adds a fork-based worker model that synthesizes ids in the child, reset this pool in a -//! `pthread_atfork` child handler (or gate `fill` on a cached pid) BEFORE relying on it there. - -use std::cell::RefCell; - -/// Size of the per-thread entropy block. 4 KiB serves ~130 anthropic ids (30 bytes each, before -/// rejection-sampling waste) per host fill — a ~100× reduction in fills — while staying a -/// trivially-bounded stack/TLS cost. Large enough to matter, small enough to never notice. -const POOL_BYTES: usize = 4096; - -struct EntropyPool { - buf: [u8; POOL_BYTES], - /// Next unread byte. `pos == POOL_BYTES` means drained → refill on next draw. - pos: usize, - /// Set if the LAST refill from the host failed; callers translate this into their own - /// entropy-unavailable contract (anthropic OMITS the header, cohere zero-fills, etc.). - healthy: bool, -} - -impl EntropyPool { - fn new() -> Self { - // Start drained so the first draw triggers the initial fill (and surfaces a broken CSPRNG - // immediately, rather than serving a zeroed buffer). - EntropyPool { - buf: [0u8; POOL_BYTES], - pos: POOL_BYTES, - healthy: true, - } - } - - /// Refill the whole block from the host's entropy source. On failure the pool is marked unhealthy - /// and left drained so no stale/zeroed bytes are served. - fn refill(&mut self) { - // A test process's host is the kernel test seam, which arms the same source the composition - // root arms in a shipped binary. - #[cfg(test)] - crate::codec::ensure_test_protocols_registered(); - if busbar_contract::codec::fill_entropy(&mut self.buf) { - self.pos = 0; - self.healthy = true; - } else { - self.pos = POOL_BYTES; - self.healthy = false; - } - } - - /// Fill `out` with fresh host-CSPRNG bytes from the pool, refilling as needed. Returns `false` - /// (filling nothing meaningful) iff the host's source is unavailable. A single `out` larger than - /// the pool is filled across multiple refills. - fn fill(&mut self, out: &mut [u8]) -> bool { - let mut written = 0usize; - while written < out.len() { - if self.pos == POOL_BYTES { - self.refill(); - if !self.healthy { - return false; - } - } - let take = (out.len() - written).min(POOL_BYTES - self.pos); - out[written..written + take].copy_from_slice(&self.buf[self.pos..self.pos + take]); - self.pos += take; - written += take; - } - true - } -} - -thread_local! { - static POOL: RefCell = RefCell::new(EntropyPool::new()); -} - -/// Fill `out` with host-CSPRNG bytes from the thread-local pool: returns `true` on success, `false` -/// iff the host's entropy source is unavailable (in which case `out`'s contents are unspecified and -/// callers must honour their entropy-failure branch). Amortises the host fill across [`POOL_BYTES`] -/// bytes. +//! NO STATE OF ITS OWN. This module used to amortise the host call through a 4 KiB per-thread +//! buffer. A plugin holds no process-global state — what it keeps is per-instance, and the one +//! owner-approved per-thread slot is the door's log-capture slot (BUSBAR-1.6.0.md THE DESIGN §2) — +//! so that buffer is gone: every draw is one host call, and nothing outlives it or is shared +//! between instances on a thread (a dropped-in image carries no copy either). + +/// Fill `out` with host-CSPRNG bytes: returns `true` on success, `false` iff the host's entropy +/// source is unavailable (in which case `out`'s contents are unspecified and callers must honour +/// their entropy-failure branch). #[inline] pub fn fill_entropy(out: &mut [u8]) -> bool { - POOL.with(|p| p.borrow_mut().fill(out)) + // A test process's host is the kernel test seam, which arms the same source the composition + // root arms in a shipped binary. + #[cfg(test)] + crate::codec::ensure_test_protocols_registered(); + busbar_contract::codec::fill_entropy(out) } #[cfg(test)] diff --git a/crates/busbar-plane-llm/src/codec/tests/synth_rng_tests.rs b/crates/busbar-plane-llm/src/codec/tests/synth_rng_tests.rs index e4807d98c8..87c7c295f2 100644 --- a/crates/busbar-plane-llm/src/codec/tests/synth_rng_tests.rs +++ b/crates/busbar-plane-llm/src/codec/tests/synth_rng_tests.rs @@ -1,13 +1,12 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright (C) 2026 Busbar Inc and contributors -//! Tests for `crates/busbar-llm/src/synth_rng.rs`. +//! Tests for `crates/busbar-plane-llm/src/codec/synth_rng.rs`. use super::*; #[test] fn fills_exact_len_and_advances() { - // Two draws that together exceed a fresh pool are still fully served (spans a refill). let mut a = [0u8; 30]; let mut b = [0u8; 30]; assert!(fill_entropy(&mut a)); @@ -18,21 +17,32 @@ fn fills_exact_len_and_advances() { } #[test] -fn draw_larger_than_pool_spans_refills() { - let mut big = vec![0u8; POOL_BYTES * 2 + 7]; +fn a_large_draw_is_served_whole() { + let mut big = vec![0u8; 8199]; assert!(fill_entropy(&mut big)); assert!(big.iter().any(|&x| x != 0)); } #[test] fn many_small_draws_stay_distinct() { - // Exhaust well past one pool block to exercise the refill path under repeated small draws. let mut seen = std::collections::HashSet::new(); for _ in 0..500 { let mut t = [0u8; 24]; assert!(fill_entropy(&mut t)); seen.insert(t); } - // 500 distinct 24-byte draws — no collisions from a stuck/rewound pointer. + // 500 distinct 24-byte draws — no collisions from a stuck or rewound source. assert_eq!(seen.len(), 500); } + +/// The plugin holds no per-thread entropy state (BUSBAR-1.6.0.md THE DESIGN §2: the one approved +/// per-thread slot is the door's log-capture slot). RED while the module kept a `thread_local!` +/// pool: its source names no thread-local. +#[test] +fn the_entropy_seam_keeps_no_thread_local_state() { + let src = include_str!("../synth_rng.rs"); + assert!( + !src.contains(concat!("thread", "_local!")), + "synth_rng must not keep per-thread state" + ); +} From d3166b0d0dc30dd4fa0ee04d2fb7df25877d1739 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:05:05 -0700 Subject: [PATCH 40/59] llm codec: the event-stream drain's linearity is counted in work units, not timed drain_frames_checked_scales_linearly_in_frame_count compared wall-clock times of 1M- and 4M-frame drains and flaked under shared CI load (ModernCI quarantined it). The drain now counts its own buffer work (DrainWork: bytes walked as frames, buffer edits, bytes those edits moved), and every edit of the drained buffer goes through the counting consume_front/clear_all. The test asserts one edit per pass, nothing moved for whole frames, every byte walked once, and total work under 8x from 1k to 4k frames (Law 8: count work, not time). The RED arm runs the old per-frame drain through the same counter: 1k/4k edits and quadratic bytes moved, rejected by the same check. --- .../busbar-plane-llm/src/codec/eventstream.rs | 56 ++++++- .../src/codec/tests/eventstream_tests.rs | 149 ++++++++++-------- 2 files changed, 139 insertions(+), 66 deletions(-) diff --git a/crates/busbar-plane-llm/src/codec/eventstream.rs b/crates/busbar-plane-llm/src/codec/eventstream.rs index c0ed249d5c..cf56592264 100644 --- a/crates/busbar-plane-llm/src/codec/eventstream.rs +++ b/crates/busbar-plane-llm/src/codec/eventstream.rs @@ -93,8 +93,56 @@ pub enum DrainStatus { /// malformed-prelude remainder (the malformed branch breaks before the push). Pass `None` on the /// cross-protocol path, which re-encodes and needs no verbatim copy. pub fn drain_frames_checked( + buf: &mut Vec, + consumed_sink: Option<&mut Vec>, +) -> (Vec<(String, Vec)>, DrainStatus, usize) { + drain_frames_counted(buf, consumed_sink, &mut DrainWork::default()) +} + +/// The work one drain pass did on its buffer, counted where it is done — the measure the +/// complexity test holds the drain to, instead of wall-clock time (Law 8: a measurement under a +/// shared load must count work units, not time). +/// +/// `scanned` is the bytes walked as complete frames; `edits` the buffer edits; `shifted` the bytes +/// those edits moved (a front `drain` memmoves the whole remaining tail). EVERY edit of the drained +/// buffer goes through [`consume_front`] / [`clear_all`], which count it, so a pass that edits per +/// frame shows up as `edits == frames` and a `shifted` quadratic in the frame count. +// Production passes discard the count; only the complexity test reads it. +#[cfg_attr(not(test), allow(dead_code))] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct DrainWork { + pub frames: usize, + pub scanned: usize, + pub edits: usize, + pub shifted: usize, +} + +impl DrainWork { + /// Every byte the pass touched: walked as frames, plus moved by an edit. + #[cfg(test)] + pub(crate) fn total(&self) -> usize { + self.scanned + self.shifted + } +} + +/// Remove the first `n` bytes of `buf`, counting the edit and the tail bytes it moves. +fn consume_front(buf: &mut Vec, n: usize, work: &mut DrainWork) { + work.edits += 1; + work.shifted += buf.len() - n; + buf.drain(..n); +} + +/// Empty `buf`, counting the edit (it moves nothing). +fn clear_all(buf: &mut Vec, work: &mut DrainWork) { + work.edits += 1; + buf.clear(); +} + +/// [`drain_frames_checked`], counting its buffer work into `work`. +pub(crate) fn drain_frames_counted( buf: &mut Vec, mut consumed_sink: Option<&mut Vec>, + work: &mut DrainWork, ) -> (Vec<(String, Vec)>, DrainStatus, usize) { let mut out = Vec::new(); let mut status = DrainStatus::Ok; @@ -137,14 +185,14 @@ pub fn drain_frames_checked( sink.extend_from_slice(&rem[..total_len]); } pos += total_len; + work.frames += 1; + work.scanned += total_len; } // Malformed: the stream is unrecoverable, so the valid prefix AND the remainder both go — // `valid_consumed` still reports only the prefix, exactly as before. match status { - DrainStatus::Ok => { - buf.drain(..pos); - } - DrainStatus::MalformedPrelude => buf.clear(), + DrainStatus::Ok => consume_front(buf, pos, work), + DrainStatus::MalformedPrelude => clear_all(buf, work), } (out, status, pos) } diff --git a/crates/busbar-plane-llm/src/codec/tests/eventstream_tests.rs b/crates/busbar-plane-llm/src/codec/tests/eventstream_tests.rs index efb2ad68ec..b94dcd3227 100644 --- a/crates/busbar-plane-llm/src/codec/tests/eventstream_tests.rs +++ b/crates/busbar-plane-llm/src/codec/tests/eventstream_tests.rs @@ -864,74 +864,99 @@ fn a_malformed_prelude_after_valid_frames_still_clears_and_reports_the_prefix() assert!(buf.is_empty(), "the WHOLE buffer clears, prefix included"); } +/// A buffer of `n` complete small frames. +fn frames_buffer(n: usize) -> Vec { + let mut buf = Vec::new(); + for i in 0..n { + let payload = format!("{{\"i\":{i}}}"); + buf.extend_from_slice(&encode_frame("contentBlockDelta", payload.as_bytes())); + } + buf +} + +/// The drain's work grew linearly from `n` to `4n` frames: under 8× — the log-scale midpoint of +/// linear's ~4× and quadratic's ~16× — in COUNTED work units, so no scheduler, cache or neighbour +/// load can move the verdict (Law 8). +fn scales_linearly(small: DrainWork, big: DrainWork) -> bool { + big.total() < small.total() * 8 +} + +/// The drain as it was before the index-tracking rewrite: one front `drain` PER FRAME, each +/// memmoving the whole remaining tail. The deliberately quadratic reference the checker must reject. +fn per_frame_drain(buf: &mut Vec) -> DrainWork { + let mut work = DrainWork::default(); + while buf.len() >= PRELUDE_LEN { + let total_len = u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]) as usize; + if buf.len() < total_len { + break; + } + work.frames += 1; + work.scanned += total_len; + consume_front(buf, total_len, &mut work); + } + work +} + /// Complexity regression: `drain_frames_checked` used `Vec::drain(..total_len)` PER FRAME, which -/// memmoves the entire remaining tail on every call — O(frames × bytes) for a buffer of many -/// small frames. The index-tracking rewrite does ONE buffer edit for the whole pass, O(bytes). -/// A ratio test (not an absolute wall-clock threshold) so it stays machine-independent: quadratic -/// predicts ~16× for a 4× frame-count increase; linear predicts ~4×. -/// -/// WEAKEST TEST IN THIS FILE: if `t(1000)` is too fast to be -/// above timer-granularity noise, the ratio is meaningless. The assertion checks that floor first -/// and panics with a clear message rather than silently passing on noise. +/// memmoves the entire remaining tail on every call — O(frames × bytes) for a buffer of many small +/// frames. The index-tracking rewrite does ONE buffer edit for the whole pass, O(bytes). /// -/// MIN-OF-`TRIALS`, not a single sample: contention from other tests running concurrently in the -/// same `cargo test --workspace` invocation can only ever ADD delay to one `Instant::now()` / -/// `elapsed()` pair — a scheduler preemption, a cache eviction from a neighboring thread, GC-style -/// jemalloc housekeeping — never subtract below the true uncontended cost of the drain. So the -/// MINIMUM across several independent trials converges toward that true cost regardless of how -/// loaded the machine is, while a single sample has no such guarantee and can land on an -/// arbitrarily contended instant for either data point, skewing the ratio in either -/// direction. This is a structural hardening of the MEASUREMENT, not a widened tolerance: the -/// asserted ratio, its rationale, and the floor check are all unchanged. +/// COUNTED, NOT TIMED. The previous form compared wall-clock times of 1M- and 4M-frame drains and +/// flaked under shared CI load (ModernCI quarantined it): a ratio of two timings measures the +/// machine as much as the code. The drain now counts its own buffer work ([`DrainWork`]: bytes +/// walked as frames, buffer edits, bytes those edits moved), and every buffer edit goes through the +/// counting [`consume_front`] / [`clear_all`]. So: one pass makes exactly ONE edit, moves nothing +/// when it consumes whole frames, walks every byte once, and its total work grows ~4× from n to 4n +/// frames — while the per-frame drain makes n edits, moves O(n²) bytes and fails the same check. #[test] fn drain_frames_checked_scales_linearly_in_frame_count() { - /// Independent trials per data point; the reported duration is the minimum observed. Three - /// (not more) because at 1M/4M frames each measurement is hundreds of milliseconds of - /// DRAM-bound work — scheduler blips that dominate a microsecond sample are relative - /// rounding error here, and min-of-N only needs ONE uncontended trial to converge. - const TRIALS: u32 = 3; - - fn bench(n: usize) -> std::time::Duration { - let mut best: Option = None; - for _ in 0..TRIALS { - let mut buf = Vec::new(); - for i in 0..n { - let payload = format!("{{\"i\":{i}}}"); - buf.extend_from_slice(&encode_frame("contentBlockDelta", payload.as_bytes())); - } - let start = std::time::Instant::now(); - let (_, status, _) = drain_frames_checked(&mut buf, None); - assert_eq!(status, DrainStatus::Ok); - let elapsed = start.elapsed(); - best = Some(match best { - Some(b) if b <= elapsed => b, - _ => elapsed, - }); - } - best.expect("TRIALS is a nonzero constant, so the loop runs at least once") - } - // 1M and 4M frames (~30 MB and ~120 MB of buffer): BOTH data points are far past every - // cache level, so the whole measurement lives in one memory regime (DRAM). The earlier - // 8k/32k sizing straddled the L2→L3 boundary, which made genuinely linear code measure - // 6.4–8.9x on real CI runners and forced arbitrary threshold tuning; at these sizes the - // linear prediction really is ~4x and quadratic really is ~16x, with nothing in between - // to hand-wave about. - let t1m = bench(1_000_000); + let run = |n: usize| { + let mut buf = frames_buffer(n); + let bytes = buf.len(); + let mut work = DrainWork::default(); + let (frames, status, consumed) = drain_frames_counted(&mut buf, None, &mut work); + assert_eq!(status, DrainStatus::Ok); + assert_eq!(frames.len(), n); + assert_eq!(consumed, bytes); + assert!(buf.is_empty()); + assert_eq!(work.frames, n); + assert_eq!(work.scanned, bytes, "every byte is walked once"); + assert_eq!(work.edits, 1, "one buffer edit for the whole pass"); + assert_eq!(work.shifted, 0, "consuming whole frames moves nothing"); + work + }; + let (small, big) = (run(1_000), run(4_000)); assert!( - t1m >= std::time::Duration::from_millis(1), - "min-of-{TRIALS} t(1M) = {t1m:?} is too fast to be above timer-granularity noise; \ - the ratio below would be meaningless, so this assertion is WITHDRAWN rather than \ - tuned" - ); - let t4m = bench(4_000_000); - // < 8x: the log-scale midpoint of linear's ~4x and quadratic's ~16x — equal headroom to - // both hypotheses, not a constant tuned to any particular runner's measurement. + scales_linearly(small, big), + "drain_frames_checked did {} work units for 1k frames and {} for 4k (ratio {:.1}x; linear \ + predicts ~4x, quadratic ~16x)", + small.total(), + big.total(), + big.total() as f64 / small.total() as f64 + ); + + // A trailing partial frame is kept and moved once, not once per frame. + let mut buf = frames_buffer(1_000); + buf.extend_from_slice(&frames_buffer(1)[..PRELUDE_LEN + 2]); + let mut work = DrainWork::default(); + let (_, status, _) = drain_frames_counted(&mut buf, None, &mut work); + assert_eq!(status, DrainStatus::Ok); + assert_eq!(work.edits, 1); + assert_eq!(work.shifted, PRELUDE_LEN + 2, "only the partial tail moves"); +} + +/// The RED arm: the per-frame drain this test exists to catch makes one edit per frame and moves a +/// quadratic number of bytes, and the same linearity check rejects it. +#[test] +fn the_linearity_check_rejects_a_per_frame_drain() { + let small = per_frame_drain(&mut frames_buffer(1_000)); + let big = per_frame_drain(&mut frames_buffer(4_000)); + assert_eq!((small.edits, big.edits), (1_000, 4_000)); assert!( - t4m < t1m * 8, - "drain_frames_checked scaled worse than linear: min-of-{TRIALS} t(1M)={t1m:?} \ - min-of-{TRIALS} t(4M)={t4m:?} (ratio {:.1}x, quadratic predicts ~16x, linear \ - predicts ~4x)", - t4m.as_secs_f64() / t1m.as_secs_f64() + !scales_linearly(small, big), + "a per-frame drain must fail the check: {} vs {} work units", + small.total(), + big.total() ); } From e00f1d1ca8ee43442ac4a330fb4f785a0bfa8ae2 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:33:42 -0700 Subject: [PATCH 41/59] llm codec: Gemini moderation and rerank always target :generateContent, as in 1.5.5 The Gemini upstream_path fallback made every verb without an action stream-aware, so a streamed moderation or rerank request targeted :streamGenerateContent. 1.5.5 made only Chat, Transcription and Speech stream-aware and sent Moderation and Rerank to :generateContent (v1.5.5 crates/busbar/src/handlers/gemini.rs:52-63). F25 map row 558 (the attempt-path parity REHOME). --- .../src/codec/gemini/handler.rs | 13 ++++--- .../src/codec/gemini/tests/handler_tests.rs | 36 +++++++++++++++++++ 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/crates/busbar-plane-llm/src/codec/gemini/handler.rs b/crates/busbar-plane-llm/src/codec/gemini/handler.rs index d917387928..79369c97e5 100644 --- a/crates/busbar-plane-llm/src/codec/gemini/handler.rs +++ b/crates/busbar-plane-llm/src/codec/gemini/handler.rs @@ -69,11 +69,14 @@ impl RequestHandler for GeminiRequestHandler { if let Some(action) = busbar_contract::codec::path_of(ACTIONS, ctx.operation) { return format!("{base}/{m}:{action}"); } - // Chat + audio understanding/TTS all ride generateContent (stream-aware). So does every - // verb with no cell above, which is unreachable in practice and answers the same thing — - // the pre-1.6.0 answer, verbatim. `stream` is already false for those, because a shape that - // cannot stream never sets it (`OpDispatch::wants_stream`'s shape floor). - let action = if ctx.stream { + // Chat + audio understanding/TTS ride generateContent, stream-aware. Every other verb with + // no action above (moderation, rerank: no Gemini cell) answers `:generateContent` whatever + // `stream` says, exactly as 1.5.5 did (v1.5.5 `crates/busbar/src/handlers/gemini.rs:52-63`: + // only Chat | Transcription | Speech were stream-aware; Moderation and Rerank were + // `:generateContent`). + let stream_aware = + [OpVerb::CHAT, OpVerb::TRANSCRIPTION, OpVerb::SPEECH].contains(&ctx.operation); + let action = if stream_aware && ctx.stream { FIELD_STREAM_GENERATE_CONTENT } else { FIELD_GENERATE_CONTENT diff --git a/crates/busbar-plane-llm/src/codec/gemini/tests/handler_tests.rs b/crates/busbar-plane-llm/src/codec/gemini/tests/handler_tests.rs index 8bc24feb64..5c7fb8169e 100644 --- a/crates/busbar-plane-llm/src/codec/gemini/tests/handler_tests.rs +++ b/crates/busbar-plane-llm/src/codec/gemini/tests/handler_tests.rs @@ -63,6 +63,42 @@ fn path_base_reshapes_the_gemini_url_for_vertex() { ); } +/// Moderation and rerank (no Gemini cell) answer `:generateContent` whether or not the request +/// streams, as 1.5.5 did (v1.5.5 `crates/busbar/src/handlers/gemini.rs:62-63`); only chat, +/// transcription and speech are stream-aware (`:52-58`). RED while every fallback verb took +/// `:streamGenerateContent` on a streamed request. +#[test] +fn moderation_and_rerank_always_use_generate_content() { + let h = GeminiRequestHandler; + let model = "gemini-2.0-flash"; + for operation in [OpVerb::MODERATION, OpVerb::RERANK] { + for stream in [false, true] { + assert_eq!( + h.upstream_path(&EgressCtx { + operation, + model, + stream, + path_base: None, + }), + "/v1beta/models/gemini-2.0-flash:generateContent", + "{operation:?} stream={stream}" + ); + } + } + for operation in [OpVerb::CHAT, OpVerb::TRANSCRIPTION, OpVerb::SPEECH] { + assert_eq!( + h.upstream_path(&EgressCtx { + operation, + model, + stream: true, + path_base: None, + }), + "/v1beta/models/gemini-2.0-flash:streamGenerateContent", + "{operation:?}" + ); + } +} + #[test] fn transcription_read_request_captures_inline_audio() { // A generateContent body with a valid-base64 inline_data audio part → IR Transcription From 9c867d1bbb23ab422cddf452b1c77470b630a18e Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:41:22 -0700 Subject: [PATCH 42/59] tests: child halves and an opt-in measurement no longer pass by returning (row 114) restart_phase (durability_amend.rs), node_boot_hooks_arm (serve_money.rs) and panic_child (plugin-loader dispatch_tests.rs) each returned early when their parent's variable was unset, so the default run counted three passes that proved nothing. Each is now #[ignore = child process of ], fails by name when run without its parent, and its parent runs it with --ignored (the pattern loader_seam_tests' foreign_panic_child already uses). The parents already assert the child ran ("1 passed" / a non-success abort), so a child that stops running fails its parent. capture_latency_metrics (busbar-llm) was an env-gated no-op passing in 0s: it is #[ignore = a measurement] and runs whenever asked with --ignored. The two bare #[ignore] benches (busbar-plane-llm same_proto_fidelity_tests, busbar-kernel store tests) state their reason. --- crates/busbar-kernel/src/store/tests/tests.rs | 2 +- .../tests/forward_pool_integration_tests.rs | 9 ++++----- .../tests/proto/same_proto_fidelity_tests.rs | 2 +- crates/busbar/src/root/tests/durability_amend.rs | 10 ++++++---- crates/busbar/src/root/tests/serve_money.rs | 15 +++++++++++---- crates/plugin-loader/src/tests/dispatch_tests.rs | 8 ++++++-- 6 files changed, 29 insertions(+), 17 deletions(-) diff --git a/crates/busbar-kernel/src/store/tests/tests.rs b/crates/busbar-kernel/src/store/tests/tests.rs index f0f91fab77..59b9aa41e9 100644 --- a/crates/busbar-kernel/src/store/tests/tests.rs +++ b/crates/busbar-kernel/src/store/tests/tests.rs @@ -1991,7 +1991,7 @@ fn test_zero_weight_member_is_never_selected() { /// internals; an `Instant`-based in-crate bench measures the EXACT same code with no crate /// surgery — the honest, low-risk way to gate the default path.) #[test] -#[ignore] +#[ignore = "a timing bench: run with --release --ignored --nocapture"] fn bench_select_weighted_in_seam() { let store = Arc::new(HealthState::new(vec![ make_lane_data(0, 100), diff --git a/crates/busbar-llm/src/engine/tests/forward_pool_integration_tests.rs b/crates/busbar-llm/src/engine/tests/forward_pool_integration_tests.rs index e0a8263840..c0543a05a3 100644 --- a/crates/busbar-llm/src/engine/tests/forward_pool_integration_tests.rs +++ b/crates/busbar-llm/src/engine/tests/forward_pool_integration_tests.rs @@ -49,17 +49,16 @@ use std::sync::Arc; /// Pre-release latency capture (opt-in). Drives N in-process requests through the full forward path /// against an instant mock upstream and reports the request-handling-time distribution — a /// reproducible latency regression metric that runs in the normal test build (no cert, no key, no -/// external mock). It's a no-op unless `BUSBAR_CAPTURE_METRICS` is set, so it costs nothing in CI. +/// external mock). A measurement, not a gate: `#[ignore]`d, so the default run neither spends it +/// nor counts it as a pass (status row 114; it was an env-gated no-op that passed in 0s). /// /// For release-representative numbers, build under `--release`: -/// BUSBAR_CAPTURE_METRICS=1 cargo test --release -p busbar capture_latency_metrics -- --nocapture +/// cargo test --release -p busbar capture_latency_metrics -- --ignored --nocapture /// Emits a line the site metrics harness parses: `BUSBAR_METRICS busbar.latency.inproc_handle_us ...` #[tokio::test] +#[ignore = "a measurement: run with --ignored --nocapture (BUSBAR_CAPTURE_N sets the count)"] async fn capture_latency_metrics() { crate::testkit::install_test_seams(); - if std::env::var("BUSBAR_CAPTURE_METRICS").is_err() { - return; // opt-in only — normal test runs skip this. - } let n: usize = std::env::var("BUSBAR_CAPTURE_N") .ok() .and_then(|s| s.parse().ok()) diff --git a/crates/busbar-plane-llm/src/codec/tests/proto/same_proto_fidelity_tests.rs b/crates/busbar-plane-llm/src/codec/tests/proto/same_proto_fidelity_tests.rs index 319ee0c7f2..797e55aab2 100644 --- a/crates/busbar-plane-llm/src/codec/tests/proto/same_proto_fidelity_tests.rs +++ b/crates/busbar-plane-llm/src/codec/tests/proto/same_proto_fidelity_tests.rs @@ -169,7 +169,7 @@ fn same_proto_cross_chunk_split_is_verbatim() { /// suite (timing is environment-sensitive). Run with: /// cargo test --release bench_same_proto_short_circuit -- --ignored --nocapture #[test] -#[ignore] +#[ignore = "a timing bench: run with --release --ignored --nocapture"] fn bench_same_proto_short_circuit() { use crate::codec::eventstream::encode_frame; diff --git a/crates/busbar/src/root/tests/durability_amend.rs b/crates/busbar/src/root/tests/durability_amend.rs index df9db2750c..b353aeb084 100644 --- a/crates/busbar/src/root/tests/durability_amend.rs +++ b/crates/busbar/src/root/tests/durability_amend.rs @@ -70,12 +70,14 @@ fn boot(dir: &std::path::Path) -> Arc> { book } -/// ONE HALF OF THE RESTART, run only in a child process the restart test below starts; a no-op -/// anywhere else. +/// ONE HALF OF THE RESTART, run only in a child process the restart tests below start +/// ([`run_phase`], `--ignored`). Ignored so the default run never counts it as a pass: alone it +/// proves nothing, and run without its phase it fails (status row 114). #[test] +#[ignore = "child process of the restart tests: run_phase runs it with --ignored"] fn restart_phase() { let (Ok(phase), Ok(dir)) = (std::env::var(PHASE), std::env::var(DIR)) else { - return; + panic!("restart_phase is the child half of the restart tests: run them, not this"); }; let book = boot(std::path::Path::new(&dir)); let recorded = counts(1_000); @@ -208,7 +210,7 @@ fn restart_phase() { fn run_phase(phase: &str, dir: &std::path::Path) { let out = std::process::Command::new(std::env::current_exe().expect("the test binary")) .args(["--exact", "root::durability::amend::tests::restart_phase"]) - .args(["--test-threads", "1", "--nocapture"]) + .args(["--ignored", "--test-threads", "1", "--nocapture"]) .env(PHASE, phase) .env(DIR, dir) .output() diff --git a/crates/busbar/src/root/tests/serve_money.rs b/crates/busbar/src/root/tests/serve_money.rs index d429107341..40a3edea46 100644 --- a/crates/busbar/src/root/tests/serve_money.rs +++ b/crates/busbar/src/root/tests/serve_money.rs @@ -664,7 +664,7 @@ fn the_nodes_boot_hooks_price_and_book_a_door_unit_in_every_build() { } let run = std::process::Command::new(std::env::current_exe().expect("the test binary")) .args(["--exact", "root::serve::money_tests::node_boot_hooks_arm"]) - .args(["--test-threads", "1", "--nocapture"]) + .args(["--ignored", "--test-threads", "1", "--nocapture"]) .env(NODE_HOOKS_ARM, "1") .output() .expect("the test binary runs"); @@ -682,11 +682,18 @@ fn the_nodes_boot_hooks_price_and_book_a_door_unit_in_every_build() { /// the real boot book; then a keyed door unit of the test plane, dropped in, is driven on the /// process's one node. Its line and its audit record are on that book, and the record names the /// card the unit was pinned to at its door: a second apply's entry, so not the opening's `0`. +/// +/// Ignored so the default run never counts it as a pass: alone it proves nothing, and run without +/// its parent's variable it fails (status row 114). #[tokio::test] +#[ignore = "child process of the_nodes_boot_hooks_price_and_book_a_door_unit_in_every_build, \ + which runs it with --ignored"] async fn node_boot_hooks_arm() { - if std::env::var_os(NODE_HOOKS_ARM).is_none() { - return; - } + assert!( + std::env::var_os(NODE_HOOKS_ARM).is_some(), + "node_boot_hooks_arm is the child half of \ + the_nodes_boot_hooks_price_and_book_a_door_unit_in_every_build: run that, not this" + ); // THE TABLE: the node's root unit is keyed to the node itself, never to a plane feature. assert!( include_str!(concat!(env!("OUT_DIR"), "/linked.rs")) diff --git a/crates/plugin-loader/src/tests/dispatch_tests.rs b/crates/plugin-loader/src/tests/dispatch_tests.rs index 4030af015d..52626290dc 100644 --- a/crates/plugin-loader/src/tests/dispatch_tests.rs +++ b/crates/plugin-loader/src/tests/dispatch_tests.rs @@ -1553,11 +1553,14 @@ fn the_pack_time_rendering_of_a_library_is_its_door_s() { ); } -/// The child half of the abort test: runs only when its parent sets the variable. +/// The child half of the abort test: runs only when its parent sets the variable. Ignored so the +/// default run never counts it as a pass: alone it proves nothing, and run without the variable it +/// fails (status row 114). #[test] +#[ignore = "child process of red_a_panic_in_a_hand_written_slot_aborts, which runs it with --ignored"] fn panic_child() { let Ok(origin) = std::env::var("BUSBAR_M1_PANIC_CHILD") else { - return; + panic!("panic_child is the child half of red_a_panic_in_a_hand_written_slot_aborts"); }; let p = if origin == "dropped" { dropped(quiet()) @@ -1581,6 +1584,7 @@ fn red_a_panic_in_a_hand_written_slot_aborts() { .args([ "--exact", "dispatch_tests::panic_child", + "--ignored", "--test-threads=1", "--nocapture", ]) From b3d2dad5c5f5c6e578c7edc822e009411ed36513 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:46:32 -0700 Subject: [PATCH 43/59] bin/oracle: on Linux the judge is fetched, busbar-release's published busbar-oracle at the BASE pin's sha256, or there is none OWNER 2026-10-13 (to ModernCI): no hand-installed tools for tenants; ModernCI removed BUSBAR_ORACLE_RUST_BIN and its installed engines. bin/oracle's resolve step 2 now fetches busbar-oracle-linux-- from busbar-release's dev-bin release over the GitHub API (the token on curl's stdin, never an argv or a file), reading release_ref and sha256_linux_ from $MODERNCI_TO_SHA's oracle-rust.pin when the base holds one, else the tree's (the first predev -> dev move). It refuses unless the sha256 is the pin's, refuses a .sha256 that disagrees, verifies a .sig against busbar-release's release key (Ed25519 over "\n\n"), and caches the verified binary by digest (re-hashed on every use). A pin with no line for the host's architecture has no judge. Laptops keep the source build. The 1.5.5 golden is untouched. `bin/oracle fetch-selftest` (oracle_one_pin.rs) is the RED: wrong sha refuses, missing line says "no judge", a disagreeing .sha256 and another key's .sig refuse, the base pin wins. --- bin/oracle | 178 +++++++++++++++++++++++++- crates/busbar/tests/oracle_one_pin.rs | 23 ++++ scripts/verify-1.6.0-done.sh | 14 +- 3 files changed, 204 insertions(+), 11 deletions(-) diff --git a/bin/oracle b/bin/oracle index 8512b08b6c..4f6765c6ae 100755 --- a/bin/oracle +++ b/bin/oracle @@ -267,11 +267,14 @@ shim_dir="$root/tool" mkdir -p "$root" # ── obtain the Rust engine ────────────────────────────────────────────────────────────────────── -# Precedence (soundest first, per the Phase C ruling "prefer `cargo build --release` from the pinned -# busbar-release checkout"): -# 1. BUSBAR_ORACLE_RUST_BIN= a prebuilt binary (CI builds it once, passes it here). -# 2. BUSBAR_RELEASE_CHECKOUT=

a busbar-release checkout to `cargo build --release` from. -# 3. else git clone busbar-release at the pinned ref, then build. +# Precedence: +# 1. BUSBAR_ORACLE_RUST_BIN= a prebuilt binary chosen by the operator (verify-1.6.0-done.sh +# refuses a DONE run with it set). +# 2. on Linux: THE FETCHED JUDGE busbar-release's published `busbar-oracle-linux--`, +# run only when its sha256 is the BASE pin's line for this +# architecture (fetch_judge, below). No line: no judge, refused. +# 3. elsewhere (a laptop): BUSBAR_RELEASE_CHECKOUT=, a busbar-release checkout to +# `cargo build --release` from, else git clone busbar-release at the pinned ref, then build. # ── the engine clone: a token is USED, never STORED (Q128 WARN, ARCHITECT 2026-10-05) ────────────── # The token (the first of BUSBAR_RELEASE_TOKEN / GH_TOKEN / GITHUB_TOKEN that is set) reaches git as a # one-shot `http..extraheader` through git's environment config (GIT_CONFIG_COUNT/KEY/VALUE): @@ -352,11 +355,176 @@ if [ "${1:-}" = "token-selftest" ]; then [ "$fail" = 0 ] && { echo "oracle token-selftest: the engine clone stores no credential"; exit 0; } echo "oracle token-selftest: FAILED"; exit 1 fi +# ── the pinned judge, FETCHED (OWNER 2026-10-13: no hand-installed tools; ModernCI installs no judge) ── +# On Linux the judge is the binary busbar-release's dev-bin.yml built and published from the pinned +# commit: `busbar-oracle-linux--` on the `dev-bin` release, run only when its +# sha256 is the pin's `sha256_linux_` line. The pin read is the BASE's: $MODERNCI_TO_SHA's +# testing/shadow-oracle/oracle-rust.pin when ModernCI names a base that holds one (a candidate never +# picks its own judge; a pin bump judges from the commit after it lands), else this tree's (the first +# predev -> dev move: dev holds no pin; .ci/predev.yaml says so). A pin with no line for this host's +# architecture has NO JUDGE (red, never another architecture's, never a source build). A `.sig` beside +# the asset is verified against busbar-release's release key (Ed25519 over "\n\n", +# busbar-release-cli update.rs RELEASE_KEY_SPKI) before the bytes are ever made executable; a +# `.sha256` beside it must agree with the pin. The verified binary is cached by its sha256 and +# re-hashed on every use, so a cache cannot substitute a judge. The token (BUSBAR_RELEASE_TOKEN, else +# GH_TOKEN, else GITHUB_TOKEN) reaches curl on stdin as a config line: never on an argv, never on disk. +ORACLE_RELEASE_API="https://api.github.com/repos/GetBusbar/busbar-release" +ORACLE_RELEASE_KEY_PEM='-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEAT8gl0GhaEpxYXdRqfeNZ6KU8KgmI+8qbtP8UZPNPJiU= +-----END PUBLIC KEY-----' +_pin_in() { awk -F= -v k="$1" '$1==k{sub(/^[^=]*=/,"");print;exit}'; } +sha256_of() { # sha256_of — the hex digest, or nothing + if command -v sha256sum >/dev/null 2>&1; then sha256sum -- "$1" | cut -c1-64 + else shasum -a 256 -- "$1" | cut -c1-64; fi +} +judge_arch() { local a; a="$(uname -m)"; [ "$a" = arm64 ] && a=aarch64; printf '%s' "$a"; } +judge_pin_text() { # judge_pin_text — the BASE pin's text (see above) + local repo="$1" rel="testing/shadow-oracle/oracle-rust.pin" + if [ -n "${MODERNCI_TO_SHA:-}" ] && git -C "$repo" cat-file -e "$MODERNCI_TO_SHA:$rel" 2>/dev/null; then + git -C "$repo" show "$MODERNCI_TO_SHA:$rel" + else + cat "$repo/$rel" + fi +} +release_api() { # release_api [ []] — an authenticated GET; the token never on an argv + local tok="${BUSBAR_RELEASE_TOKEN:-${GH_TOKEN:-${GITHUB_TOKEN:-}}}" + [ -n "$tok" ] || { echo "oracle: no token to read busbar-release's dev-bin release (BUSBAR_RELEASE_TOKEN, GH_TOKEN or GITHUB_TOKEN)" >&2; return 1; } + local accept="${2:-application/vnd.github+json}" + if [ -n "${3:-}" ]; then + printf 'header = "Authorization: Bearer %s"\n' "$tok" | curl -fsSL --retry 3 -K - -H "Accept: $accept" -o "$3" "$1" + else + printf 'header = "Authorization: Bearer %s"\n' "$tok" | curl -fsSL --retry 3 -K - -H "Accept: $accept" "$1" + fi +} +judge_assets() { # judge_assets — every dev-bin asset as `\t` + local out="$1" rid page n + rid="$(release_api "$ORACLE_RELEASE_API/releases/tags/dev-bin" | jq -r '.id')" || return 1 + [[ "$rid" =~ ^[0-9]+$ ]] || { echo "oracle: busbar-release has no dev-bin release" >&2; return 1; } + : >"$out" + for page in $(seq 1 20); do + n="$(release_api "$ORACLE_RELEASE_API/releases/$rid/assets?per_page=100&page=$page" \ + | jq -r '.[] | "\(.id)\t\(.name)"' | tee -a "$out" | wc -l)" || return 1 + [ "$n" -lt 100 ] && break + done + return 0 +} +fetch_asset() { # fetch_asset — : a directory (the selftest) or an asset list + local src="$1" name="$2" out="$3" id + if [ -d "$src" ]; then [ -f "$src/$name" ] && cp "$src/$name" "$out"; return; fi + id="$(awk -F'\t' -v n="$name" '$2==n{print $1; exit}' "$src")" + [ -n "$id" ] || return 1 + release_api "$ORACLE_RELEASE_API/releases/assets/$id" application/octet-stream "$out" +} +verify_judge_sig() { # verify_judge_sig + local pem="$1" name="$2" hex="$3" sig="$4" d rc=0 + command -v openssl >/dev/null 2>&1 || { echo "oracle: refusing the judge: $name has a .sig and there is no openssl to verify it" >&2; return 1; } + d="$(mktemp -d)" + printf '%s\n' "$pem" >"$d/key.pem" + printf '%s\n%s\n' "$name" "$hex" >"$d/msg" + if ! tr -d '\n\r ' <"$sig" | base64 -d >"$d/sig" 2>/dev/null; then rc=1 + elif ! openssl pkeyutl -verify -pubin -inkey "$d/key.pem" -rawin -in "$d/msg" -sigfile "$d/sig" >/dev/null 2>&1; then rc=1; fi + rm -rf "$d" + [ "$rc" = 0 ] || echo "oracle: refusing the judge: $name's .sig is not busbar-release's release key's signature of it" >&2 + return "$rc" +} +fetch_judge() { # fetch_judge [] — prints the verified judge + local text="$1" cache="$2" src="$3" pem="${4:-$ORACLE_RELEASE_KEY_PEM}" arch ref want name bin tmp got side + arch="$(judge_arch)" + ref="$(printf '%s\n' "$text" | _pin_in release_ref)" + want="$(printf '%s\n' "$text" | _pin_in "sha256_linux_$arch")" + [[ "$ref" =~ ^[0-9a-f]{40}$ ]] || { echo "oracle: no judge: the pin names no full 40-hex release_ref" >&2; return 1; } + [[ "$want" =~ ^[0-9a-f]{64}$ ]] || { echo "oracle: no judge: the pin has no sha256_linux_$arch line, and a host whose architecture the pin does not name has no judge" >&2; return 1; } + name="busbar-oracle-linux-$arch-$ref" + bin="$cache/$want/busbar-oracle" + if [ -x "$bin" ] && [ "$(sha256_of "$bin")" = "$want" ]; then printf '%s\n' "$bin"; return 0; fi + tmp="$(mktemp -d)" + if [ "$src" = api ]; then judge_assets "$tmp/assets.tsv" || { rm -rf "$tmp"; return 1; }; src="$tmp/assets.tsv"; fi + fetch_asset "$src" "$name" "$tmp/$name" || { echo "oracle: no judge: $name is not on busbar-release's dev-bin release" >&2; rm -rf "$tmp"; return 1; } + got="$(sha256_of "$tmp/$name")" + [ "$got" = "$want" ] || { echo "oracle: refusing the judge: $name has sha256 $got, the pin says $want" >&2; rm -rf "$tmp"; return 1; } + if fetch_asset "$src" "$name.sha256" "$tmp/$name.sha256" 2>/dev/null; then + side="$(cut -c1-64 <"$tmp/$name.sha256")" + [ "$side" = "$want" ] || { echo "oracle: refusing the judge: its published .sha256 ($side) is not the pin's $want" >&2; rm -rf "$tmp"; return 1; } + fi + if fetch_asset "$src" "$name.sig" "$tmp/$name.sig" 2>/dev/null; then + verify_judge_sig "$pem" "$name" "$got" "$tmp/$name.sig" || { rm -rf "$tmp"; return 1; } + fi + mkdir -p "$cache/$want" + mv "$tmp/$name" "$bin.part" && chmod +x "$bin.part" && mv -f "$bin.part" "$bin" + rm -rf "$tmp" + printf '%s\n' "$bin" +} +if [ "${1:-}" = "fetch-selftest" ]; then + # RED-before-green for the fetched judge, offline: a fixture "release" directory stands in for the + # dev-bin API (fetch_asset's directory source), and a throwaway Ed25519 key for busbar-release's. + st="$(mktemp -d)"; trap 'rm -rf "$st"' EXIT + fail=0 arch="$(judge_arch)" ref="0123456789abcdef0123456789abcdef01234567" + name="busbar-oracle-linux-$arch-$ref" + mkdir -p "$st/rel" "$st/cache" + printf '#!/bin/sh\necho planted-judge\n' >"$st/rel/$name" + good="$(sha256_of "$st/rel/$name")" + pin_ok="release_ref=$ref +sha256_linux_$arch=$good" + if p="$(fetch_judge "$pin_ok" "$st/cache" "$st/rel" 2>/dev/null)" && [ "$("$p")" = planted-judge ]; then + echo " [ok] the pinned digest's judge is fetched, cached and runs" + else echo " [FAILED] the pinned digest's judge was not fetched"; fail=1; fi + rm -f "$st/rel/$name" + if p="$(fetch_judge "$pin_ok" "$st/cache" "$st/rel" 2>/dev/null)" && [ "$("$p")" = planted-judge ]; then + echo " [ok] a cached judge is reused by its digest, with no source" + else echo " [FAILED] the cached judge was not reused"; fail=1; fi + printf '#!/bin/sh\necho planted-judge\n' >"$st/rel/$name" + pin_wrong="release_ref=$ref +sha256_linux_$arch=$(printf 'f%.0s' $(seq 1 64))" + if fetch_judge "$pin_wrong" "$st/cache2" "$st/rel" >/dev/null 2>"$st/err"; then + echo " [FAILED] a judge whose sha256 is not the pin's ran"; fail=1 + elif grep -q "refusing the judge" "$st/err"; then echo " [ok] a wrong sha256 refuses the judge" + else echo " [FAILED] the wrong-sha refusal did not say so: $(cat "$st/err")"; fail=1; fi + if fetch_judge "release_ref=$ref" "$st/cache3" "$st/rel" >/dev/null 2>"$st/err"; then + echo " [FAILED] a pin with no line for this architecture produced a judge"; fail=1 + elif grep -q "no judge" "$st/err"; then echo " [ok] a pin with no sha256_linux_$arch line has no judge" + else echo " [FAILED] the missing-line refusal did not say \"no judge\": $(cat "$st/err")"; fail=1; fi + printf 'e%.0s' $(seq 1 64) >"$st/rel/$name.sha256" + if fetch_judge "$pin_ok" "$st/cache4" "$st/rel" >/dev/null 2>/dev/null; then + echo " [FAILED] a published .sha256 disagreeing with the pin was accepted"; fail=1 + else echo " [ok] a published .sha256 disagreeing with the pin refuses"; fi + printf '%s %s\n' "$good" "$name" >"$st/rel/$name.sha256" + if command -v openssl >/dev/null 2>&1 && openssl genpkey -algorithm ed25519 -out "$st/k.pem" 2>/dev/null \ + && openssl pkey -in "$st/k.pem" -pubout -out "$st/pub.pem" 2>/dev/null; then + printf '%s\n%s\n' "$name" "$good" >"$st/msg" + openssl pkeyutl -sign -inkey "$st/k.pem" -rawin -in "$st/msg" -out "$st/sig.bin" 2>/dev/null + base64 <"$st/sig.bin" | tr -d '\n' >"$st/rel/$name.sig" + if fetch_judge "$pin_ok" "$st/cache5" "$st/rel" "$(cat "$st/pub.pem")" >/dev/null 2>&1; then + echo " [ok] a .sig made by the release key is accepted" + else echo " [FAILED] a good .sig was refused"; fail=1; fi + if fetch_judge "$pin_ok" "$st/cache6" "$st/rel" >/dev/null 2>"$st/err"; then + echo " [FAILED] a .sig made by another key was accepted under busbar-release's key"; fail=1 + elif grep -q "release key" "$st/err"; then echo " [ok] a .sig made by another key refuses" + else echo " [FAILED] the bad-.sig refusal did not say so: $(cat "$st/err")"; fail=1; fi + else echo " [FAILED] no openssl with Ed25519 here: the .sig check cannot be proven"; fail=1; fi + # THE BASE PIN wins over the tree's when ModernCI names a base that holds one. + git init -q "$st/repo" && mkdir -p "$st/repo/testing/shadow-oracle" \ + && printf 'release_ref=base\n' >"$st/repo/testing/shadow-oracle/oracle-rust.pin" \ + && git -C "$st/repo" add -A && git -C "$st/repo" -c user.name=t -c user.email=t@t.invalid \ + -c commit.gpgsign=false -c core.hooksPath=/dev/null commit -q -m pin \ + && printf 'release_ref=head\n' >"$st/repo/testing/shadow-oracle/oracle-rust.pin" \ + || { echo " [FAILED] could not build the base-pin fixture"; fail=1; } + b="$(git -C "$st/repo" rev-parse HEAD 2>/dev/null)" + if [ "$(MODERNCI_TO_SHA="$b" judge_pin_text "$st/repo")" = "release_ref=base" ] \ + && [ "$(MODERNCI_TO_SHA= judge_pin_text "$st/repo")" = "release_ref=head" ]; then + echo " [ok] the base's pin is read when ModernCI names a base, the tree's otherwise" + else echo " [FAILED] the judge's pin was not read from the base"; fail=1; fi + [ "$fail" = 0 ] && { echo "oracle fetch-selftest: the judge is the pinned digest's, or there is none"; exit 0; } + echo "oracle fetch-selftest: FAILED"; exit 1 +fi resolve_bin() { if [ -n "${BUSBAR_ORACLE_RUST_BIN:-}" ]; then [ -x "$BUSBAR_ORACLE_RUST_BIN" ] || { echo "oracle: BUSBAR_ORACLE_RUST_BIN is not executable: $BUSBAR_ORACLE_RUST_BIN" >&2; exit 1; } printf '%s\n' "$BUSBAR_ORACLE_RUST_BIN"; return 0 fi + if [ "$(uname -s)" = Linux ]; then + fetch_judge "$(judge_pin_text "$here")" "${XDG_CACHE_HOME:-$HOME/.cache}/busbar-oracle-judge" api || exit 1 + return 0 + fi local built="$root/checkout/target/release/$BINNAME" if [ -x "$built" ]; then printf '%s\n' "$built"; return 0; fi local checkout diff --git a/crates/busbar/tests/oracle_one_pin.rs b/crates/busbar/tests/oracle_one_pin.rs index f4fb30bbae..65f4db1f14 100644 --- a/crates/busbar/tests/oracle_one_pin.rs +++ b/crates/busbar/tests/oracle_one_pin.rs @@ -108,6 +108,29 @@ fn the_engine_clone_stores_no_token_and_builds_locked() { ); } +/// THE JUDGE IS THE PINNED DIGEST'S OR THERE IS NONE (OWNER 2026-10-13: no hand-installed tools). +/// On Linux `bin/oracle` fetches busbar-release's published `busbar-oracle-linux--` and +/// runs it only when its sha256 is the BASE pin's line for this architecture; a `.sig` beside it must +/// be the release key's. `bin/oracle fetch-selftest` proves, offline: the pinned judge is fetched and +/// cached, a wrong sha256 refuses, a pin with no line for this architecture has "no judge", a +/// disagreeing `.sha256` and another key's `.sig` refuse, and the base's pin wins over the tree's. +#[test] +fn the_fetched_judge_is_the_pinned_digests_or_none() { + let shim = repo_root().join("bin/oracle"); + let out = std::process::Command::new("bash") + .arg(&shim) + .arg("fetch-selftest") + .current_dir(repo_root()) + .output() + .expect("run bin/oracle fetch-selftest"); + assert!( + out.status.success(), + "bin/oracle fetch-selftest failed:\n{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); +} + /// NO ARM EXECS AN ENGINE SUBCOMMAND THE PIN NO LONGER HAS: busbar-release #272 deleted the /// engine's `owed-baseline` and `replay` subcommands, so `bin/oracle owed-baseline` and the generated /// `$BUSBAR_ORACLE_TOOL_DIR/replay.sh` (`exec busbar-oracle replay`) could only end in clap's diff --git a/scripts/verify-1.6.0-done.sh b/scripts/verify-1.6.0-done.sh index 9ac63f162d..dabafa8ca6 100755 --- a/scripts/verify-1.6.0-done.sh +++ b/scripts/verify-1.6.0-done.sh @@ -808,17 +808,19 @@ print("golden whole: %d PASS rows = meta recorded %d, every one with its cell fi PY } -# The engine every PARITY step judges with: bin/oracle fetches busbar-release at the pinned ref into -# target/oracle/rust//checkout and builds it (`tool-dir` is the cheapest subcommand that resolves -# it). Fails naming the two ways to provide it, so a missing judge reads as that, not as a red cell. +# The engine every PARITY step judges with: on Linux bin/oracle fetches busbar-release's published +# busbar-oracle for the pinned ref and runs it only at the pin's sha256 (elsewhere it builds the pinned +# source; `tool-dir` is the cheapest subcommand that resolves it). Fails naming how to provide it, so +# a missing judge reads as that, not as a red cell. parity_resolve_judge() { # $1 = the pinned busbar-release ref local ref="${1:-}" [ -n "$ref" ] || { echo "testing/shadow-oracle/oracle-rust.pin names no release_ref — there is no judge to build"; return 1; } if ./bin/oracle tool-dir >/dev/null 2>/tmp/done-parity-judge.$$; then rm -f /tmp/done-parity-judge.$$; return 0; fi tail -6 /tmp/done-parity-judge.$$; rm -f /tmp/done-parity-judge.$$ echo "the judge could not be fetched or built. busbar-release is private: give the run a token" - echo "(BUSBAR_RELEASE_TOKEN, GH_TOKEN or GITHUB_TOKEN; bin/oracle never stores it), or place a git" - echo "checkout holding commit $ref at target/oracle/rust/$ref/checkout before running" + echo "(BUSBAR_RELEASE_TOKEN, GH_TOKEN or GITHUB_TOKEN; bin/oracle never stores it). On Linux the" + echo "judge is dev-bin's busbar-oracle for commit $ref at the pin's sha256 (no line: no judge);" + echo "elsewhere, place a git checkout holding $ref at target/oracle/rust/$ref/checkout" return 1 } @@ -1437,7 +1439,7 @@ elif [ -x bin/oracle ]; then # cannot be obtained (busbar-release is private: a runner with no token and no checkout of the # pinned commit), say so here, by name, instead of as three unrelated-looking reds below. PARITY_JUDGE_REF="$(awk -F= '$1=="release_ref"{print $2; exit}' testing/shadow-oracle/oracle-rust.pin 2>/dev/null)" - step "the pinned judge (busbar-release ${PARITY_JUDGE_REF:-}) is fetched and built from its source" \ + step "the pinned judge (busbar-release ${PARITY_JUDGE_REF:-}) is fetched at the pin's sha256" \ parity_resolve_judge "$PARITY_JUDGE_REF" step "replay-selftest (the differ can see a diff)" ./bin/oracle replay-selftest # cells.json IS the owed set — the recorder and the replayer both iterate it, and every count in From 6913fd6907d328352d17fad84457f383fd103257 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:46:47 -0700 Subject: [PATCH 44/59] connector: read_terms copies the stated fingerprint after the bounds read; the scripted framer's seen path is asserted (row 132, #827 compile red) E0502 at datagram.rs:872 on #827, the first compile of this code: fingerprint.copy_from_slice( span(..)?.get(..fingerprint.len())) borrowed the array mutably for the call while its length was read in the argument. The stated span is read and bounded first, then copied. The datagram tests' Seen.path was recorded and never read (dead_code under clippy -D warnings). The honest-check arm of a_source_that_sent_no_verified_check_never_reaches_the_framer now asserts the check crossed on the clear lane under path 1, the number it was told under. --- crates/busbar-core-connector/src/datagram.rs | 9 ++++----- crates/busbar-core-connector/src/tests/datagram_tests.rs | 2 ++ 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/busbar-core-connector/src/datagram.rs b/crates/busbar-core-connector/src/datagram.rs index b637962d86..59402676ef 100644 --- a/crates/busbar-core-connector/src/datagram.rs +++ b/crates/busbar-core-connector/src/datagram.rs @@ -732,11 +732,10 @@ fn read_terms(t: &RendezvousTerms, frame: &[u8]) -> Result { _ => return Err(fault("the framer's terms name no handshake role")), }; let mut fingerprint = [0_u8; FINGERPRINT_BYTES as usize]; - fingerprint.copy_from_slice( - span(t.peer_fingerprint)? - .get(..fingerprint.len()) - .ok_or_else(|| fault("the far end's fingerprint is short"))?, - ); + let stated = span(t.peer_fingerprint)? + .get(..fingerprint.len()) + .ok_or_else(|| fault("the far end's fingerprint is short"))?; + fingerprint.copy_from_slice(stated); Ok(Terms { role, local: IceCredentials { diff --git a/crates/busbar-core-connector/src/tests/datagram_tests.rs b/crates/busbar-core-connector/src/tests/datagram_tests.rs index 0372b928a9..35a6826a97 100644 --- a/crates/busbar-core-connector/src/tests/datagram_tests.rs +++ b/crates/busbar-core-connector/src/tests/datagram_tests.rs @@ -545,6 +545,8 @@ fn a_source_that_sent_no_verified_check_never_reaches_the_framer() { assert!(seen .iter() .any(|s| s.told.contains(&(1, r.far.addr.to_string())))); + // The check itself crossed on the clear lane, carrying the path number it was told under. + assert!(seen.iter().any(|s| s.lane == LANE_CLEAR && s.path == 1)); } #[test] From 796beae0a7e1f3284067a8170d091a3b9317b957 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:47:09 -0700 Subject: [PATCH 45/59] connector tests: the scripted framer reads its own password before it routes the check's answer (row 132, #827) st.routes.push((.., answer(&bytes, st.local.1))) borrowed the St mutex guard mutably for the push and immutably for the password in its argument (E0502 through DerefMut). The password is read first. --- crates/busbar-core-connector/src/tests/datagram_tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/busbar-core-connector/src/tests/datagram_tests.rs b/crates/busbar-core-connector/src/tests/datagram_tests.rs index 360e1dd5af..335bc927a3 100644 --- a/crates/busbar-core-connector/src/tests/datagram_tests.rs +++ b/crates/busbar-core-connector/src/tests/datagram_tests.rs @@ -356,8 +356,8 @@ impl FramerDoor for Scripted { } } LANE_CLEAR if is_request(&bytes) => { - st.routes - .push((lane.path, LANE_CLEAR, answer(&bytes, st.local.1))); + let pwd = st.local.1; + st.routes.push((lane.path, LANE_CLEAR, answer(&bytes, pwd))); if lane.bound == 0 { st.request = (PATH_REQUEST_BIND, 0, lane.path); } else if self.knobs.rebind_new && lane.path != lane.bound { From bd4d35dbf6bdb1a78f9e9675258cc8da90bce20a Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:47:40 -0700 Subject: [PATCH 46/59] .ci/predev.yaml: the oracle check obtains its judge through bin/oracle (#911: fetched at the base pin's sha256), not an installed BUSBAR_ORACLE_RUST_BIN; no busbar-oracle capability (OWNER 2026-10-13: no hand-installed tools) --- .ci/predev.yaml | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/.ci/predev.yaml b/.ci/predev.yaml index f68999418c..7279e0f25a 100644 --- a/.ci/predev.yaml +++ b/.ci/predev.yaml @@ -29,21 +29,20 @@ checks: # The base-relative rules read history: only a backend with the repo's history runs this check. requires: [git-history] oracle: - # busbar's own check tool, the pinned busbar-oracle (the backend's busbar-oracle tool sets - # BUSBAR_ORACLE_RUST_BIN): the judge is the one predev's oracle-rust.pin names ($MODERNCI_HEAD, - # the commit that landed through every lane move's review; dev holds no pin), run only when its - # sha256 is that pin's for this host, and the backend's install is pinned by ModernCI, not by the - # candidate; the 1.5.5 golden is add-only from dev to this commit wherever dev holds it (dev - # holds none before the first move); then the candidate's release binary is recorded over the - # corpus and replayed against the committed 1.5.5 golden, strict: green is every cell byte-green - # or forgiven by an owner-signed accepted difference. + # busbar's own judge, obtained by bin/oracle itself (OWNER 2026-10-13: no hand-installed tools; + # #911): on Linux it fetches busbar-release's published busbar-oracle for the BASE pin's + # release_ref ($MODERNCI_TO_SHA's testing/shadow-oracle/oracle-rust.pin, or this tree's when dev + # holds none: the first move) and runs it only at that pin's sha256 for this architecture, its + # .sig verified against busbar-release's release key; no line for this architecture is no judge. + # BUSBAR_RELEASE_TOKEN (a secret ModernCI gives this check) reads the private release. The 1.5.5 + # golden is add-only from dev to this commit wherever dev holds it (none before the first move); + # then the candidate's release binary is recorded over the corpus and replayed against the + # committed 1.5.5 golden, strict: green is every cell byte-green or forgiven by an owner-signed + # accepted difference. run: | - pin=$(git show "$MODERNCI_HEAD:testing/shadow-oracle/oracle-rust.pin") - want=$(printf '%s\n' "$pin" | sed -n "s/^sha256_linux_$(uname -m)=//p") - [[ "$want" =~ ^[0-9a-f]{64}$ ]] || { echo "ORACLE-PIN: oracle-rust.pin at $MODERNCI_HEAD pins no linux $(uname -m) judge"; exit 1; } - echo "$want $BUSBAR_ORACLE_RUST_BIN" | sha256sum -c - + ./bin/oracle tool-dir >/dev/null if git cat-file -e "$MODERNCI_TO_SHA:testing/shadow-oracle/golden/1.5.5" 2>/dev/null; then - "$BUSBAR_ORACLE_RUST_BIN" golden-guard --repo . --base "$MODERNCI_TO_SHA" --head HEAD + ./bin/oracle golden-guard --repo . --base "$MODERNCI_TO_SHA" --head HEAD else echo "golden-guard: dev ($MODERNCI_TO_SHA) holds no golden/1.5.5; nothing to hold add-only" fi @@ -53,8 +52,6 @@ checks: ./bin/oracle record --bin target/release/busbar --plane all --out target/oracle/recordings/candidate ./bin/oracle replay --golden testing/shadow-oracle/golden/1.5.5 --candidate target/oracle/recordings/candidate --out target/oracle/reports/dev --strict needs: [gates] - # Only the backend that installs the pinned judge (the prover: aarch64) runs this check. - requires: [busbar-oracle] conformance: # Every registered suite recorded on this commit (the llm rigs judge the oracle's candidate # recording), then every MUST a fresh pass for it. A not-run suite denies. @@ -99,7 +96,8 @@ checks: # backend probed to have all of these, and says which one is missing otherwise. requires: [docker, docker-compose, python3-venv, curl, git] verify-done: - # The done-oracle, full run: exit 0 only (3 is a --fast, provisional answer). + # The done-oracle, full run: exit 0 only (3 is a --fast, provisional answer). Its PARITY group + # obtains the judge through bin/oracle as the oracle check does (BUSBAR_RELEASE_TOKEN). run: bash scripts/verify-1.6.0-done.sh needs: [gates] q128-audit: From 488c02df32b50fc26c44e35ce17023cce7781741 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:31:25 -0700 Subject: [PATCH 47/59] .ci/predev.yaml: deadline 8h (plane-delete --all, two LTO release builds and the workspace tests do not fit in 6h); verify-done and q128-audit require git-history (merge-bases and tags: the config-schema baseline is v1.5.3) --- .ci/predev.yaml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.ci/predev.yaml b/.ci/predev.yaml index 7279e0f25a..eb1ddea431 100644 --- a/.ci/predev.yaml +++ b/.ci/predev.yaml @@ -9,7 +9,8 @@ on_green: merge # The owner's approval on the pull request holds the merge too; q128-audit gates the move on the # audit's record for the commit (no open BLOCK). approve: true -deadline: 6h +# 8h: plane-delete --all (40-90 min), two LTO release builds and the workspace tests do not fit in 6h. +deadline: 8h env: RUSTFLAGS: "-D warnings" CARGO_TERM_COLOR: never @@ -100,6 +101,8 @@ checks: # obtains the judge through bin/oracle as the oracle check does (BUSBAR_RELEASE_TOKEN). run: bash scripts/verify-1.6.0-done.sh needs: [gates] + # It reads merge-bases and tags (the config-schema baseline is tag v1.5.3): full history and tags. + requires: [git-history] q128-audit: # DEV-GREEN item 5 (docs/design/BUSBAR-1.6.0.md l.2910-2912; 1.6.0-QUESTIONS.md Q128, option # A): one fresh, independent architecture audit on this commit with no open BLOCK, and a clean @@ -109,3 +112,5 @@ checks: # tree, or none, is a BLOCK. run: cargo xtask q128 audit needs: [gates] + # The audit is judged against this commit's history: full history and tags. + requires: [git-history] From c2c3f548b07bcfed0506a2f6476f5c07d414310f Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:25:48 -0700 Subject: [PATCH 48/59] =?UTF-8?q?xtask:=20RED=20=E2=80=94=20a=20suite=20st?= =?UTF-8?q?ep=20must=20build=20the=20example=20cdylibs=20its=20tests=20loa?= =?UTF-8?q?d=20by=20name=20before=20its=20first=20suite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit xtask/tests/ci_test_fixtures.rs reads every test under crates/ that names its fixture build (`cargo build -p --examples`: busbar-plugin-loader, busbar) and every .ci/*.yaml step that runs `cargo nextest run`, and refuses a step that does not run `cargo build --locked -p --examples` for each before its first suite. Red on this commit: default.yaml test, default.yaml test-gated and predev.yaml test-gated build neither, so a ModernCI run scoped to busbar alone leaves the loader's neutral_frame_door unbuilt (#833's 22 root::serve/root::registry failures). --- xtask/tests/ci_test_fixtures.rs | 130 ++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 xtask/tests/ci_test_fixtures.rs diff --git a/xtask/tests/ci_test_fixtures.rs b/xtask/tests/ci_test_fixtures.rs new file mode 100644 index 0000000000..a70ad85d9f --- /dev/null +++ b/xtask/tests/ci_test_fixtures.rs @@ -0,0 +1,130 @@ +//! A test that loads an example cdylib names the command that builds it, and fails (never skips) +//! when the library is absent. ModernCI's `scope: affected` narrows a run to the crates a change +//! touches, so a run of `busbar` alone used to build no other package's examples: the loader's +//! neutral frame door was missing and 22 composition-root tests failed on a change that never +//! touched them. Every CI step that runs the suites therefore builds each named fixture package BY +//! NAME before its first suite, and this file holds the CI files to that. + +use std::path::{Path, PathBuf}; + +fn repo_root() -> &'static Path { + Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap() +} + +fn rust_files(dir: &Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + if path.file_name().is_some_and(|n| n == "target") { + continue; + } + rust_files(&path, out); + } else if path.extension().is_some_and(|e| e == "rs") { + out.push(path); + } + } +} + +/// The packages whose examples some test under `crates/` tells its reader to build, read from the +/// command the test prints (`cargo build -p --examples`). +fn fixture_packages(root: &Path) -> Vec { + const HEAD: &str = "cargo build -p "; + const TAIL: &str = " --examples"; + let mut files = Vec::new(); + rust_files(&root.join("crates"), &mut files); + let mut packages: Vec = Vec::new(); + for file in files { + let text = std::fs::read_to_string(&file).unwrap_or_default(); + for line in text.lines() { + let mut rest = line; + while let Some(at) = rest.find(HEAD) { + let after = &rest[at + HEAD.len()..]; + let Some(end) = after.find(TAIL) else { break }; + let package = &after[..end]; + if !package.is_empty() && !package.contains(' ') { + packages.push(package.to_string()); + } + rest = &after[end..]; + } + } + } + packages.sort(); + packages.dedup(); + packages +} + +/// Every `run` block in `.ci/*.yaml` that runs the suites, by `:`. +fn suite_steps(root: &Path) -> Vec<(String, String)> { + let mut steps = Vec::new(); + let mut files: Vec = std::fs::read_dir(root.join(".ci")) + .unwrap() + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|e| e == "yaml")) + .collect(); + files.sort(); + for file in files { + let text = std::fs::read_to_string(&file).unwrap(); + let doc: serde_yaml::Value = serde_yaml::from_str(&text) + .unwrap_or_else(|e| panic!("{} does not parse: {e}", file.display())); + let Some(checks) = doc.get("checks").and_then(|c| c.as_mapping()) else { + continue; + }; + for (name, check) in checks { + let Some(run) = check.get("run").and_then(|r| r.as_str()) else { + continue; + }; + if run.contains("cargo nextest run") { + let label = format!( + "{}:{}", + file.file_name().unwrap().to_string_lossy(), + name.as_str().unwrap_or("?") + ); + steps.push((label, run.to_string())); + } + } + } + steps +} + +#[test] +fn an_affected_scope_run_of_busbar_alone_builds_the_examples_its_tests_load() { + let root = repo_root(); + let packages = fixture_packages(root); + assert!( + packages.iter().any(|p| p == "busbar-plugin-loader") + && packages.iter().any(|p| p == "busbar"), + "the scan found no test naming the loader's or busbar's example build, so it proved \ + nothing: {packages:?}" + ); + let steps = suite_steps(root); + assert!( + steps.iter().any(|(label, _)| label == "default.yaml:test"), + "no suite step was found in .ci/ (expected default.yaml:test): {:?}", + steps.iter().map(|(l, _)| l).collect::>() + ); + let mut missing = Vec::new(); + for (label, run) in &steps { + let before_suites: Vec<&str> = run + .lines() + .map(str::trim) + .take_while(|l| !l.starts_with("cargo nextest run")) + .collect(); + for package in &packages { + let wanted = format!("cargo build --locked -p {package} --examples"); + if !before_suites.contains(&wanted.as_str()) { + missing.push(format!("{label}: `{wanted}`")); + } + } + } + assert!( + missing.is_empty(), + "a suite step can run without the example cdylibs its tests load; a run scoped to one \ + crate builds no other package's examples, so each must be built by name before the \ + first suite:\n{}", + missing.join("\n") + ); +} From f6ada72804118e3bbba66d041a3d6154acaef7ab Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:25:48 -0700 Subject: [PATCH 49/59] ci: the suite steps build the loader's and busbar's example cdylibs by name, always A test that dlopens an example cdylib fails, never skips, when it is absent. The `test` step only got the loader's examples as a side effect of a workspace-wide test build; under `scope: affected` a run of busbar alone built none, and the neutral frame door's 22 composition-root tests failed on changes that never touched them (#833). Each suite step (default.yaml test and test-gated, predev.yaml test-gated) now runs `cargo build --locked -p busbar-plugin-loader --examples` and `cargo build --locked -p busbar --examples` before its first suite. Always: no variable tells a step the affected set, so the build cannot be keyed to it. Turns the previous commit's RED green. --- .ci/default.yaml | 14 ++++++++++++++ .ci/predev.yaml | 7 +++++++ 2 files changed, 21 insertions(+) diff --git a/.ci/default.yaml b/.ci/default.yaml index 3f8759b234..d6165464b4 100644 --- a/.ci/default.yaml +++ b/.ci/default.yaml @@ -49,6 +49,13 @@ checks: run: | meta=$(cargo metadata --format-version 1 --locked) cargo build --locked $(echo "$meta" | jq -r '.workspace_members as $m | .packages[] | select(.id as $i | $m | index($i)) | select(any(.targets[]; .kind | index("cdylib"))) | "-p " + .name') + # The example cdylibs the tests dlopen, built BY NAME and always: the loader's (the neutral frame + # door and the other door fixtures, which busbar's and the connector's tests load too) and + # busbar's test plane. A run scoped to `busbar` alone builds no other package's examples, and a + # test whose fixture is absent fails rather than skips; no variable tells this step the + # affected set, so the build does not wait on one. + cargo build --locked -p busbar-plugin-loader --examples + cargo build --locked -p busbar --examples plugin() { man=$(echo "$meta" | jq -r --arg p "$1" 'first(.packages[] | select(.name == $p) | .manifest_path) // empty') if [ -z "$man" ]; then [ "$3" = optional ] && return 0; echo "busbar resolves no $1"; return 1; fi @@ -84,6 +91,13 @@ checks: run: | meta=$(cargo metadata --format-version 1 --locked) cargo build --locked $(echo "$meta" | jq -r '.workspace_members as $m | .packages[] | select(.id as $i | $m | index($i)) | select(any(.targets[]; .kind | index("cdylib"))) | "-p " + .name') + # The example cdylibs the tests dlopen, built BY NAME and always: the loader's (the neutral frame + # door and the other door fixtures, which busbar's and the connector's tests load too) and + # busbar's test plane. A run scoped to `busbar` alone builds no other package's examples, and a + # test whose fixture is absent fails rather than skips; no variable tells this step the + # affected set, so the build does not wait on one. + cargo build --locked -p busbar-plugin-loader --examples + cargo build --locked -p busbar --examples plugin() { man=$(echo "$meta" | jq -r --arg p "$1" 'first(.packages[] | select(.name == $p) | .manifest_path) // empty') if [ -z "$man" ]; then [ "$3" = optional ] && return 0; echo "busbar resolves no $1"; return 1; fi diff --git a/.ci/predev.yaml b/.ci/predev.yaml index 5e7063a3a6..bdbda69fb0 100644 --- a/.ci/predev.yaml +++ b/.ci/predev.yaml @@ -107,6 +107,13 @@ checks: run: | meta=$(cargo metadata --format-version 1 --locked) cargo build --locked $(echo "$meta" | jq -r '.workspace_members as $m | .packages[] | select(.id as $i | $m | index($i)) | select(any(.targets[]; .kind | index("cdylib"))) | "-p " + .name') + # The example cdylibs the tests dlopen, built BY NAME and always: the loader's (the neutral frame + # door and the other door fixtures, which busbar's and the connector's tests load too) and + # busbar's test plane. A run scoped to `busbar` alone builds no other package's examples, and a + # test whose fixture is absent fails rather than skips; no variable tells this step the + # affected set, so the build does not wait on one. + cargo build --locked -p busbar-plugin-loader --examples + cargo build --locked -p busbar --examples plugin() { man=$(echo "$meta" | jq -r --arg p "$1" 'first(.packages[] | select(.name == $p) | .manifest_path) // empty') if [ -z "$man" ]; then [ "$3" = optional ] && return 0; echo "busbar resolves no $1"; return 1; fi From c83d790ea4da1f0349c6178b6c6e6457892de01b Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:42:22 -0700 Subject: [PATCH 50/59] llm codec $: an unreadable image token count is a refusal, never a recorded zero The image readers (gemini/handler.rs and openai_chat/handler.rs, read_image_response) read the recorded image token counts with a silent zero default (`.and_then(read_count_u64).unwrap_or(0)`). That turned gates no-float-money:count-read-shape (#81) red: a defaulted zero records no work for work that happened (OWNER rule 21: money is ledger x ratecard, exact). Both now read through the crate's one count seam, usage_count::billed_count, as every other billed and recorded read in the codec does: absent or null is zero, a readable count (float spelling included) is recorded, and a present unreadable count is a CodecError refusal naming the field. The counts stay recorded (residual units) and never billed, as in 1.5.5. Two REDs, one per site, in float_usage_tests.rs: an_unreadable_image_count_refuses_instead_of_recording_zero. Each fails on the defaulted read and passes on the refusal. --- .../src/codec/gemini/handler.rs | 37 +++++++++++-------- .../codec/gemini/tests/float_usage_tests.rs | 31 +++++++--------- .../src/codec/openai_chat/handler.rs | 34 +++++++++-------- .../openai_chat/tests/float_usage_tests.rs | 32 +++++++--------- 4 files changed, 69 insertions(+), 65 deletions(-) diff --git a/crates/busbar-plane-llm/src/codec/gemini/handler.rs b/crates/busbar-plane-llm/src/codec/gemini/handler.rs index a419837c18..cb6a2678c5 100644 --- a/crates/busbar-plane-llm/src/codec/gemini/handler.rs +++ b/crates/busbar-plane-llm/src/codec/gemini/handler.rs @@ -839,21 +839,28 @@ pub fn read_image_response(wire: &[u8]) -> Result Result { + Ok(busbar_contract::billing::TokenUsage { + input: crate::codec::usage_count::billed_count(u, FIELD_PROMPT_TOKEN_COUNT) + .map_err(|e| CodecError::Malformed(e.to_string()))?, + output: crate::codec::usage_count::billed_count( + u, + FIELD_CANDIDATES_TOKEN_COUNT, + ) + .map_err(|e| CodecError::Malformed(e.to_string()))?, + ..Default::default() + }) + }, + ) + .transpose()?; // Per-image (Imagen `:predict`) responses carry no `usageMetadata` — record the per-image cost // basis so the op bills as `Billing::Images` rather than nothing. The billable COUNT is // recoverable from the response itself (one image per `predictions` entry). Size/quality tiers diff --git a/crates/busbar-plane-llm/src/codec/gemini/tests/float_usage_tests.rs b/crates/busbar-plane-llm/src/codec/gemini/tests/float_usage_tests.rs index e326139f22..150f97c084 100644 --- a/crates/busbar-plane-llm/src/codec/gemini/tests/float_usage_tests.rs +++ b/crates/busbar-plane-llm/src/codec/gemini/tests/float_usage_tests.rs @@ -170,27 +170,24 @@ fn additive_sum_is_correct_when_exactly_one_term_is_float_spelled() { // `usageMetadata` field that is there and is not a number busbar understands. The old readers // defaulted it to zero, writing "no work happened" into the ledger for work that did. -/// AN IMAGE ANSWER'S TOKENS ARE RECORDED, NOT BILLED, SO AN UNREADABLE ONE NEVER REFUSES IT. +/// THE BUFFERED IMAGE READER REFUSES AN UNREADABLE RECORDED COUNT (#81/#42, OWNER rule 21). /// -/// 1.5.5's Imagen reader read no `usageMetadata` (v1.5.5 `crates/busbar/src/handlers/gemini.rs:446-472`) -/// and served the answer. The counts are now recorded (residual units), never billed, so an -/// unreadable one is left out of the record rather than refusing a served answer. +/// The image tokens are recorded (residual units), never billed, but a recorded count is a ledger +/// row: a present count the build cannot read is a refusal, never a zero that records no work for +/// work that happened. RED while `gemini/handler.rs` `read_image_response` defaulted an unreadable +/// count to zero. #[test] -fn an_unreadable_image_count_never_refuses_the_answer() { +fn an_unreadable_image_count_refuses_instead_of_recording_zero() { let wire = br#"{"predictions":[{"bytesBase64Encoded":"aGk="}],"usageMetadata":{"promptTokenCount":"27","candidatesTokenCount":5}}"#; - let resp = crate::codec::gemini::handler::read_image_response(wire) - .expect("an image answer is served whatever its usage spelling"); - match resp.billing() { - Some(busbar_contract::billing::Billing::Tokens(t)) => { - assert_eq!((t.input, t.output), (0, 0)); - assert_eq!( - t.residual_units, - std::collections::BTreeMap::from([("image.output_tokens".to_string(), 5)]) - ); - } - other => panic!("expected a zero token line, got {other:?}"), - } + let err = crate::codec::gemini::handler::read_image_response(wire) + .expect_err("a present, unreadable count is a refusal, never a zero"); + assert!( + format!("{err:?}").contains("promptTokenCount"), + "the refusal names the field: {err:?}" + ); + // The same body, float-spelled, still reads and is recorded: the refusal is about + // unreadability only. let ok = br#"{"predictions":[{"bytesBase64Encoded":"aGk="}],"usageMetadata":{"promptTokenCount":27.0,"candidatesTokenCount":5}}"#; let resp = crate::codec::gemini::handler::read_image_response(ok) .expect("a float-spelled count reads"); diff --git a/crates/busbar-plane-llm/src/codec/openai_chat/handler.rs b/crates/busbar-plane-llm/src/codec/openai_chat/handler.rs index 200dd8fc72..eab1298888 100644 --- a/crates/busbar-plane-llm/src/codec/openai_chat/handler.rs +++ b/crates/busbar-plane-llm/src/codec/openai_chat/handler.rs @@ -1364,21 +1364,25 @@ pub fn read_image_response(wire: &[u8]) -> Result Result { + Ok(busbar_contract::billing::TokenUsage { + input: crate::codec::usage_count::billed_count(u, keys::INPUT_TOKENS) + .map_err(|e| CodecError::Malformed(e.to_string()))?, + output: crate::codec::usage_count::billed_count(u, keys::OUTPUT_TOKENS) + .map_err(|e| CodecError::Malformed(e.to_string()))?, + ..Default::default() + }) + }, + ) + .transpose()?; // Per-image (dall-e-style) providers carry no `usage` — record the per-image cost basis so the // op is billed as `Billing::Images` rather than nothing. The billable COUNT is recoverable from // the response itself (one image per `data` entry). The size/quality TIERS live on the request diff --git a/crates/busbar-plane-llm/src/codec/openai_chat/tests/float_usage_tests.rs b/crates/busbar-plane-llm/src/codec/openai_chat/tests/float_usage_tests.rs index d77332ee25..1c71e7c45a 100644 --- a/crates/busbar-plane-llm/src/codec/openai_chat/tests/float_usage_tests.rs +++ b/crates/busbar-plane-llm/src/codec/openai_chat/tests/float_usage_tests.rs @@ -132,28 +132,24 @@ fn buffered_read_response_reads_float_encoded_counts() { // `ledger x ratecard`, so every figure derived from that row was faithfully wrong and nothing // downstream could tell. These pin the live reader paths, which is where the ledger row comes from. -/// AN IMAGE ANSWER'S TOKENS ARE RECORDED, NOT BILLED, SO AN UNREADABLE ONE NEVER REFUSES IT. +/// THE BUFFERED IMAGE READER REFUSES AN UNREADABLE RECORDED COUNT (#81/#42, OWNER rule 21). /// -/// 1.5.5 served every gpt-image-1 answer and read no `usage` (v1.5.5 -/// `crates/busbar/src/handlers/openai.rs:731-758`). The counts are now recorded (residual units), -/// never billed, so an unreadable one is left out of the record rather than refusing a served -/// answer; a float-spelled one is recorded as its count. +/// The image tokens are recorded (residual units), never billed, but a recorded count is a ledger +/// row: a present count the build cannot read is a refusal, never a zero that records no work for +/// work that happened. RED while `openai_chat/handler.rs` `read_image_response` defaulted an unreadable +/// count to zero. #[test] -fn an_unreadable_image_count_never_refuses_the_answer() { +fn an_unreadable_image_count_refuses_instead_of_recording_zero() { + // `"27"` is a string, not a number: present, and not a count. let wire = br#"{"data":[{"b64_json":"aGk="}],"usage":{"input_tokens":"27","output_tokens":5}}"#; - let resp = crate::codec::openai_chat::handler::read_image_response(wire) - .expect("an image answer is served whatever its usage spelling"); - match resp.billing() { - Some(busbar_contract::billing::Billing::Tokens(t)) => { - assert_eq!((t.input, t.output), (0, 0)); - assert_eq!( - t.residual_units, - std::collections::BTreeMap::from([("image.output_tokens".to_string(), 5)]) - ); - } - other => panic!("expected a zero token line, got {other:?}"), - } + let err = crate::codec::openai_chat::handler::read_image_response(wire) + .expect_err("a present, unreadable count is a refusal, never a zero"); + assert!( + format!("{err:?}").contains("input_tokens"), + "the refusal names the field: {err:?}" + ); + // The same body with a READABLE count still reads, float-spelled included, and is recorded. let ok = br#"{"data":[{"b64_json":"aGk="}],"usage":{"input_tokens":27.0,"output_tokens":5}}"#; let resp = crate::codec::openai_chat::handler::read_image_response(ok) .expect("a float-spelled count reads"); From 461acdbd894c91fba967aa8acfa8fd9892f7de2e Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:50:16 -0700 Subject: [PATCH 51/59] llm codec $: an unreadable image token count is UNKNOWN; the answer is served and the count is never 0 Coordinator correction to c83d790ea4. The image token counts are recorded, never billed, and 1.5.5 served the answer whatever its usage said (v1.5.5 handlers/openai.rs:731-758 and handlers/gemini.rs:446-472 read no usage). So refusing the answer over an unreadable, unbilled count is a customer-visible change that the money rule does not require. A fabricated 0 is still banned (#81, OWNER rule 21). - ir/image.rs: ImageResp.usage is Option, output: Option }>. `None` is UNKNOWN. billing() leaves an unknown count out of residual_units, as it does a zero. - usage_count::recorded_count reads through billed_count, the one seam. Absent or null is Some(0); readable is Some(n); present and unreadable is None, with the field named on the answer's warnings and in a warn log line. It is never 0 and never a refusal. - Both read_image_response readers (gemini, openai_chat) use recorded_count and carry the warnings. - Both write_image_response writers leave an unknown count out, along with the total it would sum into, rather than writing 0. A fully readable usage writes the same bytes as before. REDs, one per site, in float_usage_tests.rs: an_unreadable_image_count_is_recorded_as_unknown_and_the_answer_served. Each asserts the answer is served, the count is unknown (None, absent from the record and from the re-emitted body, never 0), and the field is named in the warnings. Each fails on the defaulted read and on the refusal. --- .../src/codec/gemini/handler.rs | 54 +++++++++------- .../codec/gemini/tests/float_usage_tests.rs | 64 +++++++++++++++---- .../src/codec/gemini/tests/handler_tests.rs | 14 ++-- crates/busbar-plane-llm/src/codec/ir/image.rs | 18 +++++- .../src/codec/ir/tests/image_tests.rs | 7 +- .../src/codec/openai_chat/handler.rs | 47 ++++++++------ .../openai_chat/tests/float_usage_tests.rs | 64 +++++++++++++++---- .../busbar-plane-llm/src/codec/usage_count.rs | 25 ++++++++ 8 files changed, 212 insertions(+), 81 deletions(-) diff --git a/crates/busbar-plane-llm/src/codec/gemini/handler.rs b/crates/busbar-plane-llm/src/codec/gemini/handler.rs index cb6a2678c5..2fe97b5ab9 100644 --- a/crates/busbar-plane-llm/src/codec/gemini/handler.rs +++ b/crates/busbar-plane-llm/src/codec/gemini/handler.rs @@ -411,12 +411,20 @@ pub fn write_image_response(r: &crate::codec::ir::image::ImageResp) -> WireBody // `usageMetadata` for token-metered gemini image models). The old writer dropped it, so a // token-metered gemini->gemini image response lost its usage in the client-facing body — // asymmetric with the OpenAI image writer. Emitted only when the IR carries usage. + // An UNKNOWN count (one the reader could not read) is left out rather than written as 0, and + // so is the total it would have summed into. if let Some(u) = &r.usage { - body[FIELD_USAGE_METADATA] = json!({ - (FIELD_PROMPT_TOKEN_COUNT): u.input, - (FIELD_CANDIDATES_TOKEN_COUNT): u.output, - (FIELD_TOTAL_TOKEN_COUNT): u.input.saturating_add(u.output), - }); + let mut meta = serde_json::Map::new(); + if let Some(n) = u.input { + meta.insert(FIELD_PROMPT_TOKEN_COUNT.into(), json!(n)); + } + if let Some(n) = u.output { + meta.insert(FIELD_CANDIDATES_TOKEN_COUNT.into(), json!(n)); + } + if let (Some(i), Some(o)) = (u.input, u.output) { + meta.insert(FIELD_TOTAL_TOKEN_COUNT.into(), json!(i.saturating_add(o))); + } + body[FIELD_USAGE_METADATA] = Value::Object(meta); } WireBody::json(SlabBytes::from( serde_json::to_vec(&body).unwrap_or_default(), @@ -841,26 +849,25 @@ pub fn read_image_response(wire: &[u8]) -> Result Result { - Ok(busbar_contract::billing::TokenUsage { - input: crate::codec::usage_count::billed_count(u, FIELD_PROMPT_TOKEN_COUNT) - .map_err(|e| CodecError::Malformed(e.to_string()))?, - output: crate::codec::usage_count::billed_count( - u, - FIELD_CANDIDATES_TOKEN_COUNT, - ) - .map_err(|e| CodecError::Malformed(e.to_string()))?, - ..Default::default() - }) - }, - ) - .transpose()?; + .map(|u| crate::codec::ir::image::ImageTokens { + input: crate::codec::usage_count::recorded_count( + u, + FIELD_PROMPT_TOKEN_COUNT, + &mut warnings, + ), + output: crate::codec::usage_count::recorded_count( + u, + FIELD_CANDIDATES_TOKEN_COUNT, + &mut warnings, + ), + }); // Per-image (Imagen `:predict`) responses carry no `usageMetadata` — record the per-image cost // basis so the op bills as `Billing::Images` rather than nothing. The billable COUNT is // recoverable from the response itself (one image per `predictions` entry). Size/quality tiers @@ -877,6 +884,7 @@ pub fn read_image_response(wire: &[u8]) -> Result { + assert_eq!((t.input, t.output), (0, 0), "image tokens never bill"); + assert_eq!( + t.residual_units, + std::collections::BTreeMap::from([("image.output_tokens".to_string(), 5)]), + "the unknown count is left out of the record, never recorded as 0" + ); + } + other => panic!("expected a zero token line, got {other:?}"), + } + let written: serde_json::Value = + serde_json::from_slice(&crate::codec::gemini::handler::write_image_response(&resp).bytes) + .unwrap(); + assert_eq!( + written.pointer("/usageMetadata/promptTokenCount"), + None, + "no 0 is written for it: {written}" + ); + assert_eq!( + written.pointer("/usageMetadata/totalTokenCount"), + None, + "nor a total that would include it" + ); + assert_eq!( + written.pointer("/usageMetadata/candidatesTokenCount"), + Some(&serde_json::json!(5)) ); - // The same body, float-spelled, still reads and is recorded: the refusal is about - // unreadability only. + // The same body, float-spelled, reads and is recorded: only an unreadable count is unknown. let ok = br#"{"predictions":[{"bytesBase64Encoded":"aGk="}],"usageMetadata":{"promptTokenCount":27.0,"candidatesTokenCount":5}}"#; let resp = crate::codec::gemini::handler::read_image_response(ok) .expect("a float-spelled count reads"); diff --git a/crates/busbar-plane-llm/src/codec/gemini/tests/handler_tests.rs b/crates/busbar-plane-llm/src/codec/gemini/tests/handler_tests.rs index 86cda9e135..d0f3a6f58d 100644 --- a/crates/busbar-plane-llm/src/codec/gemini/tests/handler_tests.rs +++ b/crates/busbar-plane-llm/src/codec/gemini/tests/handler_tests.rs @@ -856,10 +856,9 @@ fn image_response_writer_round_trips_usage() { b64: Some("aGVsbG8=".into()), ..Default::default() }], - usage: Some(busbar_contract::billing::TokenUsage { - input: 10, - output: 5, - ..Default::default() + usage: Some(crate::codec::ir::image::ImageTokens { + input: Some(10), + output: Some(5), }), ..Default::default() }; @@ -878,10 +877,9 @@ fn image_response_writer_round_trips_usage() { let back = read_image_response(&wb.bytes).expect("re-read"); assert_eq!( back.usage, - Some(busbar_contract::billing::TokenUsage { - input: 10, - output: 5, - ..Default::default() + Some(crate::codec::ir::image::ImageTokens { + input: Some(10), + output: Some(5), }), "image usage must round-trip through write->read" ); diff --git a/crates/busbar-plane-llm/src/codec/ir/image.rs b/crates/busbar-plane-llm/src/codec/ir/image.rs index 3751661639..5e695c2c4a 100644 --- a/crates/busbar-plane-llm/src/codec/ir/image.rs +++ b/crates/busbar-plane-llm/src/codec/ir/image.rs @@ -179,12 +179,23 @@ pub struct CostBasis { pub struct ImageResp { pub created: Option, pub images: Vec, // additive per image (b64 AND url may coexist) - pub usage: Option, // gpt-image-1, Gemini: the reported tokens (recorded, not billed) + pub usage: Option, // gpt-image-1, Gemini: reported tokens (recorded, not billed) pub cost_basis: Option, // per-image providers (dall-e/Imagen/Titan/SDXL) - pub warnings: Vec, // raiFilteredReason, finish_reasons, moderation notes + pub warnings: Vec, // raiFilteredReason, finish_reasons, moderation notes pub extra: SourceScopedExtra, } +/// The token counts an image answer reported (gpt-image-1 `usage`, a Gemini image model's +/// `usageMetadata`). They are RECORDED, never billed (see [`ImageResp::billing`]). `None` is +/// UNKNOWN: the provider sent a count this build cannot read. An unknown count is left out of the +/// record, never written as 0, and never refuses the answer (1.5.5 served it whatever the usage +/// said). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct ImageTokens { + pub input: Option, + pub output: Option, +} + /// The residual key an image answer's reported INPUT token count is recorded under. pub const IMAGE_INPUT_TOKENS_RESIDUAL: &str = "image.input_tokens"; /// The residual key an image answer's reported OUTPUT token count is recorded under. @@ -208,7 +219,8 @@ impl ImageResp { (IMAGE_INPUT_TOKENS_RESIDUAL, u.input), (IMAGE_OUTPUT_TOKENS_RESIDUAL, u.output), ] { - if n != 0 { + // An unknown count (`None`) is left out, as a zero is: neither is a reported unit. + if let Some(n) = n.filter(|n| *n != 0) { residual_units.insert(class.to_string(), n); } } diff --git a/crates/busbar-plane-llm/src/codec/ir/tests/image_tests.rs b/crates/busbar-plane-llm/src/codec/ir/tests/image_tests.rs index ba16243ade..e5927e18e9 100644 --- a/crates/busbar-plane-llm/src/codec/ir/tests/image_tests.rs +++ b/crates/busbar-plane-llm/src/codec/ir/tests/image_tests.rs @@ -24,10 +24,9 @@ fn geometry_conventions_are_parallel_not_collapsed() { #[test] fn billing_prefers_tokens_else_per_image() { let tokenized = ImageResp { - usage: Some(TokenUsage { - input: 5, - output: 272, - ..Default::default() + usage: Some(super::ImageTokens { + input: Some(5), + output: Some(272), }), ..Default::default() }; diff --git a/crates/busbar-plane-llm/src/codec/openai_chat/handler.rs b/crates/busbar-plane-llm/src/codec/openai_chat/handler.rs index eab1298888..d0f0989ada 100644 --- a/crates/busbar-plane-llm/src/codec/openai_chat/handler.rs +++ b/crates/busbar-plane-llm/src/codec/openai_chat/handler.rs @@ -737,12 +737,20 @@ pub fn write_image_response(r: &ImageResp) -> WireBody { // gpt-image-1 returns a token `usage` object; the reader parses it (for billing) but the writer // dropped it, so a same-/cross-protocol image hop lost the usage the client should see. Re-emit // it in OpenAI's own image-usage shape when present. + // An UNKNOWN count (one the reader could not read) is left out rather than written as 0, and + // so is the total it would have summed into. if let Some(u) = &r.usage { - body[keys::USAGE] = json!({ - (keys::INPUT_TOKENS): u.input, - (keys::OUTPUT_TOKENS): u.output, - (keys::TOTAL_TOKENS): u.input.saturating_add(u.output), - }); + let mut usage = serde_json::Map::new(); + if let Some(n) = u.input { + usage.insert(keys::INPUT_TOKENS.into(), json!(n)); + } + if let Some(n) = u.output { + usage.insert(keys::OUTPUT_TOKENS.into(), json!(n)); + } + if let (Some(i), Some(o)) = (u.input, u.output) { + usage.insert(keys::TOTAL_TOKENS.into(), json!(i.saturating_add(o))); + } + body[keys::USAGE] = Value::Object(usage); } WireBody::json(SlabBytes::from( serde_json::to_vec(&body).unwrap_or_default(), @@ -1366,23 +1374,21 @@ pub fn read_image_response(wire: &[u8]) -> Result Result { - Ok(busbar_contract::billing::TokenUsage { - input: crate::codec::usage_count::billed_count(u, keys::INPUT_TOKENS) - .map_err(|e| CodecError::Malformed(e.to_string()))?, - output: crate::codec::usage_count::billed_count(u, keys::OUTPUT_TOKENS) - .map_err(|e| CodecError::Malformed(e.to_string()))?, - ..Default::default() - }) - }, - ) - .transpose()?; + .map(|u| crate::codec::ir::image::ImageTokens { + input: crate::codec::usage_count::recorded_count(u, keys::INPUT_TOKENS, &mut warnings), + output: crate::codec::usage_count::recorded_count( + u, + keys::OUTPUT_TOKENS, + &mut warnings, + ), + }); // Per-image (dall-e-style) providers carry no `usage` — record the per-image cost basis so the // op is billed as `Billing::Images` rather than nothing. The billable COUNT is recoverable from // the response itself (one image per `data` entry). The size/quality TIERS live on the request @@ -1400,6 +1406,7 @@ pub fn read_image_response(wire: &[u8]) -> Result { + assert_eq!((t.input, t.output), (0, 0), "image tokens never bill"); + assert_eq!( + t.residual_units, + std::collections::BTreeMap::from([("image.output_tokens".to_string(), 5)]), + "the unknown count is left out of the record, never recorded as 0" + ); + } + other => panic!("expected a zero token line, got {other:?}"), + } + let written: serde_json::Value = serde_json::from_slice( + &crate::codec::openai_chat::handler::write_image_response(&resp).bytes, + ) + .unwrap(); + assert_eq!( + written.pointer("/usage/input_tokens"), + None, + "no 0 is written for it: {written}" + ); + assert_eq!( + written.pointer("/usage/total_tokens"), + None, + "nor a total that would include it" + ); + assert_eq!( + written.pointer("/usage/output_tokens"), + Some(&serde_json::json!(5)) ); - // The same body with a READABLE count still reads, float-spelled included, and is recorded. + // The same body, float-spelled, reads and is recorded: only an unreadable count is unknown. let ok = br#"{"data":[{"b64_json":"aGk="}],"usage":{"input_tokens":27.0,"output_tokens":5}}"#; let resp = crate::codec::openai_chat::handler::read_image_response(ok) .expect("a float-spelled count reads"); diff --git a/crates/busbar-plane-llm/src/codec/usage_count.rs b/crates/busbar-plane-llm/src/codec/usage_count.rs index fa208ab3e5..f9e59070e8 100644 --- a/crates/busbar-plane-llm/src/codec/usage_count.rs +++ b/crates/busbar-plane-llm/src/codec/usage_count.rs @@ -158,6 +158,31 @@ pub fn billed_count_opt( } } +/// A RECORDED count (one the ledger notes and never bills): absent or `null` is `Some(0)` (nothing +/// reported, as [`billed_count`] reads it), a readable count is `Some(n)`, and a present count this +/// build cannot read is UNKNOWN, `None`. It is never a zero, because a zero records no work for +/// work that happened (#81, OWNER rule 21). It is never a refusal either, because a count nothing +/// bills does not justify refusing an answer. The unreadable field is named on `warnings` and in a +/// warning log line, so the gap is visible where the record is read. +pub fn recorded_count( + usage: &serde_json::Value, + field: &'static str, + warnings: &mut Vec, +) -> Option { + match billed_count(usage, field) { + Ok(n) => Some(n), + Err(unreadable) => { + ::tracing::warn!( + field = unreadable.field, + spelling = %unreadable.spelling, + "a recorded usage count is unreadable: recorded as unknown, never as 0" + ); + warnings.push(unreadable.to_string()); + None + } + } +} + // ── THE USAGE TABLE WALK (#42) ─────────────────────────────────────────────────────────────── /// Where one usage count lands in the IR. From 93f25e2a7821b8a47fcabdfbd5115312a1d4d798 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:10:27 -0700 Subject: [PATCH 52/59] connector tests: the datagram rig's into_host is deliver (clippy wrong_self_convention, row 132) clippy -D warnings refused `fn into_host(&mut self, ..)`: an into_* method takes self by value. The rig hands one datagram to the host; deliver says so and names no conversion. Every call site is renamed; nothing else changes. --- .../src/tests/datagram_tests.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/busbar-core-connector/src/tests/datagram_tests.rs b/crates/busbar-core-connector/src/tests/datagram_tests.rs index 35a6826a97..c86ae4ec50 100644 --- a/crates/busbar-core-connector/src/tests/datagram_tests.rs +++ b/crates/busbar-core-connector/src/tests/datagram_tests.rs @@ -405,7 +405,7 @@ fn rig(knobs: Knobs) -> Rig { } impl Rig { - fn into_host(&mut self, from: SocketAddr, d: &[u8]) { + fn deliver(&mut self, from: SocketAddr, d: &[u8]) { let y = self .host .datagram(from, d, self.now) @@ -432,14 +432,14 @@ impl Rig { // The far end's ICE agent answers the host's probe under its own password. let a = answer(&d, FAR_PWD); let from = self.far.addr; - self.into_host(from, &a); + self.deliver(from, &a); } } let _ = self.far.a.bind_path(self.host_addr, self.now); while let Some((to, d)) = self.far.a.poll_wire() { assert_eq!(to, self.host_addr); let from = self.far.addr; - self.into_host(from, &d); + self.deliver(from, &d); } while let Some(u) = self.far.a.poll_up() { self.far.ups.push(u); @@ -450,7 +450,7 @@ impl Rig { fn first_check(&mut self) { let c = check(HOST_UFRAG, FAR_UFRAG, HOST_PWD, 1); let from = self.far.addr; - self.into_host(from, &c); + self.deliver(from, &c); } fn seen(&self) -> Vec { @@ -528,11 +528,11 @@ fn a_source_that_sent_no_verified_check_never_reaches_the_framer() { let spoof = p("192.0.2.66:4444"); let before = r.seen().len(); // A check under the wrong password, and media, from a source the far end never was. - r.into_host( + r.deliver( spoof, &check(HOST_UFRAG, FAR_UFRAG, "wrong-password-012345", 2), ); - r.into_host(spoof, &[0x80, 0, 0, 1, 2, 3]); + r.deliver(spoof, &[0x80, 0, 0, 1, 2, 3]); let seen = r.seen(); assert_eq!(seen.len(), before, "nothing crossed for the spoofed source"); assert!(seen @@ -561,7 +561,7 @@ fn a_rebind_to_a_path_that_never_answered_the_probe_is_not_applied() { // A verified check from a new source that never answers core's probe (the password leaked; the // address is not the far end's). let spoof = p("192.0.2.77:5555"); - r.into_host(spoof, &check(HOST_UFRAG, FAR_UFRAG, HOST_PWD, 3)); + r.deliver(spoof, &check(HOST_UFRAG, FAR_UFRAG, HOST_PWD, 3)); r.pump(100); assert_eq!( r.host.bound(), @@ -586,7 +586,7 @@ fn a_rebind_to_a_path_that_never_answered_the_probe_is_not_applied() { .find(|(_, d)| d[0..2] == [0, 1]) .map(|(_, d)| d.clone()) .expect("core probed the new path"); - r.into_host(spoof, &answer(&probe, FAR_PWD)); + r.deliver(spoof, &answer(&probe, FAR_PWD)); assert_eq!(r.host.bound(), Some(spoof), "a proven path is bound"); } From a2f879af7a7d9d594e1e36343dca8feaae10f3f3 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Wed, 14 Oct 2026 04:41:51 -0700 Subject: [PATCH 53/59] row 155: the usage-tap fault leaves the contract; the door returns it beside its units and the kernel counts it The codec's USAGE_TAP_FAULT_REPORTER and USAGE_TAP_FAULT_LATCH statics, their install and report ports, and the hot vtable's tap_fault_latch service are gone. OperationHandler::extract_usage returns the fault (UsageTapFault: decode, bad_json, unknown_protocol) as data; the llm door carries it on OnPieceOut::tap_fault (laid in the padding after fault, pre-tag v1, size unchanged), once per answer; the kernel's route driver hands it to the steps, and the root counts it on busbar_billing_tap_decode_fail_total{protocol,reason} under the unit's dialect and warns once per (protocol, reason). qa/contract-stateless.toml 44 -> 39 rows (codec.rs USAGE_TAP_FAULT_LATCH, USAGE_TAP_FAULT_REPORTER; services.rs KEPT, FILLING, once-cell OnceLock). qa/abi-freeze.toml hot 6224 -> 6100. --- .../busbar-contract/include/busbar_plugin.h | 10 +- crates/busbar-contract/src/abi/hot/host.rs | 24 ++-- .../busbar-contract/src/abi/hot/services.rs | 102 ++------------- .../src/abi/hot/tests/services_tests.rs | 44 +------ crates/busbar-contract/src/abi/plane/check.rs | 19 ++- crates/busbar-contract/src/abi/plane/mod.rs | 19 ++- .../src/abi/tests/plane_check_tests.rs | 32 ++++- crates/busbar-contract/src/codec.rs | 120 ++++++++++-------- .../tests/golden/abi-layout.golden | 3 +- crates/busbar-contract/tests/layout_golden.rs | 1 + crates/busbar-kernel/src/handlers/mod.rs | 38 +++--- crates/busbar-kernel/src/plane_driver/mod.rs | 4 + .../busbar-kernel/src/plane_driver/route.rs | 20 +++ .../src/plane_driver/tests/route_tests.rs | 22 ++++ .../src/plane_host/tests/mod_tests.rs | 4 +- crates/busbar-kernel/src/proto/installed.rs | 14 +- .../src/proto/tests/installed_tests.rs | 22 ++-- .../busbar-plane-llm/src/codec/chat_handle.rs | 79 ++++-------- .../codec/openai_chat/tests/handler_tests.rs | 2 + .../src/codec/tests/test_host.rs | 38 +----- .../src/codec/tests/usage_tap_count_tests.rs | 81 ++++++------ .../src/exchange/reply/mod.rs | 9 +- .../src/exchange/reply/relay.rs | 11 +- crates/busbar-plane-llm/src/plane_door.rs | 11 +- crates/busbar-plane-llm/tests/conformance.rs | 54 +++++++- crates/busbar/src/root/door_steps.rs | 10 +- crates/busbar/tests/plane_driver.rs | 3 +- qa/abi-freeze.toml | 2 +- qa/contract-stateless.toml | 25 ---- 29 files changed, 408 insertions(+), 415 deletions(-) diff --git a/crates/busbar-contract/include/busbar_plugin.h b/crates/busbar-contract/include/busbar_plugin.h index 0f431b1b54..71ef23621c 100644 --- a/crates/busbar-contract/include/busbar_plugin.h +++ b/crates/busbar-contract/include/busbar_plugin.h @@ -374,6 +374,10 @@ extern "C" { #define BB_PLANE_UNITS_ESTIMATED UINT32_C(0) /* [`UnitCount::source`]: the plane's own estimate; never billed. */ #define BB_PLANE_UNITS_REPORTED UINT32_C(1) /* [`UnitCount::source`]: the far end reported it; it bills. */ #define BB_PLANE_UNITS_FLOOR UINT32_C(2) /* [`UnitCount::source`]: the plane's floor for a delivered reply whose far-end usage could not be */ +#define BB_PLANE_TAP_FAULT_NONE UINT8_C(0) /* [`OnPieceOut::tap_fault`]: no usage-tap fault. */ +#define BB_PLANE_TAP_FAULT_DECODE UINT8_C(1) /* [`OnPieceOut::tap_fault`]: the cell's own reader refused the body (`reason="decode"`). */ +#define BB_PLANE_TAP_FAULT_BAD_JSON UINT8_C(2) /* [`OnPieceOut::tap_fault`]: the body is not JSON (`reason="bad_json"`). */ +#define BB_PLANE_TAP_FAULT_UNKNOWN_PROTOCOL UINT8_C(3) /* [`OnPieceOut::tap_fault`]: the caller's dialect is not one the tap reads */ #define BB_PLANE_PRINCIPAL_NONE UINT32_C(0) /* [`ArriveOut::principal_need`]: no principal. */ #define BB_PLANE_PRINCIPAL_REQUIRED UINT32_C(1) /* [`ArriveOut::principal_need`]: the kernel must verify a principal before the first piece. */ #define BB_PLANE_PRINCIPAL_OPTIONAL UINT32_C(2) /* [`ArriveOut::principal_need`]: verify one if the caller presents it. */ @@ -2791,7 +2795,8 @@ struct bb_plane_OnPieceOut { bb_mech_Span target; uint32_t need; uint8_t fault; - uint8_t _fault_reserved[3]; + uint8_t tap_fault; + uint8_t _fault_reserved[2]; bb_mech_Span lane; uint32_t final_status; uint32_t _final_reserved; @@ -5290,7 +5295,8 @@ BB_ASSERT(offsetof(bb_plane_OnPieceOut, verb) == 160, "bb_plane_OnPieceOut.verb: BB_ASSERT(offsetof(bb_plane_OnPieceOut, target) == 168, "bb_plane_OnPieceOut.target: offset"); BB_ASSERT(offsetof(bb_plane_OnPieceOut, need) == 176, "bb_plane_OnPieceOut.need: offset"); BB_ASSERT(offsetof(bb_plane_OnPieceOut, fault) == 180, "bb_plane_OnPieceOut.fault: offset"); -BB_ASSERT(offsetof(bb_plane_OnPieceOut, _fault_reserved) == 181, "bb_plane_OnPieceOut._fault_reserved: offset"); +BB_ASSERT(offsetof(bb_plane_OnPieceOut, tap_fault) == 181, "bb_plane_OnPieceOut.tap_fault: offset"); +BB_ASSERT(offsetof(bb_plane_OnPieceOut, _fault_reserved) == 182, "bb_plane_OnPieceOut._fault_reserved: offset"); BB_ASSERT(offsetof(bb_plane_OnPieceOut, lane) == 184, "bb_plane_OnPieceOut.lane: offset"); BB_ASSERT(offsetof(bb_plane_OnPieceOut, final_status) == 192, "bb_plane_OnPieceOut.final_status: offset"); BB_ASSERT(offsetof(bb_plane_OnPieceOut, _final_reserved) == 196, "bb_plane_OnPieceOut._final_reserved: offset"); diff --git a/crates/busbar-contract/src/abi/hot/host.rs b/crates/busbar-contract/src/abi/hot/host.rs index 75712da13f..7e9f198f78 100644 --- a/crates/busbar-contract/src/abi/hot/host.rs +++ b/crates/busbar-contract/src/abi/hot/host.rs @@ -535,12 +535,13 @@ pub type CounterAddFn = extern "C-unwind" fn( // ── THE HOST SERVICES (minor 30; ARCHITECT SD-3 queue (6), DEC-SERVE G2). The contract ports a // codec reaches — `busbar_contract::codec::{fill_entropy, wall_clock_now, -// usage_tap_fault_should_warn, max_translate_body_bytes}` — are process-wide `OnceLock`s the host -// arms in ITS image. A plane dropped in as a `cdylib` carries its own copy of the contract, whose -// ports nothing armed, so it read the failure path of every one. These four slots carry the -// host's own services across the seam; the dropped-in door arms the plane image's ports over -// them (`services::arm`) when the host opens the door. Process-wide, so `host` may be -// `HostCtx::NULL`. ── +// max_translate_body_bytes}` — are process-wide `OnceLock`s the host arms in ITS image. A plane +// dropped in as a `cdylib` carries its own copy of the contract, whose ports nothing armed, so it +// read the failure path of every one. These slots carry the host's own services across the seam; +// the dropped-in door arms the plane image's ports over them (`services::arm`) when the host +// opens the door. The usage-tap fault latch is RETIRED (row 155): the fault crosses as data on +// the plane's answer (`abi::plane::OnPieceOut::tap_fault`), so no host serves its slot and no +// plane calls it. Process-wide, so `host` may be `HostCtx::NULL`. ── /// Fill `out_len` bytes at `out` from the host's entropy source (its CSPRNG). [`StatusClass::Ok`] /// when every byte was written; [`StatusClass::Refused`] when the host has no entropy to give or /// `out` is NULL with a non-zero length (the bytes are then unspecified); [`StatusClass::Fault`] on @@ -550,9 +551,9 @@ pub type EntropyFillFn = /// The host's wall clock in whole seconds since the Unix epoch; `0` when the host holds no clock /// (the fail-closed reading, never a real second). pub type WallClockFn = extern "C-unwind" fn(host: HostCtx) -> u64; -/// Count one usage-tap fault of `reason` for `protocol` (two borrowed UTF-8 ranges, live for the -/// call) and answer `true` only the FIRST time the host sees that pair, so the caller warns once. -/// `false` for a range that is not UTF-8 or a caught panic. +/// RETIRED (row 155): the slot's shape, kept so the table's layout holds. A usage-tap fault is data +/// on the plane's answer (`abi::plane::OnPieceOut::tap_fault`), counted by the kernel; no host +/// serves this slot and no plane calls it. pub type TapFaultLatchFn = extern "C-unwind" fn( host: HostCtx, protocol_ptr: *const u8, @@ -714,7 +715,7 @@ pub struct PlaneHostVtable { pub entropy_fill: Option, /// Read the host's wall clock (whole Unix seconds). pub wall_clock: Option, - /// Count a usage-tap fault and answer whether it is the first of its kind. + /// RETIRED (row 155): always `None`; the usage-tap fault is data on the plane's answer. pub tap_fault_latch: Option, /// Read the operator's live translation cap. pub translate_cap: Option, @@ -929,13 +930,12 @@ impl PlaneHostVtable { translate_cap: None, }; - /// [`EMPTY`](Self::EMPTY) plus the four HOST SERVICES, each served from THIS image's own armed + /// [`EMPTY`](Self::EMPTY) plus the three HOST SERVICES, each served from THIS image's own armed /// contract ports ([`super::services`]). A host grants them by building its table over this /// (`..PlaneHostVtable::SERVICES`): the services are the contract's, so the host adds no code. pub const SERVICES: PlaneHostVtable = PlaneHostVtable { entropy_fill: Some(super::services::entropy_fill), wall_clock: Some(super::services::wall_clock), - tap_fault_latch: Some(super::services::tap_fault_latch), translate_cap: Some(super::services::translate_cap), ..Self::EMPTY }; diff --git a/crates/busbar-contract/src/abi/hot/services.rs b/crates/busbar-contract/src/abi/hot/services.rs index 2a0eab4681..c1957aefc4 100644 --- a/crates/busbar-contract/src/abi/hot/services.rs +++ b/crates/busbar-contract/src/abi/hot/services.rs @@ -4,19 +4,20 @@ //! THE HOST SERVICES ACROSS THE HOT SEAM (minor 30; ARCHITECT SD-3 queue (6), DEC-SERVE G2). //! //! The contract ports a codec reaches — [`fill_entropy`](crate::codec::fill_entropy), -//! [`wall_clock_now`](crate::codec::wall_clock_now), -//! [`usage_tap_fault_should_warn`](crate::codec::usage_tap_fault_should_warn) and +//! [`wall_clock_now`](crate::codec::wall_clock_now) and //! [`max_translate_body_bytes`](crate::codec::max_translate_body_bytes) — are process-wide //! `OnceLock`s the host arms in ITS image. A plane LINKED into the host shares that image, so it //! reads the host's services. A plane DROPPED IN as a `cdylib` carries its own copy of this crate, //! whose ports nothing armed: it read the failure path of every one (a synthesized id with no -//! entropy, an unstamped time, a latch that never warns, the default cap instead of the operator's). +//! entropy, an unstamped time, the default cap instead of the operator's). A usage-tap fault is no +//! port: the plane returns it as data on its answer (`abi::plane::OnPieceOut::tap_fault`), and the +//! `tap_fault_latch` slot is served and armed by no one. //! //! Two halves, one file, because they are one contract: //! -//! * **HOST side** — `entropy_fill`, `wall_clock`, `tap_fault_latch`, `translate_cap`: the -//! four slots of [`PlaneHostVtable::SERVICES`], each answering from THIS image's own ports, which -//! the host armed. A host that builds its table over `SERVICES` adds no code of its own. +//! * **HOST side** — `entropy_fill`, `wall_clock`, `translate_cap`: the three service slots of +//! [`PlaneHostVtable::SERVICES`], each answering from THIS image's own ports, which the host +//! armed. A host that builds its table over `SERVICES` adds no code of its own. //! * **PLANE side** — [`arm`]: run in a dropped-in plane's image (the door's `busbar_plane_arm` //! symbol, which the host calls when it opens the door), it installs that image's ports over the //! host's slots, so a codec in the plane reads the host's services exactly as a linked codec does. @@ -26,7 +27,7 @@ use super::pod::StatusClass; use std::panic::catch_unwind; use std::sync::atomic::{AtomicPtr, AtomicU32, Ordering}; -// ── HOST SIDE: the four slots, served from this image's armed contract ports ──────────────────────── +// ── HOST SIDE: the three slots, served from this image's armed contract ports ─────────────────────── /// `entropy_fill` — fill `out_len` bytes at `out` through [`crate::codec::fill_entropy`]. pub(crate) extern "C-unwind" fn entropy_fill( @@ -59,78 +60,12 @@ pub(crate) extern "C-unwind" fn wall_clock(_host: HostCtx) -> u64 { catch_unwind(|| crate::codec::wall_clock_now().unwrap_or(0)).unwrap_or(0) } -/// `tap_fault_latch` — [`crate::codec::usage_tap_fault_should_warn`] over the two borrowed ranges. -/// The port takes the reason as `&'static str` (a codec names its reasons as constants), so a -/// reason crossing from a plane image is kept once per distinct spelling, up to [`REASON_CAP`]; -/// past the cap every new spelling counts as [`UNLISTED_REASON`], so a plane cannot grow the host's -/// memory or its label set without bound. -pub(crate) extern "C-unwind" fn tap_fault_latch( - _host: HostCtx, - protocol_ptr: *const u8, - protocol_len: usize, - reason_ptr: *const u8, - reason_len: usize, -) -> bool { - catch_unwind(|| { - // SAFETY (both): each range is live and initialized for the call (the slot's discipline). - let (Some(protocol), Some(reason)) = - (unsafe { utf8(protocol_ptr, protocol_len) }, unsafe { - utf8(reason_ptr, reason_len) - }) - else { - return false; - }; - crate::codec::usage_tap_fault_should_warn(protocol, kept(reason)) - }) - .unwrap_or(false) -} - /// `translate_cap` — [`crate::codec::max_translate_body_bytes`]. pub(crate) extern "C-unwind" fn translate_cap(_host: HostCtx) -> u64 { catch_unwind(|| crate::codec::max_translate_body_bytes() as u64) .unwrap_or(crate::codec::TRANSLATE_BODY_MAX_BYTES_DEFAULT as u64) } -/// How many distinct usage-tap reasons the host keeps for planes (a codec declares a handful). -pub const REASON_CAP: usize = 64; - -/// The reason every spelling past [`REASON_CAP`] counts as. -pub const UNLISTED_REASON: &str = "unlisted"; - -/// `reason`, kept for the life of the process (once per spelling; see [`tap_fault_latch`]), in a -/// fixed table of [`REASON_CAP`] process-lifetime slots — nothing is leaked, and the table cannot grow. -fn kept(reason: &str) -> &'static str { - static KEPT: [std::sync::OnceLock; REASON_CAP] = - [const { std::sync::OnceLock::new() }; REASON_CAP]; - static FILLING: std::sync::Mutex<()> = std::sync::Mutex::new(()); - let _filling = FILLING - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - for slot in &KEPT { - match slot.get() { - Some(kept) if kept == reason => return kept.as_str(), - Some(_) => {} - None => return slot.get_or_init(|| reason.to_owned()).as_str(), - } - } - UNLISTED_REASON -} - -/// A borrowed range as UTF-8; `None` for a NULL range with a length, or bytes that are not UTF-8. -/// -/// # Safety -/// A non-null `ptr` must address `len` live, initialized bytes for `'a`. -unsafe fn utf8<'a>(ptr: *const u8, len: usize) -> Option<&'a str> { - if len == 0 { - return Some(""); - } - if ptr.is_null() { - return None; - } - // SAFETY: the caller's obligation. - std::str::from_utf8(unsafe { core::slice::from_raw_parts(ptr, len) }).ok() -} - // ── PLANE SIDE: arm this image's ports over the host's slots ──────────────────────────────────────── /// The host table [`arm`] was handed, and the size [`PlaneHostVtable::check`] honoured for it. @@ -168,16 +103,13 @@ pub unsafe fn arm(host: *const PlaneHostVtable) -> StatusClass { } ARMED_SIZE.store(size, Ordering::Release); ARMED.store(host.cast_mut(), Ordering::Release); - // SAFETY (all four): `host`/`size` are what `check` just validated. + // SAFETY (all three): `host`/`size` are what `check` just validated. if crate::host_slot!(host, size, entropy_fill).is_some() { crate::codec::install_entropy_source(armed_entropy); } if crate::host_slot!(host, size, wall_clock).is_some() { crate::codec::install_wall_clock(armed_wall_clock); } - if crate::host_slot!(host, size, tap_fault_latch).is_some() { - crate::codec::install_usage_tap_fault_latch(armed_tap_fault_latch); - } if crate::host_slot!(host, size, translate_cap).is_some() { crate::codec::install_translate_cap_reader(armed_translate_cap); } @@ -210,22 +142,6 @@ fn armed_wall_clock() -> u64 { .map_or(0, |now| now(HostCtx::NULL)) } -/// The usage-tap latch port, forwarded through the host's `tap_fault_latch`. -fn armed_tap_fault_latch(protocol: &str, reason: &'static str) -> bool { - // SAFETY: as `armed_entropy`. - armed() - .and_then(|(host, size)| crate::host_slot!(host, size, tap_fault_latch)) - .is_some_and(|latch| { - latch( - HostCtx::NULL, - protocol.as_ptr(), - protocol.len(), - reason.as_ptr(), - reason.len(), - ) - }) -} - /// The translate-cap port, forwarded through the host's `translate_cap`. fn armed_translate_cap() -> usize { // SAFETY: as `armed_entropy`. diff --git a/crates/busbar-contract/src/abi/hot/tests/services_tests.rs b/crates/busbar-contract/src/abi/hot/tests/services_tests.rs index 358b532142..844181262b 100644 --- a/crates/busbar-contract/src/abi/hot/tests/services_tests.rs +++ b/crates/busbar-contract/src/abi/hot/tests/services_tests.rs @@ -14,15 +14,6 @@ extern "C-unwind" fn host_entropy(_host: HostCtx, out: *mut u8, len: usize) -> S extern "C-unwind" fn host_clock(_host: HostCtx) -> u64 { 1_700_000_000 } -extern "C-unwind" fn host_latch( - _host: HostCtx, - _protocol_ptr: *const u8, - protocol_len: usize, - _reason_ptr: *const u8, - reason_len: usize, -) -> bool { - protocol_len == 5 && reason_len == 6 -} extern "C-unwind" fn host_cap(_host: HostCtx) -> u64 { 4_096 } @@ -57,7 +48,6 @@ fn an_armed_image_reads_the_hosts_services_through_every_port() { static HOST: PlaneHostVtable = PlaneHostVtable { entropy_fill: Some(host_entropy), wall_clock: Some(host_clock), - tap_fault_latch: Some(host_latch), translate_cap: Some(host_cap), ..PlaneHostVtable::EMPTY }; @@ -66,11 +56,6 @@ fn an_armed_image_reads_the_hosts_services_through_every_port() { assert!(crate::codec::fill_entropy(&mut drawn)); assert_eq!(drawn, [0xA5; 4], "the draw is the host's"); assert_eq!(crate::codec::wall_clock_now(), Some(1_700_000_000)); - assert!(crate::codec::usage_tap_fault_should_warn("proto", "reason")); - assert!(!crate::codec::usage_tap_fault_should_warn( - "proto", - "other-reason" - )); assert_eq!(crate::codec::max_translate_body_bytes(), 4_096); // The HOST side of the same seam: `SERVICES` answers from this image's (now armed) ports. @@ -93,31 +78,6 @@ fn an_armed_image_reads_the_hosts_services_through_every_port() { (PlaneHostVtable::SERVICES.translate_cap.unwrap())(HostCtx::NULL), 4_096 ); - let latch = PlaneHostVtable::SERVICES.tap_fault_latch.unwrap(); - let (p, r) = ("proto", "reason"); - assert!(latch( - HostCtx::NULL, - p.as_ptr(), - p.len(), - r.as_ptr(), - r.len() - )); - let invalid = [0xFFu8]; - assert!( - !latch(HostCtx::NULL, p.as_ptr(), p.len(), invalid.as_ptr(), 1), - "not UTF-8" - ); - - // Past `REASON_CAP` distinct spellings, every new reason is kept as `UNLISTED_REASON`, so a - // plane cannot grow the host's memory or label set without bound. (Here, after every latch - // assertion above: the kept set is process-wide.) - for i in 0..REASON_CAP { - let _ = kept(&format!("reason-{i}")); - } - assert_eq!(kept("one-past-the-cap"), UNLISTED_REASON); - assert_eq!( - kept("reason-0"), - "reason-0", - "a kept spelling is still found" - ); + // The usage-tap latch slot is RETIRED (row 155): `SERVICES` serves it no longer. + assert!(PlaneHostVtable::SERVICES.tap_fault_latch.is_none()); } diff --git a/crates/busbar-contract/src/abi/plane/check.rs b/crates/busbar-contract/src/abi/plane/check.rs index a7f079fe1a..b08f5064d0 100644 --- a/crates/busbar-contract/src/abi/plane/check.rs +++ b/crates/busbar-contract/src/abi/plane/check.rs @@ -33,8 +33,9 @@ use super::{ MECHANISM_PEER_KEY, MECHANISM_ROOT, PIECE_OUT_TEXT, PIN_FINGERPRINT, PRINCIPAL_OPTIONAL, RECORD_AUDIT, RECORD_PUT, REFUSAL_ANY_DIALECT, ROUTE_COUNTED, ROUTE_DIRECT, ROUTE_LOCAL, ROUTE_ONCE, ROUTE_POOL, ROUTE_PUBLIC, ROUTE_SCOPE, ROUTE_SESSION, ROUTE_STREAM, - SHAPE_PIECEWISE, SHAPE_WHOLE, TAIL_FALLBACK, TAIL_HOOKS_GATED, TAIL_PROBES, - TRUST_ITEM_APPROVALS, TRUST_PIN, TRUST_PRIVATE_REACH, UNITS_ESTIMATED, VERDICT_HARD, + SHAPE_PIECEWISE, SHAPE_WHOLE, TAIL_FALLBACK, TAIL_HOOKS_GATED, TAIL_PROBES, TAP_FAULT_NONE, + TAP_FAULT_UNKNOWN_PROTOCOL, TRUST_ITEM_APPROVALS, TRUST_PIN, TRUST_PRIVATE_REACH, + UNITS_ESTIMATED, VERDICT_HARD, }; use crate::abi::hook::{ signal, MessageView, SignalEntry, REQUEST_HAS_MAX_TOKENS, REQUEST_HAS_TOOLS, REQUEST_STREAM, @@ -449,7 +450,8 @@ fn verdict(outcome: Outcome, v: u32) -> Result<(), Fault> { } /// `on_piece`'s breaker fault reading: a known `FAULT_*` (the transport kind's one vocabulary), none -/// on an answer that is not READY, and its padding zero. +/// on an answer that is not READY, and its padding zero. Its usage-tap fault likewise: a known +/// `TAP_FAULT_*`, none on an answer that is not READY. fn fault_reading(outcome: Outcome, out: &OnPieceOut) -> Result<(), Fault> { use crate::abi::transport::{FAULT_HARD, FAULT_NONE}; code( @@ -461,7 +463,16 @@ fn fault_reading(outcome: Outcome, out: &OnPieceOut) -> Result<(), Fault> { if out.fault != FAULT_NONE && outcome != Outcome::Ready { return Err(fault(Rule::Contradiction, "on_piece.fault_not_ready")); } - if out._fault_reserved != [0; 3] { + code( + u64::from(out.tap_fault), + 0, + u64::from(TAP_FAULT_UNKNOWN_PROTOCOL), + "on_piece.tap_fault", + )?; + if out.tap_fault != TAP_FAULT_NONE && outcome != Outcome::Ready { + return Err(fault(Rule::Contradiction, "on_piece.tap_fault_not_ready")); + } + if out._fault_reserved != [0; 2] { return Err(fault(Rule::Contradiction, "on_piece.fault_reserved")); } Ok(()) diff --git a/crates/busbar-contract/src/abi/plane/mod.rs b/crates/busbar-contract/src/abi/plane/mod.rs index 4dd83bfe6e..d3e5fd6a5e 100644 --- a/crates/busbar-contract/src/abi/plane/mod.rs +++ b/crates/busbar-contract/src/abi/plane/mod.rs @@ -373,6 +373,16 @@ pub const UNITS_REPORTED: u32 = 1; /// [`UNITS_REPORTED`]; the plane decides when it applies, the kernel adds no floor of its own. pub const UNITS_FLOOR: u32 = 2; +/// [`OnPieceOut::tap_fault`]: no usage-tap fault. +pub const TAP_FAULT_NONE: u8 = 0; +/// [`OnPieceOut::tap_fault`]: the cell's own reader refused the body (`reason="decode"`). +pub const TAP_FAULT_DECODE: u8 = 1; +/// [`OnPieceOut::tap_fault`]: the body is not JSON (`reason="bad_json"`). +pub const TAP_FAULT_BAD_JSON: u8 = 2; +/// [`OnPieceOut::tap_fault`]: the caller's dialect is not one the tap reads +/// (`reason="unknown_protocol"`). +pub const TAP_FAULT_UNKNOWN_PROTOCOL: u8 = 3; + /// THE ONE BILLING RULE ON A COUNT'S SOURCE: [`UNITS_REPORTED`] and [`UNITS_FLOOR`] bill; /// [`UNITS_ESTIMATED`] (and any other value) never does. Every kernel site that ledgers, meters or /// reads a fee unit from a plane's counts asks this, never a source compare of its own. @@ -1784,8 +1794,15 @@ pub struct OnPieceOut { /// may still be the destination's. `FAULT_NONE` = no reading. Laid in the padding after /// `need` (pre-tag v1). pub fault: u8, + /// THE UNIT'S USAGE-TAP FAULT (`TAP_FAULT_*`), beside the units this answer reports: why the + /// plane's usage tap could not read a same-protocol 2xx body, so the request billed 0 tokens + /// ([`crate::codec::UsageTapFault`]). The kernel counts it, once per answer that carries it, on + /// `busbar_billing_tap_decode_fail_total{protocol,reason}` under the unit's dialect, and warns + /// once per `(protocol, reason)`: the fault crosses as data, never as a host call. + /// [`TAP_FAULT_NONE`] = none. Laid in the padding after `fault` (pre-tag v1). + pub tap_fault: u8, /// Alignment padding. - pub _fault_reserved: [u8; 3], + pub _fault_reserved: [u8; 2], /// THE UNIT'S LEDGER LANE, in the arena; a zero length = none named. The billing identity the /// unit's units are priced, ledgered and metered under, which is not the route entry the walk /// picked (a call of one tool on a pooled server is the tool's lane, not the server's). The diff --git a/crates/busbar-contract/src/abi/tests/plane_check_tests.rs b/crates/busbar-contract/src/abi/tests/plane_check_tests.rs index 9cdf013569..6cf70b02b0 100644 --- a/crates/busbar-contract/src/abi/tests/plane_check_tests.rs +++ b/crates/busbar-contract/src/abi/tests/plane_check_tests.rs @@ -1673,13 +1673,43 @@ fn a_fault_reading_is_the_transports_vocabulary_on_a_ready_answer() { f(Rule::Contradiction, "on_piece.fault_not_ready") ); o.fault = FAULT_NONE; - o._fault_reserved = [0, 1, 0]; + o._fault_reserved = [0, 1]; assert_eq!( piece(&o, &[], &[], &[]), f(Rule::Contradiction, "on_piece.fault_reserved") ); } +/// THE USAGE-TAP FAULT rides the answer as data (row 155): a known `TAP_FAULT_*` on a READY answer, +/// beside the breaker reading and separate from it. RED arms: past `TAP_FAULT_UNKNOWN_PROTOCOL`, and +/// on an answer that is not READY. +#[test] +fn a_usage_tap_fault_is_a_known_code_on_a_ready_answer() { + use crate::abi::plane::{ + TAP_FAULT_BAD_JSON, TAP_FAULT_DECODE, TAP_FAULT_NONE, TAP_FAULT_UNKNOWN_PROTOCOL, + }; + let mut o: OnPieceOut = z(); + for code in [ + TAP_FAULT_NONE, + TAP_FAULT_DECODE, + TAP_FAULT_BAD_JSON, + TAP_FAULT_UNKNOWN_PROTOCOL, + ] { + o.tap_fault = code; + assert_eq!(piece(&o, &[], &[], &[]), Ok(()), "{code}"); + } + o.tap_fault = TAP_FAULT_UNKNOWN_PROTOCOL + 1; + assert_eq!( + piece(&o, &[], &[], &[]), + f(Rule::UnknownCode, "on_piece.tap_fault") + ); + o.tap_fault = TAP_FAULT_DECODE; + assert_eq!( + check_on_piece(Pending, &o, (&[], &[], &[]), &caps(), &bounds()), + f(Rule::Contradiction, "on_piece.tap_fault_not_ready") + ); +} + #[test] fn a_verdict_rides_only_a_ready_answer() { let mut o: OnPieceOut = z(); diff --git a/crates/busbar-contract/src/codec.rs b/crates/busbar-contract/src/codec.rs index ebdc32cdc5..bae0af7dd1 100644 --- a/crates/busbar-contract/src/codec.rs +++ b/crates/busbar-contract/src/codec.rs @@ -123,53 +123,68 @@ pub enum CodecError { Malformed(String), } -/// The host's USAGE-TAP FAULT REPORTER: told when a cell's own reader refused a same-protocol 2xx body -/// in [`OperationHandler::extract_usage`]'s default, so the request billed 0 tokens. The host counts -/// it (a per-request volume signal) and warns once per `(protocol, reason)`; the contract carries -/// only the shape of the call, because counting and logging are the host's, not the cell's. -pub type UsageTapFaultReporter = fn(ingress_protocol: &str, error: &CodecError); - -static USAGE_TAP_FAULT_REPORTER: std::sync::OnceLock = - std::sync::OnceLock::new(); - -/// Install the host's [`UsageTapFaultReporter`]. The first install wins and later ones are ignored: -/// one process has one host. The host installs it where it installs the protocols whose cells report -/// through it, so no cell is reachable before it is armed. -pub fn install_usage_tap_fault_reporter(reporter: UsageTapFaultReporter) { - let _ = USAGE_TAP_FAULT_REPORTER.set(reporter); +/// A USAGE-TAP FAULT: why a cell's usage tap could not read a same-protocol 2xx body, so the request +/// billed 0 tokens. It is DATA the tap returns ([`OperationHandler::extract_usage`]'s `Err`): the +/// plane carries it out on its door's answer beside the units it reports +/// ([`crate::abi::plane::OnPieceOut::tap_fault`]), and the kernel counts it (one increment of +/// `busbar_billing_tap_decode_fail_total{protocol,reason}`) and warns once per `(protocol, reason)`. +/// The contract carries only the shape: no port, no latch and no static, so a codec reaches no host +/// to report it and holds nothing that counts it. +/// +/// ```compile_fail +/// // There is no host port to report through: the fault is returned, never reported. +/// busbar_contract::codec::report_usage_tap_decode_failure( +/// "proto", +/// &busbar_contract::codec::CodecError::Malformed(String::new()), +/// ); +/// ``` +/// +/// ```compile_fail +/// // And no latch a codec asks whether to warn: the kernel's count is the only one. +/// let _ = busbar_contract::codec::usage_tap_fault_should_warn("proto", "decode"); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UsageTapFault { + /// The cell's own reader refused the body (`decode`). + Decode, + /// The body is not JSON (`bad_json`). + BadJson, + /// The caller's dialect is not one the tap reads (`unknown_protocol`). + UnknownProtocol, } -/// Report a usage-tap decode failure to the installed host reporter; a no-op in a process whose host -/// installed none (a bare codec test). -pub fn report_usage_tap_decode_failure(ingress_protocol: &str, error: &CodecError) { - if let Some(report) = USAGE_TAP_FAULT_REPORTER.get() { - report(ingress_protocol, error); +impl UsageTapFault { + /// The `reason` label the kernel counts the fault under. + #[must_use] + pub const fn reason(self) -> &'static str { + match self { + UsageTapFault::Decode => "decode", + UsageTapFault::BadJson => "bad_json", + UsageTapFault::UnknownProtocol => "unknown_protocol", + } } -} -/// The host's USAGE-TAP FAULT LATCH: counts one usage-tap fault of `reason` for `ingress_protocol` -/// (a per-request volume signal) and answers `true` only the FIRST time the host sees that -/// `(protocol, reason)`, so a codec that raises its own coded diagnostic for the fault warns once and -/// logs at debug after. [`UsageTapFaultReporter`] is this latch plus the one decode-failure line a -/// cell's default tap prints; a codec whose tap reads a body in more than one way reports each way's -/// reason through the latch and prints its own line. Counting is the host's, not the codec's. -pub type UsageTapFaultLatch = fn(ingress_protocol: &str, reason: &'static str) -> bool; - -static USAGE_TAP_FAULT_LATCH: std::sync::OnceLock = std::sync::OnceLock::new(); - -/// Install the host's [`UsageTapFaultLatch`]. First install wins, exactly as -/// [`install_usage_tap_fault_reporter`], and the host installs both in the same place. -pub fn install_usage_tap_fault_latch(latch: UsageTapFaultLatch) { - let _ = USAGE_TAP_FAULT_LATCH.set(latch); -} + /// The fault's code on the door's answer ([`crate::abi::plane::TAP_FAULT_DECODE`] and on). + #[must_use] + pub const fn code(self) -> u8 { + match self { + UsageTapFault::Decode => crate::abi::plane::TAP_FAULT_DECODE, + UsageTapFault::BadJson => crate::abi::plane::TAP_FAULT_BAD_JSON, + UsageTapFault::UnknownProtocol => crate::abi::plane::TAP_FAULT_UNKNOWN_PROTOCOL, + } + } -/// Count one usage-tap fault through the installed host latch and answer whether this is the first -/// of its `(protocol, reason)`. In a process whose host installed none (a bare codec test) nothing is -/// counted and the answer is `false`, so the caller logs at debug. -pub fn usage_tap_fault_should_warn(ingress_protocol: &str, reason: &'static str) -> bool { - USAGE_TAP_FAULT_LATCH - .get() - .is_some_and(|latch| latch(ingress_protocol, reason)) + /// The fault a door's answer code names; `None` for [`crate::abi::plane::TAP_FAULT_NONE`] and + /// for a code outside the vocabulary. + #[must_use] + pub const fn of_code(code: u8) -> Option { + match code { + crate::abi::plane::TAP_FAULT_DECODE => Some(UsageTapFault::Decode), + crate::abi::plane::TAP_FAULT_BAD_JSON => Some(UsageTapFault::BadJson), + crate::abi::plane::TAP_FAULT_UNKNOWN_PROTOCOL => Some(UsageTapFault::UnknownProtocol), + _ => None, + } + } } /// The host's TRANSLATE-BODY CAP READER: the operator's per-response translation cap, in bytes, as @@ -329,16 +344,19 @@ pub trait OperationHandler: Send + Sync { /// end, only when [`Self::taps_usage`] is true). Default: run THIS operation's own reader over the /// body and project its token usage — so a token-metered non-chat op (embeddings) bills the same /// as the cross-protocol path. Chat overrides this to run the egress protocol's chat reader. - fn extract_usage(&self, ingress_protocol: &str, body: &[u8]) -> Option { + /// + /// `Err` is the [`UsageTapFault`] that kept the tap from reading the body: the request bills 0 + /// tokens, and the fault is returned as data for the kernel to count, never reported from here. + fn extract_usage( + &self, + _ingress_protocol: &str, + body: &[u8], + ) -> Result, UsageTapFault> { + // A same-protocol 2xx body the op's own codec cannot decode is a `decode` fault (like the + // cross-protocol seam), never a silent 0-token bill with no operator signal. match self.read_response(body) { - Ok(r) => r.token_usage(), - Err(e) => { - // A same-protocol 2xx body the op's own codec cannot decode: record it (like the - // cross-protocol seam) rather than silently bill 0 tokens with no operator signal. - // The host's reporter counts it and warns once per (protocol, reason). - report_usage_tap_decode_failure(ingress_protocol, &e); - None - } + Ok(r) => Ok(r.token_usage()), + Err(_) => Err(UsageTapFault::Decode), } } /// The Content-Type of THIS operation's egress request wire (what `write_request` emits). diff --git a/crates/busbar-contract/tests/golden/abi-layout.golden b/crates/busbar-contract/tests/golden/abi-layout.golden index d613b94692..cceeeb28ea 100644 --- a/crates/busbar-contract/tests/golden/abi-layout.golden +++ b/crates/busbar-contract/tests/golden/abi-layout.golden @@ -2099,7 +2099,8 @@ pkind::OnPieceOut.verb=160 pkind::OnPieceOut.target=168 pkind::OnPieceOut.need=176 pkind::OnPieceOut.fault=180 -pkind::OnPieceOut._fault_reserved=181 +pkind::OnPieceOut.tap_fault=181 +pkind::OnPieceOut._fault_reserved=182 pkind::OnPieceOut.lane=184 pkind::OnPieceOut.final_status=192 pkind::OnPieceOut._final_reserved=196 diff --git a/crates/busbar-contract/tests/layout_golden.rs b/crates/busbar-contract/tests/layout_golden.rs index 3a86dc8e1c..38b277a985 100644 --- a/crates/busbar-contract/tests/layout_golden.rs +++ b/crates/busbar-contract/tests/layout_golden.rs @@ -1850,6 +1850,7 @@ fn compute_layout() -> String { target, need, fault, + tap_fault, _fault_reserved, lane, final_status, diff --git a/crates/busbar-kernel/src/handlers/mod.rs b/crates/busbar-kernel/src/handlers/mod.rs index 384130c52b..26b6b29cff 100644 --- a/crates/busbar-kernel/src/handlers/mod.rs +++ b/crates/busbar-kernel/src/handlers/mod.rs @@ -50,9 +50,9 @@ // `busbar_mcp::PROTO_DECL` directly (dev-dependency). NOTE THE SCOPE: this was MCP the PROTOCOL; the // `mcp/` PLANE (`crate::mcp`) never travelled with the codec and is still core's. -// THE USAGE-TAP FAULT HOST SERVICES the protocol install arms for every codec cell (#83a HOST): -// the warn-once latch and the decode reporter, both counting on -// `busbar_kernel::snapshot::BILLING_TAP_DECODE_FAIL_TOTAL`. +// THE USAGE-TAP FAULT COUNT (row 155): a plane returns a usage-tap fault as data on its door's +// answer (`abi::plane::OnPieceOut::tap_fault`), and the kernel counts it here on +// `busbar_kernel::snapshot::BILLING_TAP_DECODE_FAIL_TOTAL`, warning once per `(protocol, reason)`. /// Process-lifetime warn-once latch for the usage-tap decode fault class, keyed `protocol:reason`. A /// live protocol/dialect the tap reader cannot decode fails on EVERY 2xx body of that shape, so an @@ -72,30 +72,25 @@ pub fn usage_tap_decode_fail_should_warn(protocol: &str, reason: &'static str) - seen.insert(format!("{protocol}:{reason}")) } -/// THE HOST'S USAGE-TAP FAULT REPORTER — what [`OperationHandler::extract_usage`](busbar_contract::codec::OperationHandler::extract_usage)'s default reports -/// through when a cell's own reader refuses a same-protocol 2xx body (the request bills 0 tokens). -/// Counted on [`BILLING_TAP_DECODE_FAIL_TOTAL`](crate::snapshot::BILLING_TAP_DECODE_FAIL_TOTAL) and warned once per `(protocol, reason)`, exactly as -/// the default did inline before the trait moved into the contract, which takes no logging or metrics -/// dependency. Installed by [`crate::proto::install_protocols`] and the test registration seams, so -/// it is armed before any cell is reachable. -pub fn report_usage_tap_decode_failure( - ingress_protocol: &str, - e: &busbar_contract::codec::CodecError, -) { - if usage_tap_decode_fail_should_warn(ingress_protocol, "decode") { +/// COUNT ONE USAGE-TAP FAULT a plane's answer carried for a unit read in `protocol`: one increment +/// of [`BILLING_TAP_DECODE_FAIL_TOTAL`](crate::snapshot::BILLING_TAP_DECODE_FAIL_TOTAL) under +/// `reason`, warned the first time the `(protocol, reason)` is seen and logged at debug after. The +/// request billed 0 tokens for its tap; the plane names the fault, the kernel counts it. +pub fn usage_tap_fault(protocol: &str, reason: &'static str) { + if usage_tap_decode_fail_should_warn(protocol, reason) { crate::diagnostics::diag_warn!( crate::diagnostics::USAGE_TAP_DECODE_FAILED, - protocol = ingress_protocol, - error = ?e, - "usage tap: read_response failed to decode a same-protocol 2xx body; \ + protocol = protocol, + reason = reason, + "usage tap: a same-protocol 2xx body's usage could not be read; \ billing 0 tokens for this request" ); } else { crate::diagnostics::diag_debug!( crate::diagnostics::USAGE_TAP_DECODE_FAILED, - protocol = ingress_protocol, - error = ?e, - "usage tap: read_response still failing to decode a same-protocol 2xx body; \ + protocol = protocol, + reason = reason, + "usage tap: a same-protocol 2xx body's usage still could not be read; \ billing 0 tokens for this request" ); } @@ -192,7 +187,8 @@ impl OpDispatch { &self, ingress_protocol: &str, body: &[u8], - ) -> Option { + ) -> Result, busbar_contract::codec::UsageTapFault> + { self.op_handler.extract_usage(ingress_protocol, body) } pub fn egress_accept(&self, egress_protocol: &str, wants_stream: bool) -> &'static str { diff --git a/crates/busbar-kernel/src/plane_driver/mod.rs b/crates/busbar-kernel/src/plane_driver/mod.rs index f6fa8907d1..9f162a26e1 100644 --- a/crates/busbar-kernel/src/plane_driver/mod.rs +++ b/crates/busbar-kernel/src/plane_driver/mod.rs @@ -581,6 +581,10 @@ pub trait DriverSteps: Units { /// `dialect` (the one `arrive` answered): the previous release's translation counter counts an /// attempt whose far end speaks another dialect. fn attempting(&self, _ctx: &UnitCtx, _dialect: u32, _provider: &str) {} + /// The plane's answer for the unit, read in its `dialect`, carried a USAGE-TAP FAULT of + /// `reason` (`abi::plane::OnPieceOut::tap_fault`): the steps count it, once per answer that + /// carries it (`handlers::usage_tap_fault`). + fn usage_tap_fault(&self, _ctx: &UnitCtx, _dialect: u32, _reason: &'static str) {} /// THE STEPS' OWN WORDS for a refusal they decided with `reason`, handed to the plane's /// `refusal` as [`busbar_contract::abi::plane::RefusalIn::text`] (as a limit names the bucket /// that blocked); `None` = the reason's word. A `ROUTE_SCOPE` unit refused because several diff --git a/crates/busbar-kernel/src/plane_driver/route.rs b/crates/busbar-kernel/src/plane_driver/route.rs index 142b639941..2c3be88592 100644 --- a/crates/busbar-kernel/src/plane_driver/route.rs +++ b/crates/busbar-kernel/src/plane_driver/route.rs @@ -510,6 +510,7 @@ struct Answer { final_status: u32, final_message: Span, final_details: Span, + tap_fault: u8, } impl Answer { @@ -534,8 +535,16 @@ impl Answer { final_status: o.final_status, final_message: o.final_message, final_details: o.final_details, + tap_fault: o.tap_fault, } } + + /// The usage-tap fault this answer carries, as the reason the kernel counts it under; `None` + /// for an answer that carries none. + fn usage_tap_fault(&self) -> Option<&'static str> { + busbar_contract::codec::UsageTapFault::of_code(self.tap_fault) + .map(busbar_contract::codec::UsageTapFault::reason) + } } /// Where a piece's emitted bytes go. @@ -855,12 +864,17 @@ async fn until(left: Option) { pub trait AttemptSteps { /// An attempt starts on a member of `provider`, the unit's request read in `dialect`. fn attempting(&self, _ctx: &UnitCtx, _dialect: u32, _provider: &str) {} + /// The plane's answer carried a usage-tap fault of `reason` for the unit read in `dialect`. + fn usage_tap_fault(&self, _ctx: &UnitCtx, _dialect: u32, _reason: &'static str) {} } impl AttemptSteps for S { fn attempting(&self, ctx: &UnitCtx, dialect: u32, provider: &str) { super::DriverSteps::attempting(self, ctx, dialect, provider); } + fn usage_tap_fault(&self, ctx: &UnitCtx, dialect: u32, reason: &'static str) { + super::DriverSteps::usage_tap_fault(self, ctx, dialect, reason); + } } impl AttemptSteps for () {} @@ -1097,6 +1111,12 @@ impl super::PlaneUnits<'_, S, F, C> { drop(st); self.driver.money.checkpoint(run.ctx, units) }; + // THE USAGE-TAP FAULT the plane returned beside its units (row 155): counted here, once + // per answer that carries it (the plane writes it on one window of the answer). + if let Some(reason) = out.usage_tap_fault() { + self.steps + .usage_tap_fault(run.ctx, run.bufs.dialect, reason); + } // THE UNIT'S LEDGER LANE, where the answer names one (SEAM-L(j)): the money steps // lane the unit by it from here. A lane that is not UTF-8 is a plane fault. let lane = run.bufs.arena(out.lane); diff --git a/crates/busbar-kernel/src/plane_driver/tests/route_tests.rs b/crates/busbar-kernel/src/plane_driver/tests/route_tests.rs index 2531cfc617..5b1eb5e87a 100644 --- a/crates/busbar-kernel/src/plane_driver/tests/route_tests.rs +++ b/crates/busbar-kernel/src/plane_driver/tests/route_tests.rs @@ -126,3 +126,25 @@ fn a_plane_is_lent_the_operators_name_of_its_member() { assert_eq!(super::operator_name(b"gpt-model"), b"gpt-model"); assert_eq!(super::operator_name(b""), b""); } + +/// THE USAGE-TAP FAULT IS READ OFF THE ANSWER (row 155): a plane's answer carrying a usage-tap +/// fault beside its units reads as the reason the kernel counts it under, and one carrying none +/// reads as nothing to count. The steps count each answer's fault once, and a plane writes it on +/// one window of its answer. +#[test] +fn an_answers_usage_tap_fault_reads_as_the_reason_the_kernel_counts() { + use busbar_contract::abi::plane::{ + OnPieceOut, TAP_FAULT_BAD_JSON, TAP_FAULT_DECODE, TAP_FAULT_NONE, + TAP_FAULT_UNKNOWN_PROTOCOL, + }; + let mut o: OnPieceOut = blank_out(); + for (code, reason) in [ + (TAP_FAULT_NONE, None), + (TAP_FAULT_DECODE, Some("decode")), + (TAP_FAULT_BAD_JSON, Some("bad_json")), + (TAP_FAULT_UNKNOWN_PROTOCOL, Some("unknown_protocol")), + ] { + o.tap_fault = code; + assert_eq!(Answer::of(&o).usage_tap_fault(), reason, "{code}"); + } +} diff --git a/crates/busbar-kernel/src/plane_host/tests/mod_tests.rs b/crates/busbar-kernel/src/plane_host/tests/mod_tests.rs index 7b24be6b9a..1b42d00fa7 100644 --- a/crates/busbar-kernel/src/plane_host/tests/mod_tests.rs +++ b/crates/busbar-kernel/src/plane_host/tests/mod_tests.rs @@ -44,8 +44,10 @@ fn builds_a_full_vtable_with_frozen_preamble() { // linked one does (ARCHITECT SD-3 queue (6)). assert!(vt.entropy_fill.is_some()); assert!(vt.wall_clock.is_some()); - assert!(vt.tap_fault_latch.is_some()); assert!(vt.translate_cap.is_some()); + // RETIRED (row 155): the usage-tap fault is data on the plane's answer, counted by the kernel; + // no host serves the latch slot. + assert!(vt.tap_fault_latch.is_none()); } #[test] diff --git a/crates/busbar-kernel/src/proto/installed.rs b/crates/busbar-kernel/src/proto/installed.rs index 2b8acaea45..590954f8f1 100644 --- a/crates/busbar-kernel/src/proto/installed.rs +++ b/crates/busbar-kernel/src/proto/installed.rs @@ -250,21 +250,13 @@ static REGISTRY: std::sync::OnceLock = std::sync::OnceLock::new(); /// [`registry`]'s initializer. static INSTALLED: std::sync::OnceLock> = std::sync::OnceLock::new(); -/// Arm the HOST SERVICES a protocol cell reaches through the contract: the usage-tap fault reporter -/// (`handlers::report_usage_tap_decode_failure`) a cell's default usage tap reports through, the -/// usage-tap fault latch (`handlers::usage_tap_decode_fail_should_warn`) a codec whose tap reads a -/// body more than one way counts each way through, the translate-body cap reader +/// Arm the HOST SERVICES a protocol cell reaches through the contract: the translate-body cap reader /// (`proxy::max_translate_body_bytes`, the live operator knob), the host entropy source (the OS /// CSPRNG) and the host wall clock (`store::now`). Called wherever protocols become reachable — /// [`install_protocols`] and the test registration seams — so no cell can be dispatched before they -/// are armed. Idempotent. +/// are armed. Idempotent. A usage-tap fault is no service: the plane returns it on its answer and +/// the kernel counts it (`handlers::usage_tap_fault`). fn arm_host_services() { - busbar_contract::codec::install_usage_tap_fault_reporter( - crate::handlers::report_usage_tap_decode_failure, - ); - busbar_contract::codec::install_usage_tap_fault_latch( - crate::handlers::usage_tap_decode_fail_should_warn, - ); busbar_contract::codec::install_translate_cap_reader(crate::proxy::max_translate_body_bytes); busbar_contract::codec::install_entropy_source(os_entropy); busbar_contract::codec::install_wall_clock(crate::store::now); diff --git a/crates/busbar-kernel/src/proto/tests/installed_tests.rs b/crates/busbar-kernel/src/proto/tests/installed_tests.rs index 9b41d257af..5520d5d257 100644 --- a/crates/busbar-kernel/src/proto/tests/installed_tests.rs +++ b/crates/busbar-kernel/src/proto/tests/installed_tests.rs @@ -44,13 +44,12 @@ fn a_prefix_slice_of_an_interned_name_resolves_its_own_declaration() { ); } -/// THE USAGE-TAP COUNT, HOST STEP (#83a SD-3, architect ruling R-USAGE): the host services the -/// protocol registration arms count every usage-tap fault a codec reports — through the latch (a -/// codec's own reason) or through the decode reporter (a cell's default tap) — as ONE increment of -/// `busbar_billing_tap_decode_fail_total{protocol,reason}`, and warn once per `(protocol, reason)`. -/// The LLM plane's suite pins which reports each tap path makes; this pins what one report counts. +/// THE USAGE-TAP COUNT, KERNEL STEP (#83a SD-3, architect ruling R-USAGE; row 155): every usage-tap +/// fault a plane's answer carries is counted by the kernel as ONE increment of +/// `busbar_billing_tap_decode_fail_total{protocol,reason}`, and warned once per `(protocol, reason)`. +/// The LLM plane's suite pins which fault each tap path returns; this pins what one fault counts. #[test] -fn an_armed_usage_tap_report_counts_one_increment_under_its_reason() { +fn a_usage_tap_fault_counts_one_increment_under_its_reason() { use metrics::{ Counter, CounterFn, Gauge, Histogram, Key, KeyName, Metadata, Recorder, SharedString, Unit, }; @@ -86,17 +85,12 @@ fn an_armed_usage_tap_report_counts_one_increment_under_its_reason() { Histogram::noop() } } - // What registration and `install_protocols` run to arm the host's services. - arm_host_services(); let counts = Counts::default(); let proto = "sd3-armed-tap-protocol"; let (first, second) = metrics::with_local_recorder(&Local(Arc::clone(&counts)), || { - let first = busbar_contract::codec::usage_tap_fault_should_warn(proto, "bad_json"); - let second = busbar_contract::codec::usage_tap_fault_should_warn(proto, "bad_json"); - busbar_contract::codec::report_usage_tap_decode_failure( - proto, - &busbar_contract::codec::CodecError::Malformed("x".into()), - ); + let first = crate::handlers::usage_tap_decode_fail_should_warn(proto, "bad_json"); + let second = crate::handlers::usage_tap_decode_fail_should_warn(proto, "bad_json"); + crate::handlers::usage_tap_fault(proto, "decode"); (first, second) }); assert!( diff --git a/crates/busbar-plane-llm/src/codec/chat_handle.rs b/crates/busbar-plane-llm/src/codec/chat_handle.rs index 036c206f0a..e3f7490dc8 100644 --- a/crates/busbar-plane-llm/src/codec/chat_handle.rs +++ b/crates/busbar-plane-llm/src/codec/chat_handle.rs @@ -584,68 +584,43 @@ impl OperationHandler for ChatOperation { &self, ingress_protocol: &str, body: &[u8], - ) -> Option { + ) -> Result, busbar_contract::codec::UsageTapFault> + { // Same-protocol usage tap (verbatim from the former `ChatOperation`; `crate::codec::` re-pathed). + // Each way the tap cannot read the body is RETURNED as its fault: the door carries it out on + // its answer and the kernel counts it and warns once per (protocol, reason) (row 155). The + // codec's own line is the per-request detail, at debug. + use busbar_contract::codec::UsageTapFault; let Some(p) = super::proto_codec::protocol_for(ingress_protocol) else { - if busbar_contract::codec::usage_tap_fault_should_warn( - ingress_protocol, - "unknown_protocol", - ) { - ::tracing::warn!(diag = %crate::codec::diagnostics::USAGE_TAP_UNKNOWN_PROTOCOL.banner(), - protocol = ingress_protocol, - "usage tap: unknown ingress protocol for a same-protocol 2xx body; \ - billing 0 tokens for this request" - ); - } else { - ::tracing::debug!(diag = %crate::codec::diagnostics::USAGE_TAP_UNKNOWN_PROTOCOL.banner(), - protocol = ingress_protocol, - "usage tap: unknown ingress protocol for a same-protocol 2xx body; \ - billing 0 tokens for this request" - ); - } - return None; + ::tracing::debug!(diag = %crate::codec::diagnostics::USAGE_TAP_UNKNOWN_PROTOCOL.banner(), + protocol = ingress_protocol, + "usage tap: unknown ingress protocol for a same-protocol 2xx body; \ + billing 0 tokens for this request" + ); + return Err(UsageTapFault::UnknownProtocol); }; let v = match crate::codec::json::parse::(body) { Ok(v) => v, Err(_e) => { - if busbar_contract::codec::usage_tap_fault_should_warn(ingress_protocol, "bad_json") - { - ::tracing::warn!(diag = %crate::codec::diagnostics::USAGE_TAP_BAD_JSON.banner(), - protocol = ingress_protocol, - error = %crate::codec::json::parse_err_log(body.len()), - "usage tap: failed to parse a same-protocol 2xx body as JSON; \ - billing 0 tokens for this request" - ); - } else { - ::tracing::debug!(diag = %crate::codec::diagnostics::USAGE_TAP_BAD_JSON.banner(), - protocol = ingress_protocol, - error = %crate::codec::json::parse_err_log(body.len()), - "usage tap: failed to parse a same-protocol 2xx body as JSON; \ - billing 0 tokens for this request" - ); - } - return None; + ::tracing::debug!(diag = %crate::codec::diagnostics::USAGE_TAP_BAD_JSON.banner(), + protocol = ingress_protocol, + error = %crate::codec::json::parse_err_log(body.len()), + "usage tap: failed to parse a same-protocol 2xx body as JSON; \ + billing 0 tokens for this request" + ); + return Err(UsageTapFault::BadJson); } }; match p.reader().read_response(&v) { - Ok(ir) => Some(ir.usage.to_token_usage()), + Ok(ir) => Ok(Some(ir.usage.to_token_usage())), Err(e) => { - if busbar_contract::codec::usage_tap_fault_should_warn(ingress_protocol, "decode") { - ::tracing::warn!(diag = %busbar_contract::diagnostic::USAGE_TAP_DECODE_FAILED.banner(), - protocol = ingress_protocol, - error = ?e, - "usage tap: read_response failed to decode a same-protocol 2xx body; \ - billing 0 tokens for this request" - ); - } else { - ::tracing::debug!(diag = %busbar_contract::diagnostic::USAGE_TAP_DECODE_FAILED.banner(), - protocol = ingress_protocol, - error = ?e, - "usage tap: read_response still failing to decode a same-protocol 2xx body; \ - billing 0 tokens for this request" - ); - } - None + ::tracing::debug!(diag = %busbar_contract::diagnostic::USAGE_TAP_DECODE_FAILED.banner(), + protocol = ingress_protocol, + error = ?e, + "usage tap: read_response failed to decode a same-protocol 2xx body; \ + billing 0 tokens for this request" + ); + Err(UsageTapFault::Decode) } } } diff --git a/crates/busbar-plane-llm/src/codec/openai_chat/tests/handler_tests.rs b/crates/busbar-plane-llm/src/codec/openai_chat/tests/handler_tests.rs index b54fa63e4e..f03d68b277 100644 --- a/crates/busbar-plane-llm/src/codec/openai_chat/tests/handler_tests.rs +++ b/crates/busbar-plane-llm/src/codec/openai_chat/tests/handler_tests.rs @@ -403,6 +403,7 @@ fn embeddings_taps_usage_and_extract_usage_reads_prompt_tokens() { let body = br#"{"object":"list","data":[{"object":"embedding","index":0,"embedding":[0.1,0.2]}],"usage":{"prompt_tokens":7,"total_tokens":7}}"#; let usage = OpenAiEmbeddings .extract_usage("openai", body) + .expect("the tap reads the body") .expect("token-metered embeddings yields usage"); assert_eq!(usage.input, 7); } @@ -643,6 +644,7 @@ fn image_response_usage_is_recorded_never_billed() { "openai", &serde_json::to_vec(&wire).unwrap(), ) + .expect("the tap reads the body") .expect("the tap reads the answer"); assert_eq!((tapped.input, tapped.output), (0, 0)); } diff --git a/crates/busbar-plane-llm/src/codec/tests/test_host.rs b/crates/busbar-plane-llm/src/codec/tests/test_host.rs index cde2df1d75..177aed23d1 100644 --- a/crates/busbar-plane-llm/src/codec/tests/test_host.rs +++ b/crates/busbar-plane-llm/src/codec/tests/test_host.rs @@ -10,36 +10,14 @@ //! - WALL CLOCK: the system clock in whole seconds, as the host reads it. //! - TRANSLATE CAP: none armed — the contract's documented 32 MiB default applies, which is the //! host's own default for an unconfigured limit. -//! - USAGE-TAP COUNT: a per-thread record of every `(protocol, reason)` fault the codec reports, -//! first-seen per thread, which [`tap_faults`] reads back. What the host does with a report (the -//! `busbar_billing_tap_decode_fail_total` series and the warn-once set) is pinned in the host's suite. +//! - USAGE-TAP FAULTS: none to arm. A tap returns its fault as data (row 155); what the kernel does +//! with it (the `busbar_billing_tap_decode_fail_total` series and the warn-once set) is pinned in +//! the kernel's suite. //! - CLASSIFICATION: [`classified`] reads the class the host's classifier places a raw upstream error //! in off the shared fixture `testing/plane-copies/upstream-classes.json`, which the host's suite //! holds its classifier to. -use busbar_contract::codec::CodecError; use busbar_contract::upstream::{CanonicalSignal, RawUpstreamError, StatusClass}; -use std::cell::RefCell; -use std::collections::BTreeMap; - -thread_local! { - /// Every usage-tap fault this thread reported, `(protocol, reason) -> count`. - static TAP_FAULTS: RefCell> = - const { RefCell::new(BTreeMap::new()) }; -} - -fn record_tap_fault(protocol: &str, reason: &'static str) -> bool { - TAP_FAULTS.with(|t| { - let mut t = t.borrow_mut(); - let n = t.entry((protocol.to_string(), reason)).or_insert(0); - *n += 1; - *n == 1 - }) -} - -fn report_decode_failure(protocol: &str, _error: &CodecError) { - record_tap_fault(protocol, "decode"); -} fn os_entropy(out: &mut [u8]) -> bool { getrandom::fill(out).is_ok() @@ -58,19 +36,9 @@ pub fn ensure_test_protocols_registered() { ARM.call_once(|| { busbar_contract::codec::install_entropy_source(os_entropy); busbar_contract::codec::install_wall_clock(system_clock); - busbar_contract::codec::install_usage_tap_fault_latch(record_tap_fault); - busbar_contract::codec::install_usage_tap_fault_reporter(report_decode_failure); }); } -/// The usage-tap faults THIS thread reported while running `f`, `(protocol, reason) -> count`. -pub fn tap_faults(f: impl FnOnce()) -> BTreeMap<(String, &'static str), u64> { - ensure_test_protocols_registered(); - TAP_FAULTS.with(|t| t.borrow_mut().clear()); - f(); - TAP_FAULTS.with(|t| t.borrow().clone()) -} - /// The class the host's classifier places `raw` in with an EMPTY operator `error_map`, read off the /// shared fixture. A raw shape the fixture does not list fails the test that produced it: a dialect /// whose error reader starts producing a new shape must add it there, where the host's suite checks diff --git a/crates/busbar-plane-llm/src/codec/tests/usage_tap_count_tests.rs b/crates/busbar-plane-llm/src/codec/tests/usage_tap_count_tests.rs index 0a8f7211ff..553d2fb2aa 100644 --- a/crates/busbar-plane-llm/src/codec/tests/usage_tap_count_tests.rs +++ b/crates/busbar-plane-llm/src/codec/tests/usage_tap_count_tests.rs @@ -1,24 +1,17 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright (C) 2026 Busbar Inc and contributors -//! THE USAGE-TAP COUNT PIN (#83a SD-3, architect ruling R-USAGE): every way a same-protocol usage -//! tap can fail to read a 2xx body reports ONCE to the host's usage-tap seam, under the reason that -//! says which way — whether the tap is the chat cell's own (unknown protocol / bad JSON / a body its -//! reader refuses) or a leaf cell's default (the host's decode reporter). One report -//! is one increment of `busbar_billing_tap_decode_fail_total{protocol,reason}`: the host's suite pins -//! that step. The plane names no metrics crate and reaches no host: this suite reads the reports the -//! test host recorded. +//! THE USAGE-TAP FAULT PIN (#83a SD-3, architect ruling R-USAGE; row 155): every way a same-protocol +//! usage tap can fail to read a 2xx body is RETURNED as its fault, under the reason that says which +//! way, whether the tap is the chat cell's own (unknown protocol / bad JSON / a body its reader +//! refuses) or a leaf cell's default (a decode failure). The door carries the fault out on its +//! answer and the kernel counts it, one increment of +//! `busbar_billing_tap_decode_fail_total{protocol,reason}`: the kernel's suite pins that step. The +//! codec names no metrics crate, reaches no host and holds no latch: this suite reads what the tap +//! returns. -use busbar_contract::codec::OperationHandler; +use busbar_contract::codec::{OperationHandler, UsageTapFault}; use busbar_contract::operation::OpVerb; -use std::collections::BTreeMap; - -fn faults(pairs: &[(&str, &'static str, u64)]) -> BTreeMap<(String, &'static str), u64> { - pairs - .iter() - .map(|&(p, r, n)| ((p.to_string(), r), n)) - .collect() -} fn cell(op: OpVerb) -> &'static dyn OperationHandler { crate::codec::bedrock::DECL @@ -32,34 +25,44 @@ const UNREADABLE: &[u8] = b"[1,2,3]"; const NOT_JSON: &[u8] = b"{not json"; #[test] -fn the_chat_cells_three_fault_paths_each_count_once_under_their_reason() { +fn the_chat_cells_three_fault_paths_each_return_their_reason() { let chat = crate::codec::chat_handle::ChatOperation("bedrock"); let unknown = crate::codec::chat_handle::ChatOperation("no-such-dialect"); - let counts = crate::codec::test_host::tap_faults(|| { - assert!(chat.extract_usage("bedrock", UNREADABLE).is_none()); - assert!(chat.extract_usage("bedrock", NOT_JSON).is_none()); - assert!(chat.extract_usage("bedrock", NOT_JSON).is_none()); - assert!(unknown.extract_usage("no-such-dialect", b"{}").is_none()); - }); + let faults = [ + chat.extract_usage("bedrock", UNREADABLE), + chat.extract_usage("bedrock", NOT_JSON), + unknown.extract_usage("no-such-dialect", b"{}"), + ]; assert_eq!( - counts, - faults(&[ - ("bedrock", "decode", 1), - ("bedrock", "bad_json", 2), - ("no-such-dialect", "unknown_protocol", 1), - ]) + faults, + [ + Err(UsageTapFault::Decode), + Err(UsageTapFault::BadJson), + Err(UsageTapFault::UnknownProtocol), + ] ); + let reasons: Vec<&str> = faults + .iter() + .filter_map(|f| f.as_ref().err().map(|f| f.reason())) + .collect(); + assert_eq!(reasons, ["decode", "bad_json", "unknown_protocol"]); } #[test] -fn a_leaf_cells_default_tap_counts_a_refused_body_as_a_decode_failure() { - let counts = crate::codec::test_host::tap_faults(|| { - assert!(cell(OpVerb::EMBEDDINGS) - .extract_usage("bedrock", NOT_JSON) - .is_none()); - assert!(cell(OpVerb::IMAGE) - .extract_usage("bedrock", NOT_JSON) - .is_none()); - }); - assert_eq!(counts, faults(&[("bedrock", "decode", 2)])); +fn a_leaf_cells_default_tap_returns_a_refused_body_as_a_decode_fault() { + for op in [OpVerb::EMBEDDINGS, OpVerb::IMAGE] { + assert_eq!( + cell(op).extract_usage("bedrock", NOT_JSON), + Err(UsageTapFault::Decode), + "{op:?}" + ); + } +} + +/// A body the tap reads is no fault, whatever usage it states. +#[test] +fn a_readable_body_is_no_fault() { + let chat = crate::codec::chat_handle::ChatOperation("bedrock"); + let body = br#"{"output":{"message":{"role":"assistant","content":[{"text":"hi"}]}},"stopReason":"end_turn","usage":{"inputTokens":3,"outputTokens":2,"totalTokens":5}}"#; + assert!(chat.extract_usage("bedrock", body).is_ok()); } diff --git a/crates/busbar-plane-llm/src/exchange/reply/mod.rs b/crates/busbar-plane-llm/src/exchange/reply/mod.rs index 931c23177e..ff6b5a54e8 100644 --- a/crates/busbar-plane-llm/src/exchange/reply/mod.rs +++ b/crates/busbar-plane-llm/src/exchange/reply/mod.rs @@ -21,7 +21,7 @@ use std::borrow::Cow; use std::collections::BTreeMap; use busbar_contract::billing::TokenUsage; -use busbar_contract::codec::OperationHandler; +use busbar_contract::codec::{OperationHandler, UsageTapFault}; use busbar_contract::protocol::HeadFields; use busbar_contract::upstream::{Disposition, StatusClass}; @@ -160,6 +160,9 @@ pub struct Piece<'a> { /// audit row per path (` from `), 1.5.5's row. Empty on a relay of the caller's /// own dialect and on every end that delivers nothing. pub dropped: Vec, + /// Why the usage tap could not read a same-dialect whole body, on the end that read it: the + /// door carries it out beside its units, and the kernel counts it (row 155). + pub tap_fault: Option, } enum State { @@ -239,6 +242,7 @@ fn answered<'a>(r: Rendered, units: Units, verdict: Verdict, fault: Option, + /// Why the usage tap could not read a same-dialect whole body (only when metering): returned + /// as data for the kernel to count, never reported from here (row 155). + pub tap_fault: Option, } /// How a relayed answer was cut: by the far end's transport, or by the stream's ceiling. @@ -361,6 +364,7 @@ impl Relay { }) == Some(crate::codec::ir::IrStopReason::Error); let mut open_billing: Option = None; + let mut tap_fault = None; let usage = if !self.meter { None } else if let Some(t) = self.translate.as_ref() { @@ -378,7 +382,9 @@ impl Relay { if self.nonstream_buf_truncated { Some(wire::unrecovered_usage(self.ingress, &buf)) } else { - self.handler.extract_usage(self.ingress, &buf).or_else(|| { + let tapped = self.handler.extract_usage(self.ingress, &buf); + tap_fault = tapped.as_ref().err().copied(); + tapped.ok().flatten().or_else(|| { match self.handler.read_response(&buf) { Ok(read) => { open_billing = read.billing(); @@ -418,6 +424,7 @@ impl Relay { .as_ref() .map(|t| t.dropped()) .unwrap_or_default(), + tap_fault, } } diff --git a/crates/busbar-plane-llm/src/plane_door.rs b/crates/busbar-plane-llm/src/plane_door.rs index 3692271252..8936bccaa0 100644 --- a/crates/busbar-plane-llm/src/plane_door.rs +++ b/crates/busbar-plane-llm/src/plane_door.rs @@ -65,8 +65,8 @@ use busbar_contract::abi::plane::{ CANCEL_ABORTED, CANCEL_OK_PARTIAL, CLAIM_EXACT, CLAIM_PROBE, EMIT_DONE, EMIT_TO_FAR_END, FROM_CALLER, FROM_FAR_END, FROM_KERNEL, INGRESS_REQUEST_RESPONSE, INGRESS_RESPONSE_STREAM, PIECE_HAS_STATUS, PIECE_LAST, PRINCIPAL_NONE, PRINCIPAL_REQUIRED, REFUSAL_GATE, ROUTE_DIRECT, - ROUTE_POOL, SHAPE_PIECEWISE, SPAN_ABSENT, TAIL_FALLBACK, TAIL_PROBES, UNITS_FLOOR, - UNITS_REPORTED, VERDICT_HARD, VERDICT_NONE, VERDICT_OK, VERDICT_RETRY, + ROUTE_POOL, SHAPE_PIECEWISE, SPAN_ABSENT, TAIL_FALLBACK, TAIL_PROBES, TAP_FAULT_NONE, + UNITS_FLOOR, UNITS_REPORTED, VERDICT_HARD, VERDICT_NONE, VERDICT_OK, VERDICT_RETRY, }; use busbar_contract::abi::plane::{PlaneCancelIn, PlaneCancelOut}; use busbar_contract::abi::plane::{RecordWrite, AUDIT_DEGRADED, RECORD_AUDIT}; @@ -442,6 +442,9 @@ struct Answer { verdict: u32, /// `FAULT_*`: the breaker's reading of this answer, written once, on its first window. fault: u8, + /// `TAP_FAULT_*`: why the usage tap could not read the body, beside the units; written once, + /// on the first window, for the kernel to count (row 155). + tap_fault: u8, /// The caller's reply is complete. done: bool, /// The unit's audit rows, `(action, resource)`, each written as a degraded `RECORD_AUDIT`: @@ -795,6 +798,9 @@ fn to_caller(piece: Piece<'_>) -> Answer { units: counts(&piece.units), verdict: verdict(piece.verdict), fault, + tap_fault: piece + .tap_fault + .map_or(TAP_FAULT_NONE, busbar_contract::codec::UsageTapFault::code), done: piece.done, ..Answer::default() } @@ -1050,6 +1056,7 @@ fn deliver( out.set(|o| &o.verdict, p.answer.verdict); // Once per answer: a reading recorded on every window of a long answer would count it again. out.set(|o| &o.fault, std::mem::take(&mut p.answer.fault)); + out.set(|o| &o.tap_fault, std::mem::take(&mut p.answer.tap_fault)); out.set(|o| &o.flags, far | if done { EMIT_DONE } else { 0 }); if !more { unit.pending = None; diff --git a/crates/busbar-plane-llm/tests/conformance.rs b/crates/busbar-plane-llm/tests/conformance.rs index 013f41521b..ca133fe7ce 100644 --- a/crates/busbar-plane-llm/tests/conformance.rs +++ b/crates/busbar-plane-llm/tests/conformance.rs @@ -23,7 +23,7 @@ use busbar_contract::abi::plane::{ self, slot, ArriveIn, ArriveOut, OnPieceIn, OnPieceOut, OutField, PlaneOpenIn, PlaneOpenOut, PlaneRefreshOut, ProjectIn, ProjectOut, RefusalIn, RefusalOut, ServeIn, ServeOut, UnitCount, EMIT_DONE, EMIT_TO_FAR_END, FROM_CALLER, FROM_FAR_END, FROM_KERNEL, PIECE_HAS_STATUS, - PIECE_LAST, PRINCIPAL_REQUIRED, REFUSAL_KERNEL, UNITS_REPORTED, + PIECE_LAST, PRINCIPAL_REQUIRED, REFUSAL_KERNEL, TAP_FAULT_BAD_JSON, UNITS_REPORTED, }; use busbar_contract::abi::sdk::capture::{CaptureHome, CaptureSlot}; use busbar_contract::abi::sdk::door::kind_op; @@ -158,7 +158,7 @@ impl Piece { .collect(); format!( "{:?} emitted={} more={} to_far_end={} done={} status={} verdict={} verb={} target={} \ - fields={fields:?} units={units:?}", + fields={fields:?} units={units:?} tap_fault={}", c.outcome, String::from_utf8_lossy(&self.reply[..o.emitted as usize]), o.more, @@ -168,6 +168,7 @@ impl Piece { o.verdict, at(&self.arena, o.verb), at(&self.arena, o.target), + o.tap_fault, ) } } @@ -483,6 +484,55 @@ fn the_linked_and_the_dropped_in_door_answer_every_op_the_same() { assert_eq!(script(&dropped(&d)), linked, "the dropped-in door"); } +/// A same-dialect 2xx body the usage tap cannot read: not JSON, and longer than one narrow window. +const UNREADABLE: &[u8] = b"{not json: the far end answered 200 with a body no reader can meter"; + +/// THE USAGE-TAP FAULT RIDES THE DOOR'S ANSWER AS DATA, ONCE (row 155): a same-dialect 2xx body the +/// tap cannot read is relayed as it came, and the answer that carries its units carries the fault +/// beside them, on ONE window of the answer however many the reply takes, so the kernel, which +/// counts each answer's fault, counts it exactly once. Linked and dropped in alike: the codec +/// reaches no host to report it. +#[test] +fn a_usage_tap_fault_rides_one_window_of_the_doors_answer() { + let d = Dispatcher::new(DispatchConfig::default()); + for p in [linked(&d), dropped(&d)] { + let mut i: PlaneOpenIn = z(); + i.open.head = in_head(); + (i.open.generation, i.open.settings) = (1, octets(SETTINGS)); + let mut o: PlaneOpenOut = z(); + o.open.head = out_head(); + assert_eq!(p.open(&mut Frame::new(i, o)).0.outcome, Outcome::Ready); + assert!(arrive(&p, 11, b"/v1/messages", CHAT).starts_with("arrive Ready")); + let mut wide = Piece::new(1024); + assert!(wide + .call(&p, 11, FROM_KERNEL, 0, b"", 1) + .contains("to_far_end=true")); + assert!(wide + .call(&p, 11, FROM_CALLER, PIECE_LAST, CHAT, 0) + .contains(" more=0 ")); + let mut narrow = Piece::new(16); + let whole = PIECE_HAS_STATUS | PIECE_LAST; + let mut windows = vec![narrow.call(&p, 11, FROM_FAR_END, whole, UNREADABLE, 0)]; + while windows.last().is_some_and(|w| w.contains(" more=1 ")) { + windows.push(narrow.call(&p, 11, FROM_FAR_END, 0, b"", 0)); + } + let all = windows.join("\n"); + assert!( + windows.len() > 1, + "the answer takes more than one window: {all}" + ); + let carried: Vec<&String> = windows + .iter() + .filter(|w| !w.ends_with(" tap_fault=0")) + .collect(); + assert_eq!(carried.len(), 1, "one window carries the fault: {all}"); + assert!( + carried[0].ends_with(&format!(" tap_fault={TAP_FAULT_BAD_JSON}")), + "the fault is the tap's own reason: {all}" + ); + } +} + /// A call capture for the hand-built table entry below: one slot per thread, as `plugin_door!` /// expands for a plugin's own image. struct TestCapture; diff --git a/crates/busbar/src/root/door_steps.rs b/crates/busbar/src/root/door_steps.rs index 0e3e107849..22a2e1677a 100644 --- a/crates/busbar/src/root/door_steps.rs +++ b/crates/busbar/src/root/door_steps.rs @@ -459,7 +459,8 @@ pub struct DoorFacts { pub keeps: Vec, /// The plane's dialect names, by the index `arrive` answers, and each provider's dialect: an /// attempt whose far end speaks another dialect than the caller's is counted as a translation - /// (`busbar_translations_total`), as the previous release counted it. Empty = none counted. + /// (`busbar_translations_total`), as the previous release counted it, and a usage-tap fault + /// an answer carries is counted under the unit's dialect. Empty = none counted. pub translations: (Arc<[String]>, Arc>), /// The plane's declared breaker fact (`declares.breaker`, ARCHITECT Q4): whether a transient /// failure below the trip threshold benches a member's cell. `None`: it declares none, and @@ -829,6 +830,13 @@ impl DriverSteps for DoorSteps<'_> { } } + fn usage_tap_fault(&self, _ctx: &UnitCtx, dialect: u32, reason: &'static str) { + // The unit's dialect is the ingress protocol its tap read the body as (row 155). + if let Some(protocol) = self.facts.translations.0.get(dialect as usize) { + busbar_kernel::handlers::usage_tap_fault(protocol, reason); + } + } + fn refusal_words(&self, reason: ReasonCode) -> Option> { // A unit routed by scope that no one entry reached: the entries that reached it (none, or // several), so the plane words which (abi/plane `ROUTE_SCOPE`). diff --git a/crates/busbar/tests/plane_driver.rs b/crates/busbar/tests/plane_driver.rs index bcd2f053d3..bc1a92d0af 100644 --- a/crates/busbar/tests/plane_driver.rs +++ b/crates/busbar/tests/plane_driver.rs @@ -788,7 +788,8 @@ fn zero_piece_out() -> OnPieceOut { target: span, need: 0, fault: 0, - _fault_reserved: [0; 3], + tap_fault: 0, + _fault_reserved: [0; 2], lane: span, final_status: 0, _final_reserved: 0, diff --git a/qa/abi-freeze.toml b/qa/abi-freeze.toml index 1da7dbca16..f96bb03009 100644 --- a/qa/abi-freeze.toml +++ b/qa/abi-freeze.toml @@ -7,7 +7,7 @@ [[frozen]] dir = "crates/busbar-contract/src/abi/hot" -lines = "6224" +lines = "6100" [[frozen]] dir = "crates/busbar-contract/src/abi/cold" diff --git a/qa/contract-stateless.toml b/qa/contract-stateless.toml index 7669df3bec..e07d734f2b 100644 --- a/qa/contract-stateless.toml +++ b/qa/contract-stateless.toml @@ -3,11 +3,6 @@ # row is one the tree carries today. Strike a row in the commit that drains it; # never add one. Regenerate with `cargo xtask gate contract-stateless --write`. -[[finding]] -rule = "contract-stateless:once-cell" -file = "crates/busbar-contract/src/abi/hot/services.rs" -item = "OnceLock" - [[finding]] rule = "contract-stateless:once-cell" file = "crates/busbar-contract/src/codec.rs" @@ -43,16 +38,6 @@ rule = "contract-stateless:static" file = "crates/busbar-contract/src/abi/hot/services.rs" item = "ARMED_SIZE" -[[finding]] -rule = "contract-stateless:static" -file = "crates/busbar-contract/src/abi/hot/services.rs" -item = "FILLING" - -[[finding]] -rule = "contract-stateless:static" -file = "crates/busbar-contract/src/abi/hot/services.rs" -item = "KEPT" - # The door macro's per-thread call-capture slot (plugin logging, #85), expanded in each PLUGIN's own # crate: one slot per plugin image, never state shared by host and plugins. Owner 2026-09-28. [[finding]] @@ -170,16 +155,6 @@ rule = "contract-stateless:static" file = "crates/busbar-contract/src/codec.rs" item = "TRANSLATE_CAP_READER" -[[finding]] -rule = "contract-stateless:static" -file = "crates/busbar-contract/src/codec.rs" -item = "USAGE_TAP_FAULT_LATCH" - -[[finding]] -rule = "contract-stateless:static" -file = "crates/busbar-contract/src/codec.rs" -item = "USAGE_TAP_FAULT_REPORTER" - [[finding]] rule = "contract-stateless:static" file = "crates/busbar-contract/src/codec.rs" From 4e3f943072ffaa8edb4df76bcb7b7f7ec23d15a0 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Wed, 14 Oct 2026 04:45:58 -0700 Subject: [PATCH 54/59] row 155: the linked engine's relay end hands its usage-tap fault to the kernel's count too The default build serves the llm plane through busbar-llm's engine, which drives the plane's Relay itself: its stream end now counts end.tap_fault through busbar_kernel::handlers::usage_tap_fault under the relay's ingress dialect, the same increment the contract latch made before. The import rides the engine's existing busbar_kernel::handlers line (ports-only count unchanged). --- crates/busbar-llm/src/engine/mod.rs | 2 +- crates/busbar-llm/src/engine/response_body.rs | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/busbar-llm/src/engine/mod.rs b/crates/busbar-llm/src/engine/mod.rs index a7b5f10407..fc7ed5aa22 100644 --- a/crates/busbar-llm/src/engine/mod.rs +++ b/crates/busbar-llm/src/engine/mod.rs @@ -36,7 +36,7 @@ use busbar_contract::upstream::Disposition; #[cfg_attr(not(test), allow(unused_imports))] use busbar_contract::upstream::StatusClass; #[cfg(test)] -use busbar_kernel::handlers::op_for; +use busbar_kernel::handlers::{op_for, usage_tap_fault}; use busbar_kernel::plane_host::OnExhaustedInput as OnExhausted; use busbar_kernel::{ auth::{present_caller, CallerCredential as KernelCallerCredential}, diff --git a/crates/busbar-llm/src/engine/response_body.rs b/crates/busbar-llm/src/engine/response_body.rs index 9253be1a09..44d4e832ce 100644 --- a/crates/busbar-llm/src/engine/response_body.rs +++ b/crates/busbar-llm/src/engine/response_body.rs @@ -410,6 +410,11 @@ where if let Some(reason) = end.stream_fault { this.record_transient(reason); } + // THE USAGE-TAP FAULT the relay's tap returned as data (row 155): the kernel + // counts it, under the caller's dialect the tap read the body as. + if let Some(fault) = end.tap_fault { + usage_tap_fault(this.relay.ingress(), fault.reason()); + } // The stream's drops (design F3 "Drops"), already warned once each: one audit row // per wire path, the twin of the buffered answer's rows. if let (Some(host), Some(rt)) = (this.host.as_ref(), this.rt.as_ref()) { From eaf3bdef5e29d52452b98666b06a4d8067a20138 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Wed, 14 Oct 2026 05:01:31 -0700 Subject: [PATCH 55/59] row 155: keep the tap-fault hunks off #890's (the door names TAP_FAULT_NONE by path; the kernel test sits mid-file) --- .../src/plane_driver/tests/route_tests.rs | 44 +++++++++---------- crates/busbar-plane-llm/src/plane_door.rs | 11 ++--- 2 files changed, 28 insertions(+), 27 deletions(-) diff --git a/crates/busbar-kernel/src/plane_driver/tests/route_tests.rs b/crates/busbar-kernel/src/plane_driver/tests/route_tests.rs index 5b1eb5e87a..4d8736dd55 100644 --- a/crates/busbar-kernel/src/plane_driver/tests/route_tests.rs +++ b/crates/busbar-kernel/src/plane_driver/tests/route_tests.rs @@ -83,6 +83,28 @@ fn only_the_answers_first_piece_lends_the_kept_head() { assert!(i.head_fields.is_null() && i.head_fields_len == 0); } +/// THE USAGE-TAP FAULT IS READ OFF THE ANSWER (row 155): a plane's answer carrying a usage-tap +/// fault beside its units reads as the reason the kernel counts it under, and one carrying none +/// reads as nothing to count. The steps count each answer's fault once, and a plane writes it on +/// one window of its answer. +#[test] +fn an_answers_usage_tap_fault_reads_as_the_reason_the_kernel_counts() { + use busbar_contract::abi::plane::{ + OnPieceOut, TAP_FAULT_BAD_JSON, TAP_FAULT_DECODE, TAP_FAULT_NONE, + TAP_FAULT_UNKNOWN_PROTOCOL, + }; + let mut o: OnPieceOut = blank_out(); + for (code, reason) in [ + (TAP_FAULT_NONE, None), + (TAP_FAULT_DECODE, Some("decode")), + (TAP_FAULT_BAD_JSON, Some("bad_json")), + (TAP_FAULT_UNKNOWN_PROTOCOL, Some("unknown_protocol")), + ] { + o.tap_fault = code; + assert_eq!(Answer::of(&o).usage_tap_fault(), reason, "{code}"); + } +} + #[test] fn every_piece_says_whether_the_attempts_member_relays_the_callers_credential() { let mut b = bufs(0); @@ -126,25 +148,3 @@ fn a_plane_is_lent_the_operators_name_of_its_member() { assert_eq!(super::operator_name(b"gpt-model"), b"gpt-model"); assert_eq!(super::operator_name(b""), b""); } - -/// THE USAGE-TAP FAULT IS READ OFF THE ANSWER (row 155): a plane's answer carrying a usage-tap -/// fault beside its units reads as the reason the kernel counts it under, and one carrying none -/// reads as nothing to count. The steps count each answer's fault once, and a plane writes it on -/// one window of its answer. -#[test] -fn an_answers_usage_tap_fault_reads_as_the_reason_the_kernel_counts() { - use busbar_contract::abi::plane::{ - OnPieceOut, TAP_FAULT_BAD_JSON, TAP_FAULT_DECODE, TAP_FAULT_NONE, - TAP_FAULT_UNKNOWN_PROTOCOL, - }; - let mut o: OnPieceOut = blank_out(); - for (code, reason) in [ - (TAP_FAULT_NONE, None), - (TAP_FAULT_DECODE, Some("decode")), - (TAP_FAULT_BAD_JSON, Some("bad_json")), - (TAP_FAULT_UNKNOWN_PROTOCOL, Some("unknown_protocol")), - ] { - o.tap_fault = code; - assert_eq!(Answer::of(&o).usage_tap_fault(), reason, "{code}"); - } -} diff --git a/crates/busbar-plane-llm/src/plane_door.rs b/crates/busbar-plane-llm/src/plane_door.rs index 8936bccaa0..5ff351788a 100644 --- a/crates/busbar-plane-llm/src/plane_door.rs +++ b/crates/busbar-plane-llm/src/plane_door.rs @@ -65,8 +65,8 @@ use busbar_contract::abi::plane::{ CANCEL_ABORTED, CANCEL_OK_PARTIAL, CLAIM_EXACT, CLAIM_PROBE, EMIT_DONE, EMIT_TO_FAR_END, FROM_CALLER, FROM_FAR_END, FROM_KERNEL, INGRESS_REQUEST_RESPONSE, INGRESS_RESPONSE_STREAM, PIECE_HAS_STATUS, PIECE_LAST, PRINCIPAL_NONE, PRINCIPAL_REQUIRED, REFUSAL_GATE, ROUTE_DIRECT, - ROUTE_POOL, SHAPE_PIECEWISE, SPAN_ABSENT, TAIL_FALLBACK, TAIL_PROBES, TAP_FAULT_NONE, - UNITS_FLOOR, UNITS_REPORTED, VERDICT_HARD, VERDICT_NONE, VERDICT_OK, VERDICT_RETRY, + ROUTE_POOL, SHAPE_PIECEWISE, SPAN_ABSENT, TAIL_FALLBACK, TAIL_PROBES, UNITS_FLOOR, + UNITS_REPORTED, VERDICT_HARD, VERDICT_NONE, VERDICT_OK, VERDICT_RETRY, }; use busbar_contract::abi::plane::{PlaneCancelIn, PlaneCancelOut}; use busbar_contract::abi::plane::{RecordWrite, AUDIT_DEGRADED, RECORD_AUDIT}; @@ -798,9 +798,10 @@ fn to_caller(piece: Piece<'_>) -> Answer { units: counts(&piece.units), verdict: verdict(piece.verdict), fault, - tap_fault: piece - .tap_fault - .map_or(TAP_FAULT_NONE, busbar_contract::codec::UsageTapFault::code), + tap_fault: piece.tap_fault.map_or( + busbar_contract::abi::plane::TAP_FAULT_NONE, + busbar_contract::codec::UsageTapFault::code, + ), done: piece.done, ..Answer::default() } From e56756114d38ac2db9b855406610c63bfe401322 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Wed, 14 Oct 2026 05:16:52 -0700 Subject: [PATCH 56/59] row 155: the tap-fault byte is a closed enum; the kernel counts any other value as reason=unknown, and a door's fault reaches the kernel counter (RED) Coordinator conditions on the #1030 seam: OnPieceOut::tap_fault is 0 = none plus the named reasons; check_on_piece no longer refuses a value past the vocabulary (only a fault on a non-READY answer), and UsageTapFault::reason_of reads any other value as reason="unknown", counted and warned once, never ignored and never read as none. RED: plane_driver_cases' a_usage_tap_fault_the_door_returns_ reaches_the_kernel_counter drives the kernel double and the linked and dropped test plane through the one loop: a door answering a bad_json fault, and one answering an unknown byte, each reach busbar_billing_tap_decode_fail_total once per unit under their reason. --- crates/busbar-contract/src/abi/plane/check.rs | 14 +-- crates/busbar-contract/src/abi/plane/mod.rs | 6 +- .../src/abi/tests/plane_check_tests.rs | 11 +-- crates/busbar-contract/src/codec.rs | 18 +++- .../busbar-kernel/src/plane_driver/route.rs | 5 +- .../src/plane_driver/tests/route_tests.rs | 7 +- crates/busbar-kernel/tests/common/mod.rs | 17 +++- crates/busbar-kernel/tests/plane_driver.rs | 8 ++ .../tests/support/plane_driver_cases.rs | 89 +++++++++++++++++++ .../tests/fixtures/plane_driver_test_plane.rs | 8 ++ 10 files changed, 158 insertions(+), 25 deletions(-) diff --git a/crates/busbar-contract/src/abi/plane/check.rs b/crates/busbar-contract/src/abi/plane/check.rs index b08f5064d0..a4fa2990d6 100644 --- a/crates/busbar-contract/src/abi/plane/check.rs +++ b/crates/busbar-contract/src/abi/plane/check.rs @@ -34,8 +34,7 @@ use super::{ RECORD_AUDIT, RECORD_PUT, REFUSAL_ANY_DIALECT, ROUTE_COUNTED, ROUTE_DIRECT, ROUTE_LOCAL, ROUTE_ONCE, ROUTE_POOL, ROUTE_PUBLIC, ROUTE_SCOPE, ROUTE_SESSION, ROUTE_STREAM, SHAPE_PIECEWISE, SHAPE_WHOLE, TAIL_FALLBACK, TAIL_HOOKS_GATED, TAIL_PROBES, TAP_FAULT_NONE, - TAP_FAULT_UNKNOWN_PROTOCOL, TRUST_ITEM_APPROVALS, TRUST_PIN, TRUST_PRIVATE_REACH, - UNITS_ESTIMATED, VERDICT_HARD, + TRUST_ITEM_APPROVALS, TRUST_PIN, TRUST_PRIVATE_REACH, UNITS_ESTIMATED, VERDICT_HARD, }; use crate::abi::hook::{ signal, MessageView, SignalEntry, REQUEST_HAS_MAX_TOKENS, REQUEST_HAS_TOOLS, REQUEST_STREAM, @@ -450,8 +449,9 @@ fn verdict(outcome: Outcome, v: u32) -> Result<(), Fault> { } /// `on_piece`'s breaker fault reading: a known `FAULT_*` (the transport kind's one vocabulary), none -/// on an answer that is not READY, and its padding zero. Its usage-tap fault likewise: a known -/// `TAP_FAULT_*`, none on an answer that is not READY. +/// on an answer that is not READY, and its padding zero. Its usage-tap fault rides only a READY +/// answer; any value is admitted there, because the kernel counts a value outside the `TAP_FAULT_*` +/// vocabulary as `unknown` rather than refusing the answer it rides. fn fault_reading(outcome: Outcome, out: &OnPieceOut) -> Result<(), Fault> { use crate::abi::transport::{FAULT_HARD, FAULT_NONE}; code( @@ -463,12 +463,6 @@ fn fault_reading(outcome: Outcome, out: &OnPieceOut) -> Result<(), Fault> { if out.fault != FAULT_NONE && outcome != Outcome::Ready { return Err(fault(Rule::Contradiction, "on_piece.fault_not_ready")); } - code( - u64::from(out.tap_fault), - 0, - u64::from(TAP_FAULT_UNKNOWN_PROTOCOL), - "on_piece.tap_fault", - )?; if out.tap_fault != TAP_FAULT_NONE && outcome != Outcome::Ready { return Err(fault(Rule::Contradiction, "on_piece.tap_fault_not_ready")); } diff --git a/crates/busbar-contract/src/abi/plane/mod.rs b/crates/busbar-contract/src/abi/plane/mod.rs index d3e5fd6a5e..6407443b2b 100644 --- a/crates/busbar-contract/src/abi/plane/mod.rs +++ b/crates/busbar-contract/src/abi/plane/mod.rs @@ -1798,8 +1798,10 @@ pub struct OnPieceOut { /// plane's usage tap could not read a same-protocol 2xx body, so the request billed 0 tokens /// ([`crate::codec::UsageTapFault`]). The kernel counts it, once per answer that carries it, on /// `busbar_billing_tap_decode_fail_total{protocol,reason}` under the unit's dialect, and warns - /// once per `(protocol, reason)`: the fault crosses as data, never as a host call. - /// [`TAP_FAULT_NONE`] = none. Laid in the padding after `fault` (pre-tag v1). + /// once per `(protocol, reason)`: the fault crosses as data, never as a host call. A CLOSED + /// enum: [`TAP_FAULT_NONE`] = none, then the named reasons; any other value is counted under + /// `reason="unknown"` (and warned once), never ignored and never read as none. Laid in the + /// padding after `fault` (pre-release, at the unchanged plane ABI version). pub tap_fault: u8, /// Alignment padding. pub _fault_reserved: [u8; 2], diff --git a/crates/busbar-contract/src/abi/tests/plane_check_tests.rs b/crates/busbar-contract/src/abi/tests/plane_check_tests.rs index 6cf70b02b0..8bf278fbc8 100644 --- a/crates/busbar-contract/src/abi/tests/plane_check_tests.rs +++ b/crates/busbar-contract/src/abi/tests/plane_check_tests.rs @@ -1680,11 +1680,11 @@ fn a_fault_reading_is_the_transports_vocabulary_on_a_ready_answer() { ); } -/// THE USAGE-TAP FAULT rides the answer as data (row 155): a known `TAP_FAULT_*` on a READY answer, -/// beside the breaker reading and separate from it. RED arms: past `TAP_FAULT_UNKNOWN_PROTOCOL`, and -/// on an answer that is not READY. +/// THE USAGE-TAP FAULT rides the answer as data (row 155), beside the breaker reading and separate +/// from it, on a READY answer only. A value past the vocabulary is ADMITTED: the kernel counts it +/// as `unknown` rather than refusing the answer it rides. RED arm: a fault on a non-READY answer. #[test] -fn a_usage_tap_fault_is_a_known_code_on_a_ready_answer() { +fn a_usage_tap_fault_rides_only_a_ready_answer() { use crate::abi::plane::{ TAP_FAULT_BAD_JSON, TAP_FAULT_DECODE, TAP_FAULT_NONE, TAP_FAULT_UNKNOWN_PROTOCOL, }; @@ -1701,7 +1701,8 @@ fn a_usage_tap_fault_is_a_known_code_on_a_ready_answer() { o.tap_fault = TAP_FAULT_UNKNOWN_PROTOCOL + 1; assert_eq!( piece(&o, &[], &[], &[]), - f(Rule::UnknownCode, "on_piece.tap_fault") + Ok(()), + "counted as unknown, never refused" ); o.tap_fault = TAP_FAULT_DECODE; assert_eq!( diff --git a/crates/busbar-contract/src/codec.rs b/crates/busbar-contract/src/codec.rs index bae0af7dd1..c33376b05e 100644 --- a/crates/busbar-contract/src/codec.rs +++ b/crates/busbar-contract/src/codec.rs @@ -174,8 +174,24 @@ impl UsageTapFault { } } + /// The reason the kernel counts a door's answer code under, the byte read as the CLOSED enum + /// it is: `None` only for [`crate::abi::plane::TAP_FAULT_NONE`]; a named code's + /// [`Self::reason`]; and every other value [`Self::UNKNOWN_REASON`]. A byte outside the + /// vocabulary is never ignored and never read as none: it is counted, and warned once. + #[must_use] + pub const fn reason_of(code: u8) -> Option<&'static str> { + match Self::of_code(code) { + Some(fault) => Some(fault.reason()), + None if code == crate::abi::plane::TAP_FAULT_NONE => None, + None => Some(Self::UNKNOWN_REASON), + } + } + + /// The `reason` a tap-fault byte outside the vocabulary is counted under. + pub const UNKNOWN_REASON: &'static str = "unknown"; + /// The fault a door's answer code names; `None` for [`crate::abi::plane::TAP_FAULT_NONE`] and - /// for a code outside the vocabulary. + /// for a code outside the vocabulary (which [`Self::reason_of`] counts as `unknown`). #[must_use] pub const fn of_code(code: u8) -> Option { match code { diff --git a/crates/busbar-kernel/src/plane_driver/route.rs b/crates/busbar-kernel/src/plane_driver/route.rs index 2c3be88592..b97df1ce84 100644 --- a/crates/busbar-kernel/src/plane_driver/route.rs +++ b/crates/busbar-kernel/src/plane_driver/route.rs @@ -540,10 +540,9 @@ impl Answer { } /// The usage-tap fault this answer carries, as the reason the kernel counts it under; `None` - /// for an answer that carries none. + /// only for an answer that carries none. A byte outside the vocabulary is `unknown`, counted. fn usage_tap_fault(&self) -> Option<&'static str> { - busbar_contract::codec::UsageTapFault::of_code(self.tap_fault) - .map(busbar_contract::codec::UsageTapFault::reason) + busbar_contract::codec::UsageTapFault::reason_of(self.tap_fault) } } diff --git a/crates/busbar-kernel/src/plane_driver/tests/route_tests.rs b/crates/busbar-kernel/src/plane_driver/tests/route_tests.rs index 4d8736dd55..21589c3f02 100644 --- a/crates/busbar-kernel/src/plane_driver/tests/route_tests.rs +++ b/crates/busbar-kernel/src/plane_driver/tests/route_tests.rs @@ -85,8 +85,9 @@ fn only_the_answers_first_piece_lends_the_kept_head() { /// THE USAGE-TAP FAULT IS READ OFF THE ANSWER (row 155): a plane's answer carrying a usage-tap /// fault beside its units reads as the reason the kernel counts it under, and one carrying none -/// reads as nothing to count. The steps count each answer's fault once, and a plane writes it on -/// one window of its answer. +/// reads as nothing to count. A byte outside the vocabulary reads as `unknown`: counted, never +/// ignored and never read as none. The steps count each answer's fault once, and a plane writes it +/// on one window of its answer. #[test] fn an_answers_usage_tap_fault_reads_as_the_reason_the_kernel_counts() { use busbar_contract::abi::plane::{ @@ -99,6 +100,8 @@ fn an_answers_usage_tap_fault_reads_as_the_reason_the_kernel_counts() { (TAP_FAULT_DECODE, Some("decode")), (TAP_FAULT_BAD_JSON, Some("bad_json")), (TAP_FAULT_UNKNOWN_PROTOCOL, Some("unknown_protocol")), + (TAP_FAULT_UNKNOWN_PROTOCOL + 1, Some("unknown")), + (u8::MAX, Some("unknown")), ] { o.tap_fault = code; assert_eq!(Answer::of(&o).usage_tap_fault(), reason, "{code}"); diff --git a/crates/busbar-kernel/tests/common/mod.rs b/crates/busbar-kernel/tests/common/mod.rs index 1e5508b7b7..b1e610e83a 100644 --- a/crates/busbar-kernel/tests/common/mod.rs +++ b/crates/busbar-kernel/tests/common/mod.rs @@ -119,6 +119,9 @@ pub struct TestUnits { /// a plane does with one is post it; what this harness does is keep it, so a cell can post it /// onto a real ledger and read the books. pub abandoned: Mutex>, + /// Every usage-tap fault the driver handed these steps, `(dialect, reason)`, in order. Each is + /// also counted on the kernel's own counter, as the root's steps count it. + pub tap_faults: Mutex>, } impl Default for TestUnits { @@ -139,6 +142,7 @@ impl Default for TestUnits { groups: Vec::new(), capped: None, abandoned: Mutex::new(Vec::new()), + tap_faults: Mutex::new(Vec::new()), } } } @@ -355,8 +359,17 @@ macro_rules! step { }}; } -/// The plane driver's kernel steps: told nothing beyond the loop's own seats. -impl busbar_kernel::plane_driver::DriverSteps for TestUnits {} +/// The protocol label this harness counts a usage-tap fault under (its plane has no dialect names). +pub const TAP_FAULT_PROTOCOL: &str = "plane-driver-test"; + +/// The plane driver's kernel steps: told nothing beyond the loop's own seats, save a usage-tap +/// fault, which they keep and count on the kernel's counter exactly as the root's steps do. +impl busbar_kernel::plane_driver::DriverSteps for TestUnits { + fn usage_tap_fault(&self, _ctx: &UnitCtx, dialect: u32, reason: &'static str) { + self.tap_faults.lock().unwrap().push((dialect, reason)); + busbar_kernel::handlers::usage_tap_fault(TAP_FAULT_PROTOCOL, reason); + } +} impl Units for TestUnits { fn arrival(&self, token: &Pass, _ctx: &UnitCtx) -> SeatVerdict { diff --git a/crates/busbar-kernel/tests/plane_driver.rs b/crates/busbar-kernel/tests/plane_driver.rs index 915c80da22..2e17a32a4a 100644 --- a/crates/busbar-kernel/tests/plane_driver.rs +++ b/crates/busbar-kernel/tests/plane_driver.rs @@ -492,6 +492,14 @@ impl Double { *i.fields_buf = OutField { name, value }; o.fields_written = 1; o.arena_written = at as u64; + // A plane whose usage tap could not read the answer (modes `tap-bad-json`, + // `tap-unknown`, the latter a byte outside the vocabulary): the fault rides the answer + // that opens the reply, once. + o.tap_fault = match u.mode.as_slice() { + b"tap-bad-json" => busbar_contract::abi::plane::TAP_FAULT_BAD_JSON, + b"tap-unknown" => 0xC8, + _ => busbar_contract::abi::plane::TAP_FAULT_NONE, + }; } let n = u.pending.len().min(i.reply_cap); std::ptr::copy_nonoverlapping(u.pending.as_ptr(), i.reply_buf, n); diff --git a/crates/busbar-kernel/tests/support/plane_driver_cases.rs b/crates/busbar-kernel/tests/support/plane_driver_cases.rs index 120fd6f726..ae731133de 100644 --- a/crates/busbar-kernel/tests/support/plane_driver_cases.rs +++ b/crates/busbar-kernel/tests/support/plane_driver_cases.rs @@ -870,6 +870,95 @@ async fn a_retry_verdict_before_the_first_byte_fails_over() { // `ledger_and_meter`: "`lane` is the SERVING lane"): when the answer commits, the driver names the // member that answered to the money steps, once. +/// THE USAGE-TAP FAULT A DOOR RETURNS REACHES THE KERNEL'S COUNTER (row 155): a plane whose +/// answer carries a usage-tap fault beside its units has it counted, ONCE per unit, by the steps +/// the driver hands it to, on `busbar_billing_tap_decode_fail_total{protocol,reason}` (the +/// counter, labels and increments of 1.5.5's latch). The byte is a closed enum: a value outside +/// the vocabulary is counted under `reason="unknown"`, never ignored and never read as none. RED: +/// drop the driver's read of `tap_fault` and neither the steps nor the counter see a fault. +#[tokio::test] +async fn a_usage_tap_fault_the_door_returns_reaches_the_kernel_counter() { + use metrics::{ + Counter, CounterFn, Gauge, Histogram, Key, KeyName, Metadata, Recorder, SharedString, Unit, + }; + use std::collections::BTreeMap; + type Counts = Arc>>; + struct Cell(Counts, String); + impl CounterFn for Cell { + fn increment(&self, value: u64) { + *self.0.lock().unwrap().entry(self.1.clone()).or_insert(0) += value; + } + fn absolute(&self, value: u64) { + self.0.lock().unwrap().insert(self.1.clone(), value); + } + } + struct Local(Counts); + impl Recorder for Local { + fn describe_counter(&self, _: KeyName, _: Option, _: SharedString) {} + fn describe_gauge(&self, _: KeyName, _: Option, _: SharedString) {} + fn describe_histogram(&self, _: KeyName, _: Option, _: SharedString) {} + fn register_counter(&self, key: &Key, _: &Metadata<'_>) -> Counter { + let labels: Vec = key + .labels() + .map(|l| format!("{}={}", l.key(), l.value())) + .collect(); + let series = format!("{}{{{}}}", key.name(), labels.join(",")); + Counter::from_arc(Arc::new(Cell(Arc::clone(&self.0), series))) + } + fn register_gauge(&self, _: &Key, _: &Metadata<'_>) -> Gauge { + Gauge::noop() + } + fn register_histogram(&self, _: &Key, _: &Metadata<'_>) -> Histogram { + Histogram::noop() + } + } + let counts = Counts::default(); + let local = Local(Arc::clone(&counts)); + let _recording = metrics::set_default_local_recorder(&local); + let mut ran = 0u64; + for way in ways() { + ran += 1; + for (member, reason) in [("tap-bad-json", "bad_json"), ("tap-unknown", "unknown")] { + let r = rig(way, BufferCaps::default(), Book::default()); + let (steps, far, caller) = ( + TestUnits::passing(), + Far::new(&[member], CHUNKS), + Caller::default(), + ); + let units = r + .driver + .unit(&steps, &far, &caller, arrival("/call", b"p"), 0); + assert!(matches!(drive(&units).await, Outcome::Completed)); + let seen: Vec<&str> = steps + .tap_faults + .lock() + .unwrap() + .iter() + .map(|f| f.1) + .collect(); + assert_eq!(seen, vec![reason], "{way:?}: one fault per unit"); + } + let ok = TestUnits::passing(); + let (far, caller) = (Far::new(&["ok"], CHUNKS), Caller::default()); + let r = rig(way, BufferCaps::default(), Book::default()); + let units = r.driver.unit(&ok, &far, &caller, arrival("/call", b"p"), 0); + assert!(matches!(drive(&units).await, Outcome::Completed)); + assert!( + ok.tap_faults.lock().unwrap().is_empty(), + "{way:?}: an answer carrying none counts none" + ); + } + let series = |reason: &str| { + format!( + "busbar_billing_tap_decode_fail_total{{protocol={},reason={reason}}}", + super::common::TAP_FAULT_PROTOCOL + ) + }; + let counts = counts.lock().unwrap().clone(); + assert_eq!(counts.get(&series("bad_json")), Some(&ran), "{counts:?}"); + assert_eq!(counts.get(&series("unknown")), Some(&ran), "{counts:?}"); +} + /// RED: without the driver's call the money steps never learn the serving member. #[tokio::test] async fn the_answering_member_is_named_to_the_money_steps_once() { diff --git a/crates/busbar/tests/fixtures/plane_driver_test_plane.rs b/crates/busbar/tests/fixtures/plane_driver_test_plane.rs index 027df976e0..794a82cb1a 100644 --- a/crates/busbar/tests/fixtures/plane_driver_test_plane.rs +++ b/crates/busbar/tests/fixtures/plane_driver_test_plane.rs @@ -1165,6 +1165,14 @@ extern "C" fn on_piece( *i.fields_buf = OutField { name, value }; o.fields_written = 1; o.arena_written = at as u64; + // A plane whose usage tap could not read the answer (modes `tap-bad-json`, + // `tap-unknown`, the latter a byte outside the vocabulary): the fault rides the answer + // that opens the reply, once. + o.tap_fault = match u.mode.as_slice() { + b"tap-bad-json" => busbar_contract::abi::plane::TAP_FAULT_BAD_JSON, + b"tap-unknown" => 0xC8, + _ => busbar_contract::abi::plane::TAP_FAULT_NONE, + }; } let n = u.pending.len().min(i.reply_cap); std::ptr::copy_nonoverlapping(u.pending.as_ptr(), i.reply_buf, n); From ec167b0462bd73441531da333b5798028381eafc Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Wed, 14 Oct 2026 06:05:42 -0700 Subject: [PATCH 57/59] row 155 (#1030 compile red): the legacy engine counts the tap fault through busbar_kernel::handlers::usage_tap_fault, the same kernel counter path; merged origin/predev --- crates/busbar-llm/src/engine/response_body.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/busbar-llm/src/engine/response_body.rs b/crates/busbar-llm/src/engine/response_body.rs index 44d4e832ce..e6f65773f1 100644 --- a/crates/busbar-llm/src/engine/response_body.rs +++ b/crates/busbar-llm/src/engine/response_body.rs @@ -413,7 +413,10 @@ where // THE USAGE-TAP FAULT the relay's tap returned as data (row 155): the kernel // counts it, under the caller's dialect the tap read the body as. if let Some(fault) = end.tap_fault { - usage_tap_fault(this.relay.ingress(), fault.reason()); + busbar_kernel::handlers::usage_tap_fault( + this.relay.ingress(), + fault.reason(), + ); } // The stream's drops (design F3 "Drops"), already warned once each: one audit row // per wire path, the twin of the buffered answer's rows. From 4788be0ac995408254f9ed0043aa1f1434ec5dc0 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Wed, 14 Oct 2026 07:13:23 -0700 Subject: [PATCH 58/59] row 155 (#1030 clippy red): drop the now-unused usage_tap_fault import (the call names it by path); merged origin/predev --- crates/busbar-llm/src/engine/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/busbar-llm/src/engine/mod.rs b/crates/busbar-llm/src/engine/mod.rs index fc7ed5aa22..a7b5f10407 100644 --- a/crates/busbar-llm/src/engine/mod.rs +++ b/crates/busbar-llm/src/engine/mod.rs @@ -36,7 +36,7 @@ use busbar_contract::upstream::Disposition; #[cfg_attr(not(test), allow(unused_imports))] use busbar_contract::upstream::StatusClass; #[cfg(test)] -use busbar_kernel::handlers::{op_for, usage_tap_fault}; +use busbar_kernel::handlers::op_for; use busbar_kernel::plane_host::OnExhaustedInput as OnExhausted; use busbar_kernel::{ auth::{present_caller, CallerCredential as KernelCallerCredential}, From f487e843119ba12ed53a96feafa22f7a68b8e941 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:51:04 -0700 Subject: [PATCH 59/59] row 155 (L1#1): the door-unit tests drive the door from tests/conformance.rs, the one file a plugin's tests may name the loader from kind-isolation:test-deps grants a plugin crate its dev-edge on busbar-plugin-loader only when every source file that names the loader as code is its tests/conformance.rs (the both-ways witness rule, xtask kind_isolation.rs conformance_witness_edges). #890's tests/door_retry.rs named the loader too, so the edge stopped being granted and read as an unlisted plane -> plugin-tooling test edge. The retry tests move verbatim into tests/conformance.rs as `mod door_units`; no ledger row is raised. --- crates/busbar-plane-llm/tests/conformance.rs | 281 +++++++++++++++++++ crates/busbar-plane-llm/tests/door_retry.rs | 276 ------------------ 2 files changed, 281 insertions(+), 276 deletions(-) delete mode 100644 crates/busbar-plane-llm/tests/door_retry.rs diff --git a/crates/busbar-plane-llm/tests/conformance.rs b/crates/busbar-plane-llm/tests/conformance.rs index 013f41521b..4db2eb5ed6 100644 --- a/crates/busbar-plane-llm/tests/conformance.rs +++ b/crates/busbar-plane-llm/tests/conformance.rs @@ -544,3 +544,284 @@ fn red_a_door_that_stops_counting_the_far_ends_units_answers_differently() { assert_ne!(Some(red_line), far_end(&honest)); assert_ne!(red, honest); } + +/// THE DOOR'S UNITS ACROSS ATTEMPTS, driven through the one loader like the witness above. They live +/// in this file because it is the one file a plugin's tests may drive its door through the loader +/// from (kind isolation's both-ways witness rule). +/// +/// A RETRY VERDICT, AS THE DOOR SERVES IT (row-159 audit plane-llm L1#1; status row 155): a far-end +/// error the plane judges retryable (here a context-length 400, the error map's class) keeps the +/// unit for the kernel's next ATTEMPT, so the failover reaches the next member, as 1.5.5 served the +/// second member after the first failed (v1.5.5 `crates/busbar/src/ingress/tests/tests.rs` +/// `test_real_failover_serves_second_member_after_first_5xx`, and the context-length exclusion of +/// `proxy`'s failover walk). An answer the kernel states it will not fail over (`PIECE_FINAL`) ends +/// the unit whatever its verdict, so the door keeps nothing behind it: a later piece of that unit is +/// a FAULT, the unit is gone. +mod door_units { + use std::mem::zeroed; + use std::sync::Arc; + + use busbar_contract::abi::mechanism::call::{AbiStr, Blob, Field, Outcome, BLOB_OCTETS}; + use busbar_contract::abi::mechanism::lifecycle::slot as life; + use busbar_contract::abi::plane::{ + slot, ArriveIn, ArriveOut, OnPieceIn, OnPieceOut, OutField, PlaneOpenIn, PlaneOpenOut, + UnitCount, EMIT_DONE, FROM_CALLER, FROM_FAR_END, FROM_KERNEL, PIECE_FINAL, + PIECE_HAS_STATUS, PIECE_LAST, VERDICT_OK, VERDICT_RETRY, + }; + use busbar_plane_llm::plane_door; + use busbar_plugin_loader::dispatch::kinds::plane::Plane; + use busbar_plugin_loader::dispatch::{ + in_head, load_linked, out_head, Bind, DispatchConfig, Dispatcher, Frame, LinkedRow, NoSink, + Plugin, + }; + + fn z() -> T { + // SAFETY: every `in`/`out` here is plain C data; all-zero is a valid value of each. + unsafe { zeroed() } + } + + fn octets(b: &'static [u8]) -> Blob { + Blob { + ptr: b.as_ptr(), + len: b.len(), + fmt: BLOB_OCTETS, + flags: 0, + } + } + + const fn text(b: &'static [u8]) -> AbiStr { + AbiStr { + ptr: b.as_ptr(), + len: b.len(), + } + } + + const fn field(name: &'static [u8], value: &'static [u8]) -> Field { + Field { + name: text(name), + value: text(value), + } + } + + /// Two anthropic far ends in one pool; the error map reads `invalid_request_error` as context length. + const SETTINGS: &[u8] = br#"{ + "providers": { + "ant-a": { "protocol": "anthropic", "base_url": "https://a.example", + "error_map": { "invalid_request_error": "context_length" } }, + "ant-b": { "protocol": "anthropic", "base_url": "https://b.example" } + }, + "models": { "small": { "provider": "ant-a" }, "large": { "provider": "ant-b" } }, + "pools": { "p": { "members": ["small", "large"] } } + }"#; + const CHAT: &[u8] = + br#"{"model":"p","max_tokens":16,"messages":[{"role":"user","content":"hi"}]}"#; + const CALLER: &[Field] = &[ + field(b"content-type", b"application/json"), + field(b"anthropic-version", b"2023-06-01"), + ]; + const TOO_LONG: &[u8] = + br#"{"type":"error","error":{"type":"invalid_request_error","message":"prompt is too long"}}"#; + const ANSWER: &[u8] = br#"{"id":"msg_1","type":"message","role":"assistant","model":"large","content":[{"type":"text","text":"hello"}],"stop_reason":"end_turn","usage":{"input_tokens":7,"output_tokens":3}}"#; + const HEAD: &[Field] = &[field(b"content-type", b"application/json")]; + + fn door() -> (Dispatcher, Plugin) { + let d = Dispatcher::new(DispatchConfig::default()); + let row = LinkedRow::of(plane_door::door).expect("the linked door states its Statement"); + let p = load_linked( + &row, + Bind { + instance: Arc::from("the-instance"), + max_inflight_cap: 64, + sink: Arc::new(NoSink), + dispatcher: d.adopter(), + conns: busbar_plugin_loader::dispatch::ConnTable::Probe, + }, + ) + .expect("the linked door loads"); + let mut i: PlaneOpenIn = z(); + i.open.head = in_head(); + (i.open.generation, i.open.settings) = (1, octets(SETTINGS)); + let mut o: PlaneOpenOut = z(); + o.open.head = out_head(); + let (opened, _) = p.open(&mut Frame::new(i, o)); + assert_eq!(opened.outcome, Outcome::Ready, "the door opens"); + for s in [life::HYDRATE, life::START] { + let mut g = Frame::new( + busbar_contract::abi::mechanism::lifecycle::GenIn { + head: in_head(), + generation: 1, + }, + out_head(), + ); + assert_eq!(p.call(s, &mut g).outcome, Outcome::Ready); + } + (d, p) + } + + fn arrive(p: &Plugin, unit: u64) { + let mut a: Frame = Frame::new(z(), z()); + (a.input.head, a.out.head) = (in_head(), out_head()); + (a.input.unit, a.input.method, a.input.target) = + (unit, text(b"POST"), text(b"/v1/messages")); + (a.input.fields, a.input.fields_len) = (CALLER.as_ptr(), CALLER.len()); + a.input.body = octets(CHAT); + assert_eq!(p.call(slot::ARRIVE, &mut a).outcome, Outcome::Ready); + } + + /// One piece's answer: its outcome, verdict and whether it closed the reply. + #[derive(Debug, PartialEq, Eq)] + struct Answer { + outcome: Outcome, + verdict: u32, + done: bool, + emitted: String, + } + + #[allow(clippy::too_many_arguments)] + fn piece( + p: &Plugin, + unit: u64, + from: u32, + flags: u32, + status: u32, + bytes: &'static [u8], + member: &'static [u8], + attempt_no: u32, + ) -> Answer { + let (mut reply, mut units, mut fields, mut arena) = ( + vec![0_u8; 4096], + [z::(); 8], + [z::(); 16], + [0_u8; 1024], + ); + let mut i: OnPieceIn = z(); + i.head = in_head(); + (i.unit, i.from, i.flags, i.bytes) = (unit, from, flags, octets(bytes)); + (i.attempt_no, i.member, i.pool) = (attempt_no, text(member), text(b"p")); + if flags & PIECE_HAS_STATUS != 0 { + i.status_code = status; + (i.head_fields, i.head_fields_len) = (HEAD.as_ptr(), HEAD.len()); + } + (i.reply_buf, i.reply_cap) = (reply.as_mut_ptr(), reply.len()); + (i.units_buf, i.units_cap) = (units.as_mut_ptr(), units.len()); + (i.fields_buf, i.fields_cap) = (fields.as_mut_ptr(), fields.len()); + (i.arena_buf, i.arena_cap) = (arena.as_mut_ptr(), arena.len()); + let mut o: OnPieceOut = z(); + o.head = out_head(); + let mut f = Frame::new(i, o); + let called = p.call(slot::ON_PIECE, &mut f); + Answer { + outcome: called.outcome, + verdict: f.out.verdict, + done: f.out.flags & EMIT_DONE != 0, + emitted: String::from_utf8_lossy(&reply[..f.out.emitted as usize]).into_owned(), + } + } + + /// One attempt of `unit` on `member`: its ATTEMPT piece and the caller's body. + fn attempt(p: &Plugin, unit: u64, member: &'static [u8], attempt_no: u32) { + let a = piece(p, unit, FROM_KERNEL, 0, 0, b"", member, attempt_no); + assert_eq!( + a.outcome, + Outcome::Ready, + "the ATTEMPT on {member:?}: {a:?}" + ); + let b = piece(p, unit, FROM_CALLER, PIECE_LAST, 0, CHAT, member, 0); + assert_eq!(b.outcome, Outcome::Ready, "the body on {member:?}: {b:?}"); + } + + /// RED: the door forgot the unit on its retry verdict, so the second ATTEMPT was a FAULT and the + /// failover never reached `large`. + #[test] + fn a_retry_verdict_keeps_the_unit_and_the_failover_reaches_the_next_member() { + let (_d, p) = door(); + arrive(&p, 7); + attempt(&p, 7, b"small", 1); + let failed = piece( + &p, + 7, + FROM_FAR_END, + PIECE_HAS_STATUS | PIECE_LAST, + 400, + TOO_LONG, + b"small", + 0, + ); + assert_eq!(failed.outcome, Outcome::Ready, "{failed:?}"); + assert_eq!( + failed.verdict, VERDICT_RETRY, + "context length is retried: {failed:?}" + ); + attempt(&p, 7, b"large", 2); + let served = piece( + &p, + 7, + FROM_FAR_END, + PIECE_HAS_STATUS | PIECE_LAST, + 200, + ANSWER, + b"large", + 0, + ); + assert_eq!(served.outcome, Outcome::Ready, "{served:?}"); + assert_eq!(served.verdict, VERDICT_OK); + assert!( + served.done && served.emitted.contains("hello"), + "{served:?}" + ); + // The unit ended with its answer: nothing of it is left behind. + let after = piece(&p, 7, FROM_KERNEL, 0, 0, b"", b"small", 3); + assert_eq!( + after.outcome, + Outcome::Fault, + "the ended unit is gone: {after:?}" + ); + } + + /// RED: a door that keeps every retried unit leaves one behind when the kernel will not fail the + /// answer over (a relayed answer): the unit's later piece is answered instead of faulted. + #[test] + fn a_final_answer_ends_the_unit_whatever_its_verdict() { + let (_d, p) = door(); + arrive(&p, 9); + attempt(&p, 9, b"small", 1); + let last = piece( + &p, + 9, + FROM_FAR_END, + PIECE_HAS_STATUS | PIECE_LAST | PIECE_FINAL, + 400, + TOO_LONG, + b"small", + 0, + ); + assert_eq!(last.outcome, Outcome::Ready, "{last:?}"); + assert_eq!(last.verdict, VERDICT_RETRY); + assert!(last.done, "the final answer closes the reply: {last:?}"); + let after = piece(&p, 9, FROM_KERNEL, 0, 0, b"", b"large", 2); + assert_eq!( + after.outcome, + Outcome::Fault, + "nothing left behind: {after:?}" + ); + } + + /// A piece whose flags the contract refuses (`PIECE_FINAL` without a status) is a FAULT. + #[test] + fn a_final_mark_off_the_status_piece_is_a_fault() { + let (_d, p) = door(); + arrive(&p, 11); + attempt(&p, 11, b"small", 1); + let bad = piece( + &p, + 11, + FROM_FAR_END, + PIECE_LAST | PIECE_FINAL, + 0, + ANSWER, + b"small", + 0, + ); + assert_eq!(bad.outcome, Outcome::Fault, "{bad:?}"); + } +} diff --git a/crates/busbar-plane-llm/tests/door_retry.rs b/crates/busbar-plane-llm/tests/door_retry.rs deleted file mode 100644 index 2f740efa6e..0000000000 --- a/crates/busbar-plane-llm/tests/door_retry.rs +++ /dev/null @@ -1,276 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (C) 2026 Busbar Inc and contributors - -//! A RETRY VERDICT, AS THE DOOR SERVES IT (row-159 audit plane-llm L1#1; status row 155): a far-end -//! error the plane judges retryable (here a context-length 400, the error map's class) keeps the -//! unit for the kernel's next ATTEMPT, so the failover reaches the next member, as 1.5.5 served the -//! second member after the first failed (v1.5.5 `crates/busbar/src/ingress/tests/tests.rs` -//! `test_real_failover_serves_second_member_after_first_5xx`, and the context-length exclusion of -//! `proxy`'s failover walk). An answer the kernel states it will not fail over (`PIECE_FINAL`) ends -//! the unit whatever its verdict, so the door keeps nothing behind it: a later piece of that unit is -//! a FAULT, the unit is gone. - -use std::mem::zeroed; -use std::sync::Arc; - -use busbar_contract::abi::mechanism::call::{AbiStr, Blob, Field, Outcome, BLOB_OCTETS}; -use busbar_contract::abi::mechanism::lifecycle::slot as life; -use busbar_contract::abi::plane::{ - slot, ArriveIn, ArriveOut, OnPieceIn, OnPieceOut, OutField, PlaneOpenIn, PlaneOpenOut, - UnitCount, EMIT_DONE, FROM_CALLER, FROM_FAR_END, FROM_KERNEL, PIECE_FINAL, PIECE_HAS_STATUS, - PIECE_LAST, VERDICT_OK, VERDICT_RETRY, -}; -use busbar_plane_llm::plane_door; -use busbar_plugin_loader::dispatch::kinds::plane::Plane; -use busbar_plugin_loader::dispatch::{ - in_head, load_linked, out_head, Bind, DispatchConfig, Dispatcher, Frame, LinkedRow, NoSink, - Plugin, -}; - -fn z() -> T { - // SAFETY: every `in`/`out` here is plain C data; all-zero is a valid value of each. - unsafe { zeroed() } -} - -fn octets(b: &'static [u8]) -> Blob { - Blob { - ptr: b.as_ptr(), - len: b.len(), - fmt: BLOB_OCTETS, - flags: 0, - } -} - -const fn text(b: &'static [u8]) -> AbiStr { - AbiStr { - ptr: b.as_ptr(), - len: b.len(), - } -} - -const fn field(name: &'static [u8], value: &'static [u8]) -> Field { - Field { - name: text(name), - value: text(value), - } -} - -/// Two anthropic far ends in one pool; the error map reads `invalid_request_error` as context length. -const SETTINGS: &[u8] = br#"{ - "providers": { - "ant-a": { "protocol": "anthropic", "base_url": "https://a.example", - "error_map": { "invalid_request_error": "context_length" } }, - "ant-b": { "protocol": "anthropic", "base_url": "https://b.example" } - }, - "models": { "small": { "provider": "ant-a" }, "large": { "provider": "ant-b" } }, - "pools": { "p": { "members": ["small", "large"] } } -}"#; -const CHAT: &[u8] = br#"{"model":"p","max_tokens":16,"messages":[{"role":"user","content":"hi"}]}"#; -const CALLER: &[Field] = &[ - field(b"content-type", b"application/json"), - field(b"anthropic-version", b"2023-06-01"), -]; -const TOO_LONG: &[u8] = - br#"{"type":"error","error":{"type":"invalid_request_error","message":"prompt is too long"}}"#; -const ANSWER: &[u8] = br#"{"id":"msg_1","type":"message","role":"assistant","model":"large","content":[{"type":"text","text":"hello"}],"stop_reason":"end_turn","usage":{"input_tokens":7,"output_tokens":3}}"#; -const HEAD: &[Field] = &[field(b"content-type", b"application/json")]; - -fn door() -> (Dispatcher, Plugin) { - let d = Dispatcher::new(DispatchConfig::default()); - let row = LinkedRow::of(plane_door::door).expect("the linked door states its Statement"); - let p = load_linked( - &row, - Bind { - instance: Arc::from("the-instance"), - max_inflight_cap: 64, - sink: Arc::new(NoSink), - dispatcher: d.adopter(), - conns: busbar_plugin_loader::dispatch::ConnTable::Probe, - }, - ) - .expect("the linked door loads"); - let mut i: PlaneOpenIn = z(); - i.open.head = in_head(); - (i.open.generation, i.open.settings) = (1, octets(SETTINGS)); - let mut o: PlaneOpenOut = z(); - o.open.head = out_head(); - let (opened, _) = p.open(&mut Frame::new(i, o)); - assert_eq!(opened.outcome, Outcome::Ready, "the door opens"); - for s in [life::HYDRATE, life::START] { - let mut g = Frame::new( - busbar_contract::abi::mechanism::lifecycle::GenIn { - head: in_head(), - generation: 1, - }, - out_head(), - ); - assert_eq!(p.call(s, &mut g).outcome, Outcome::Ready); - } - (d, p) -} - -fn arrive(p: &Plugin, unit: u64) { - let mut a: Frame = Frame::new(z(), z()); - (a.input.head, a.out.head) = (in_head(), out_head()); - (a.input.unit, a.input.method, a.input.target) = (unit, text(b"POST"), text(b"/v1/messages")); - (a.input.fields, a.input.fields_len) = (CALLER.as_ptr(), CALLER.len()); - a.input.body = octets(CHAT); - assert_eq!(p.call(slot::ARRIVE, &mut a).outcome, Outcome::Ready); -} - -/// One piece's answer: its outcome, verdict and whether it closed the reply. -#[derive(Debug, PartialEq, Eq)] -struct Answer { - outcome: Outcome, - verdict: u32, - done: bool, - emitted: String, -} - -#[allow(clippy::too_many_arguments)] -fn piece( - p: &Plugin, - unit: u64, - from: u32, - flags: u32, - status: u32, - bytes: &'static [u8], - member: &'static [u8], - attempt_no: u32, -) -> Answer { - let (mut reply, mut units, mut fields, mut arena) = ( - vec![0_u8; 4096], - [z::(); 8], - [z::(); 16], - [0_u8; 1024], - ); - let mut i: OnPieceIn = z(); - i.head = in_head(); - (i.unit, i.from, i.flags, i.bytes) = (unit, from, flags, octets(bytes)); - (i.attempt_no, i.member, i.pool) = (attempt_no, text(member), text(b"p")); - if flags & PIECE_HAS_STATUS != 0 { - i.status_code = status; - (i.head_fields, i.head_fields_len) = (HEAD.as_ptr(), HEAD.len()); - } - (i.reply_buf, i.reply_cap) = (reply.as_mut_ptr(), reply.len()); - (i.units_buf, i.units_cap) = (units.as_mut_ptr(), units.len()); - (i.fields_buf, i.fields_cap) = (fields.as_mut_ptr(), fields.len()); - (i.arena_buf, i.arena_cap) = (arena.as_mut_ptr(), arena.len()); - let mut o: OnPieceOut = z(); - o.head = out_head(); - let mut f = Frame::new(i, o); - let called = p.call(slot::ON_PIECE, &mut f); - Answer { - outcome: called.outcome, - verdict: f.out.verdict, - done: f.out.flags & EMIT_DONE != 0, - emitted: String::from_utf8_lossy(&reply[..f.out.emitted as usize]).into_owned(), - } -} - -/// One attempt of `unit` on `member`: its ATTEMPT piece and the caller's body. -fn attempt(p: &Plugin, unit: u64, member: &'static [u8], attempt_no: u32) { - let a = piece(p, unit, FROM_KERNEL, 0, 0, b"", member, attempt_no); - assert_eq!( - a.outcome, - Outcome::Ready, - "the ATTEMPT on {member:?}: {a:?}" - ); - let b = piece(p, unit, FROM_CALLER, PIECE_LAST, 0, CHAT, member, 0); - assert_eq!(b.outcome, Outcome::Ready, "the body on {member:?}: {b:?}"); -} - -/// RED: the door forgot the unit on its retry verdict, so the second ATTEMPT was a FAULT and the -/// failover never reached `large`. -#[test] -fn a_retry_verdict_keeps_the_unit_and_the_failover_reaches_the_next_member() { - let (_d, p) = door(); - arrive(&p, 7); - attempt(&p, 7, b"small", 1); - let failed = piece( - &p, - 7, - FROM_FAR_END, - PIECE_HAS_STATUS | PIECE_LAST, - 400, - TOO_LONG, - b"small", - 0, - ); - assert_eq!(failed.outcome, Outcome::Ready, "{failed:?}"); - assert_eq!( - failed.verdict, VERDICT_RETRY, - "context length is retried: {failed:?}" - ); - attempt(&p, 7, b"large", 2); - let served = piece( - &p, - 7, - FROM_FAR_END, - PIECE_HAS_STATUS | PIECE_LAST, - 200, - ANSWER, - b"large", - 0, - ); - assert_eq!(served.outcome, Outcome::Ready, "{served:?}"); - assert_eq!(served.verdict, VERDICT_OK); - assert!( - served.done && served.emitted.contains("hello"), - "{served:?}" - ); - // The unit ended with its answer: nothing of it is left behind. - let after = piece(&p, 7, FROM_KERNEL, 0, 0, b"", b"small", 3); - assert_eq!( - after.outcome, - Outcome::Fault, - "the ended unit is gone: {after:?}" - ); -} - -/// RED: a door that keeps every retried unit leaves one behind when the kernel will not fail the -/// answer over (a relayed answer): the unit's later piece is answered instead of faulted. -#[test] -fn a_final_answer_ends_the_unit_whatever_its_verdict() { - let (_d, p) = door(); - arrive(&p, 9); - attempt(&p, 9, b"small", 1); - let last = piece( - &p, - 9, - FROM_FAR_END, - PIECE_HAS_STATUS | PIECE_LAST | PIECE_FINAL, - 400, - TOO_LONG, - b"small", - 0, - ); - assert_eq!(last.outcome, Outcome::Ready, "{last:?}"); - assert_eq!(last.verdict, VERDICT_RETRY); - assert!(last.done, "the final answer closes the reply: {last:?}"); - let after = piece(&p, 9, FROM_KERNEL, 0, 0, b"", b"large", 2); - assert_eq!( - after.outcome, - Outcome::Fault, - "nothing left behind: {after:?}" - ); -} - -/// A piece whose flags the contract refuses (`PIECE_FINAL` without a status) is a FAULT. -#[test] -fn a_final_mark_off_the_status_piece_is_a_fault() { - let (_d, p) = door(); - arrive(&p, 11); - attempt(&p, 11, b"small", 1); - let bad = piece( - &p, - 11, - FROM_FAR_END, - PIECE_LAST | PIECE_FINAL, - 0, - ANSWER, - b"small", - 0, - ); - assert_eq!(bad.outcome, Outcome::Fault, "{bad:?}"); -}