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/26] 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/26] 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/26] 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/26] 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/26] =?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 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 06/26] 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 07/26] 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 08/26] 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 09/26] 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 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 10/26] =?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 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 11/26] 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 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 12/26] 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 13/26] 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 14/26] 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 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 15/26] 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 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 16/26] 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 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 17/26] 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 18/26] 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 1a5f416c7d2d6c0e93c2c17d71928b044a731203 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:50:30 -0700 Subject: [PATCH 19/26] oracle: + billing|budget|downgrade, a script cell for a pool budget's on_exhaust: downgrade (status row 155) ARCHITECT ruling 2026-10-13 (row 155, #833): the 1.5.5 golden holds no cell for budget `on_exhaust: downgrade`. This adds the script-driver cell (testing/shadow-oracle/scripts/budget-downgrade.sh, its own config: a pool-scoped group budget of 25 a day at a 10 per-request fee, downgrading frontier to value; three chats on frontier; one mock per pool), its cells.json row from the generator's literal cell (busbar-release #348; 2518 -> 2519, core +1, billing +1), its owed-baseline row so the add-only rerecord records it, and its scripts-allowlist row. --- qa/scripts-allowlist.toml | 5 + testing/shadow-oracle/cells.json | 17 +- testing/shadow-oracle/owed-baseline.txt | 1 + .../shadow-oracle/scripts/budget-downgrade.sh | 149 ++++++++++++++++++ 4 files changed, 169 insertions(+), 3 deletions(-) create mode 100755 testing/shadow-oracle/scripts/budget-downgrade.sh diff --git a/qa/scripts-allowlist.toml b/qa/scripts-allowlist.toml index d5d766bdf9..258d909acb 100644 --- a/qa/scripts-allowlist.toml +++ b/qa/scripts-allowlist.toml @@ -743,6 +743,11 @@ path = "testing/shadow-oracle/scripts/rate-card-history.sh" class = "oracle-driver" reason = "an oracle cell driver or rig the oracle runs (scripts/ is in harness-rev): moves into busbar-release-oracle with a golden re-stamp HELD (ARCHITECT 2026-10-02): a port that re-stamps the golden blesses it, so this stays until a port can prove identical recordings with no re-stamp" +[[script]] +path = "testing/shadow-oracle/scripts/budget-downgrade.sh" +class = "oracle-driver" +reason = "an oracle cell driver or rig the oracle runs (scripts/ is in harness-rev): moves into busbar-release-oracle with a golden re-stamp HELD (ARCHITECT 2026-10-02): a port that re-stamps the golden blesses it, so this stays until a port can prove identical recordings with no re-stamp" + [[script]] path = "testing/shadow-oracle/scripts/refund-across-window.sh" class = "oracle-driver" diff --git a/testing/shadow-oracle/cells.json b/testing/shadow-oracle/cells.json index c9d1fcefe8..fcbf6b13cf 100644 --- a/testing/shadow-oracle/cells.json +++ b/testing/shadow-oracle/cells.json @@ -55,10 +55,10 @@ } ], "counts": { - "total": 2518, + "total": 2519, "by_plane": { "a2a": 468, - "core": 989, + "core": 990, "llm": 149, "mcp": 912 }, @@ -67,7 +67,7 @@ "admin.ops": 329, "admin.plugins": 7, "auth.lifecycle": 3, - "billing": 15, + "billing": 16, "boot.deprecated-env": 5, "boot.refusal": 209, "boot.warning": 31, @@ -12074,6 +12074,17 @@ }, "why": "a past UTC day bucket: empty and byte-stable" }, + { + "id": "billing|budget|downgrade", + "plane": "core", + "family": "billing", + "driver": "script", + "script": { + "name": "budget-downgrade.sh" + }, + "outcome": "ok", + "why": "a group budget scoped to pool `frontier` (25 a day at a 10 per-request fee, no rate card) declares `on_exhaust: downgrade, downgrade_to: value`: the first two chats naming `frontier` are served there and spend 20; the third would spend 30, so it is re-admitted through `value` and dispatched to `value`'s member instead of refused (1.5.5 ingress::admit_check + ingress::dispatch). Recorded: each chat's status, the downgraded answer, how many requests each pool's upstream received (one mock per pool), the key and group usage views, and the busbar_requests_total / busbar_upstream_attempts_total counters by pool. Recorded from the published 1.5.5 binary." + }, { "id": "billing|group-usage|after-2", "plane": "core", diff --git a/testing/shadow-oracle/owed-baseline.txt b/testing/shadow-oracle/owed-baseline.txt index 289089ddc8..02071db216 100644 --- a/testing/shadow-oracle/owed-baseline.txt +++ b/testing/shadow-oracle/owed-baseline.txt @@ -250,6 +250,7 @@ auth.lifecycle|key-revoke auth.lifecycle|key-rotate billing|admin-usage|after-2 billing|admin-usage|past-day +billing|budget|downgrade billing|group-usage|after-2 billing|group-usage|broke-after-prime billing|key-usage|after-1 diff --git a/testing/shadow-oracle/scripts/budget-downgrade.sh b/testing/shadow-oracle/scripts/budget-downgrade.sh new file mode 100755 index 0000000000..a1441b9955 --- /dev/null +++ b/testing/shadow-oracle/scripts/budget-downgrade.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026 Busbar Inc and contributors +# +# Script-driver cell: `billing|budget|downgrade`. A group budget scoped to pool `frontier` declares +# `on_exhaust: downgrade, downgrade_to: value`: once the budget is spent, a request naming +# `frontier` is not refused; it is re-admitted through `value`, its charge lands on `value`, and it +# is dispatched there (1.5.5 `ingress::admit_check` and `ingress::dispatch`). The cell records where +# each request went and where each charge and count landed. +# +# Steps, on our own throwaway boot (per_request_fee 10, no rate card: a request costs its fee): +# 1. mint a key in group `oracle`, whose `frontier` budget is 25 a day +# 2. three chats naming pool `frontier`: the first two spend 20; the third would spend 30 > 25, so +# it is downgraded to `value` +# 3. read what each pool's upstream received (one mock per pool, each counting its requests), the +# key and group usage views, and the request and attempt counters on `/metrics` +# +# Env from the recorder: BUSBAR_BIN RAW WORK ORACLE_ADMIN_TOKEN SCRIPT_LISTEN_PORT SCRIPT_ADMIN_PORT +# SCRIPT_MOCK_PORT BUSBAR_ORACLE_BIN +set -uo pipefail +here="$(cd "$(dirname "$0")/.." && pwd)" +repo="$(cd "${here}/../.." && pwd)" +source "${repo}/testing/fleet-fixtures/lib.sh" +BIN="${BUSBAR_BIN:?}"; RAW="${RAW:?}"; ADMIN="${ORACLE_ADMIN_TOKEN:-shadow-oracle-admin}" +ORACLE_BIN="${BUSBAR_ORACLE_BIN:?the Rust oracle engine must be published as BUSBAR_ORACLE_BIN}" +LP="${SCRIPT_LISTEN_PORT:-49611}" AP="${SCRIPT_ADMIN_PORT:-49612}" MP="${SCRIPT_MOCK_PORT:-49621}" +VP=$((MP + 1)) +W="$RAW/budget-downgrade-work"; rm -rf "$W"; mkdir -p "$W/frontier" "$W/value" + +# A harness failure is never an outcome of the binary: the -1 shape record.sh reads as a named gap. +gap() { jq -n --arg e "$1" '{status:-1, headers:{}, body:"", effects:{error:$e}}' >"$RAW/captured.json"; exit 0; } + +for p in "$LP" "$AP" "$MP" "$VP"; do assert_port_free "$p" || gap "port $p busy"; done + +# One mock per pool, each dropping one capture file per upstream request it answers, so the cell +# counts where every request was dispatched without reading the mock's capture format. +ORACLE_MOCK_CAPTURE_DIR="$W/frontier" "$ORACLE_BIN" mock "$MP" oracle-marker "$W/frontier.control" >"$W/frontier.log" 2>&1 & track_pid $! +ORACLE_MOCK_CAPTURE_DIR="$W/value" "$ORACLE_BIN" mock "$VP" oracle-marker "$W/value.control" >"$W/value.log" 2>&1 & track_pid $! +wait_for_http "http://127.0.0.1:${MP}/" 5 || gap "the frontier mock upstream did not come up" +wait_for_http "http://127.0.0.1:${VP}/" 5 || gap "the value mock upstream did not come up" + +"$BIN" --generate-signing-key >"$W/signing.key" 2>/dev/null +[ -s "$W/signing.key" ] || gap "--generate-signing-key produced no key" +cat >"$W/providers.yaml" <"$W/config.yaml" <"$RAW/captured.json"; exit 0; } + +( exec env BUSBAR_CONFIG="$W/config.yaml" BUSBAR_PROVIDERS="$W/providers.yaml" \ + ORACLE_UPSTREAM_KEY=unused BUSBAR_ADMIN_TOKEN="$ADMIN" RUST_LOG=warn "$BIN" ) >"$W/busbar.log" 2>&1 & +pid=$!; track_pid $pid +wait_for_http "http://127.0.0.1:${LP}/healthz" 30 || fail 1 "$(tail -c 500 "$W/busbar.log")" + +mint_raw="$(curl -sS -m 10 -w '\n%{http_code}' -X POST "http://127.0.0.1:${AP}/api/v1/admin/keys" -H "Authorization: Bearer $ADMIN" -H 'Content-Type: application/json' -d '{"name":"budget-downgrade","group":"oracle"}')" +mint_code="$(printf '%s' "$mint_raw" | tail -1)" +mint="$(printf '%s' "$mint_raw" | sed '$d')" +kid="$(jq -r '.id // empty' <<<"$mint")"; tok="$(jq -r '.token // empty' <<<"$mint")" +[ -n "$kid" ] && [ -n "$tok" ] || fail 2 "$mint" +step mint_status "$mint_code" + +chat() { # chat