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/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-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/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 3f231124bd..e5c2aa7187 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. @@ -169,289 +108,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..de10ce581b --- /dev/null +++ b/crates/busbar-kernel/src/plane/tests/approvals_tests.rs @@ -0,0 +1,50 @@ +// 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:?}" + ); +} 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" - ); - } -} 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_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() { 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 57c57ef172..ab67742e0c 100644 --- a/crates/busbar-kernel/src/test_support/mod.rs +++ b/crates/busbar-kernel/src/test_support/mod.rs @@ -2135,6 +2135,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-plane-decisions/src/codec/mod.rs b/crates/busbar-plane-decisions/src/codec/mod.rs index 29ac3a65c7..f8122c99c1 100644 --- a/crates/busbar-plane-decisions/src/codec/mod.rs +++ b/crates/busbar-plane-decisions/src/codec/mod.rs @@ -41,19 +41,41 @@ 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"; -/// Every pointer this plane resolves in a RESPONSE body (`systemone` or `models`). Declared here +/// 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"; + +/// 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] = &[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] = &[]; +pub const RESPONSE_PTRS: &[&str] = &[ + PTR_ERROR, + PTR_REQUEST_ID, + PTR_ID, + 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 +/// 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/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 54ef26f741..244aa1f9de 100644 --- a/crates/busbar-plane-decisions/src/driven.rs +++ b/crates/busbar-plane-decisions/src/driven.rs @@ -13,39 +13,54 @@ //! | `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. //! -//! 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`]) | +//! +//! 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`]). //! -//! 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. +//! 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. //! -//! 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, + 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; @@ -60,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 @@ -70,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"]; @@ -84,19 +109,40 @@ 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 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 @@ -117,21 +163,143 @@ 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 exactly one, none otherwise (see the module doc's measured 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 == 1 { - tail::CLAIMS - } else { - &[] +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 +/// 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 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 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().filter(|(_, v)| serves(v)); + 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. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum PrincipalNeed { @@ -156,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, }) } @@ -207,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, @@ -233,64 +410,343 @@ 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]; + +/// 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 its dialect's [`Usage::counted`]), or none of them. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum Slot { + #[default] + Other, + Error, + Usage, + Count(usize), +} + +/// 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. +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 count lives inside the body -/// (`/usage/units`), so the bytes are also kept until the last piece, and the count is 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 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)] 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 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] { - self.body.extend_from_slice(bytes); + for &b in bytes { + self.byte(b); + } 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. - #[must_use] - pub fn units(&self) -> Option { - if codec::has(&self.body, PTR_ERROR) { - return None; + /// 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, } - 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, - }) } - /// 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). + /// 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, + k if k == self.usage().parent => Slot::Usage, + _ => Slot::Other, + } + } else { + self.usage() + .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 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, + pub fn success(&self, status: Option) -> bool { + 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): + /// 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 (_, members) = self.settled(); + let usage = self.usage(); + let mut whole = [None; 3]; + 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(pointer)), + }; + } + let reported = |class: u32, amount: u64| Units { + class, reported: true, - amount: 1, - }) + amount, + }; + Ok([ + 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)), + ]) } } +/// 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 /// type), the ones naming the hop (`host`), and the credential carriers (the far end is presented /// the member's credential by the kernel, never the caller's). Compared without case. Hop-by-hop @@ -302,23 +758,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/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.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..4c4ab49c36 100644 --- a/crates/busbar-plane-decisions/src/plane_door.rs +++ b/crates/busbar-plane-decisions/src/plane_door.rs @@ -17,18 +17,29 @@ //! * 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. -//! * `arrive`: [`crate::driven::arrive`]; an unclaimed request is refused at 404. +//! ([`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. //! * `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`]). -//! * `refusal`: [`crate::driven::refusal_body`]. +//! 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`]; 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; @@ -44,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}; @@ -72,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. @@ -81,6 +95,16 @@ 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; + +/// [`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"; @@ -105,33 +129,42 @@ 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)]; -const BILLABLE_CLASSES: &[BillableClass] = &[ - BillableClass { - class: abi_str(tail::BILLABLE_CLASSES[0].0.as_str()), - family: abi_str(tail::BILLABLE_CLASSES[0].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)]; + +/// [`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])]; @@ -272,31 +305,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) } } @@ -317,9 +402,16 @@ 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 + /// 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 +419,21 @@ 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 dialect and `upstream_model`. + models: Keyed, + /// 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, } impl DecisionsDoor { - /// Route claimed arrivals over `section`'s one model, or none. - 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, + /// Route claimed arrivals over `routes`. + fn route_over(&self, routes: BTreeMap) { + self.models.with_all(|models| *models = routes); } } @@ -372,7 +457,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)), } @@ -387,13 +476,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(), - routed: Keyed::new(), + models: Keyed::new(), + arrivals: Keyed::new(), + 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 @@ -410,8 +515,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 } @@ -441,9 +550,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 } ); @@ -459,8 +583,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 +605,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, |r| r.dialect == arrived.dialect) + .map(|(name, routed)| (name.to_string(), routed.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 +629,31 @@ slot!( ) })); let unit = input.get().unit; - plane.units.with_all(|units| unit_of(units, unit).caller = caller); + 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 { + 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 } @@ -542,7 +704,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], @@ -550,11 +713,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 @@ -566,7 +736,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()); @@ -576,7 +747,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), @@ -588,8 +759,14 @@ fn attempt_piece( *unit = Unit { 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), + reading: FarEndReading::for_dialect(dialect), ..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); @@ -628,10 +805,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, @@ -651,14 +838,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; } - units.entry(key).or_default() + 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); + } +} + +/// `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!( @@ -671,7 +874,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; @@ -685,7 +891,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), @@ -706,8 +914,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/codec.rs b/crates/busbar-plane-decisions/src/tests/codec.rs index e56cd634af..d5943b1e50 100644 --- a/crates/busbar-plane-decisions/src/tests/codec.rs +++ b/crates/busbar-plane-decisions/src/tests/codec.rs @@ -22,16 +22,25 @@ 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") + ); } +/// 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 4f5e6d64ac..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,16 +30,28 @@ 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); } } +/// 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]; @@ -43,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] @@ -56,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"[..])]); @@ -67,6 +84,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 +108,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 +120,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] @@ -161,12 +255,107 @@ 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 one_or_more_models_mount_the_claim() { + 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 { + 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, |_| 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, |_| true), + Err(Unrouted::Unnamed) + ); + assert_eq!( + route_model(br#"{"model":"zz"}"#, &two, |_| true), + Err(Unrouted::Unknown("zz".to_string())) + ); + assert_eq!( + route_model(br#"{"model":null}"#, &two, |_| true), + Err(Unrouted::NotAName) + ); + assert_eq!( + 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, |_| true), + Err(Unrouted::Unnamed) + ); + + let one = models(&["jev"]); + assert_eq!( + 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(br#"{"model":"other"}"#, &one, |_| true), + Err(Unrouted::Unknown("other".to_string())) + ); + assert_eq!( + route_model(br#"{}"#, &models(&[]), |_| true), + 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 only_exactly_one_model_mounts_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()); +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 @@ -175,18 +364,21 @@ fn only_exactly_one_model_mounts_the_claim() { 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, @@ -203,8 +395,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()), @@ -213,3 +408,176 @@ 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)) + ); +} + +/// 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 4fdabafd06..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"); @@ -30,8 +85,8 @@ 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() { - let one = snapshot_spec(1); +fn one_or_more_models_claim_systemone_and_none_claims_nothing() { + let one = snapshot_spec(&[tail::DIALECT_JEV]); assert_eq!( one.claims, vec![ClaimSpec::new( @@ -44,8 +99,13 @@ 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(&[]).claims.is_empty(), + "no model claims nothing" + ); + for models in [2, 3] { + let jev = vec![tail::DIALECT_JEV; models]; + assert_eq!(snapshot_spec(&jev).claims, one.claims, "{models} models"); } } @@ -54,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/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..f38bfd2135 --- /dev/null +++ b/crates/busbar-plane-decisions/tests/door.rs @@ -0,0 +1,367 @@ +// 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); +} + +/// 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:?}"); + } +} + +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 new file mode 100644 index 0000000000..a99d20c939 --- /dev/null +++ b/crates/busbar-plane-decisions/tests/door_harness/mod.rs @@ -0,0 +1,545 @@ +// 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, 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, 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; + +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 { + 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 { + 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() + }; + (i.providers, i.providers_len) = (facts.as_ptr(), facts.len()); + 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), + } +} + +/// `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 { + 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], + /// The dialect `arrive` answered, carried on every piece. + pub dialect: u32, +} + +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"", + dialect: 0, + } + } + + /// 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, + dialect: 0, + } + } + + /// 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, + dialect: 0, + } + } + + /// 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, + dialect: 0, + } + } +} + +/// 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; + i.dialect = given.dialect; + 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 + } + + /// 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.dialect = given.dialect; + 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) { + 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/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/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.rs b/crates/busbar/src/root/tests/linked.rs index 70c62fa088..56382befbb 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 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(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(); 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(), 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/crates/busbar/tests/cli_validate.rs b/crates/busbar/tests/cli_validate.rs index ca6082bd0e..a3a73d0a20 100644 --- a/crates/busbar/tests/cli_validate.rs +++ b/crates/busbar/tests/cli_validate.rs @@ -1517,10 +1517,10 @@ 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 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"); @@ -1541,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(), - 1, - "the decision plane speaks one dialect: {stderr}" - ); + assert_eq!(dialects.len(), 2, "the plane speaks two dialects: {stderr}"); dialects[0].to_string() } @@ -1570,6 +1566,24 @@ fn validate_ok_on_a_good_decisions_config() { let _ = std::fs::remove_dir_all(&dir); } +/// 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_config_in_the_planes_second_dialect() { + let dir = fixture_dir("plane-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 config in the second dialect 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/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"}"#); 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/qa/kind-isolation.toml b/qa/kind-isolation.toml index 9af9420263..1e97362c49 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" @@ -1787,8 +1776,8 @@ cite = "measured on predev f830faa691 (ARCHITECT 2026-10-07, Law 9); the drain [[law0]] crate = "busbar-kernel" axis = "instance:secret" -count = "115" -cite = "measured on predev f830faa691 (ARCHITECT 2026-10-07, Law 9); the drain is THE DESIGN §1 / DoD item 2: core names no instance. -2 for R-FIX3 (the kernel's plugin tests move onto in-crate doubles)" +count = "114" +cite = "measured on predev f830faa691 (ARCHITECT 2026-10-07, Law 9); the drain is THE DESIGN §1 / DoD item 2: core names no instance. -2 for R-FIX3 (the kernel's plugin tests move onto in-crate doubles); lowered 115 -> 114 by the deletion of the kernel's dead ask-state seal (audit K2-H3), the gate's measure on this branch merged with predev 1617e9f019" [[law0]] crate = "busbar-kernel" @@ -1799,8 +1788,8 @@ cite = "measured on predev f830faa691 (ARCHITECT 2026-10-07, Law 9); the drain [[law0]] crate = "busbar-kernel" axis = "plane" -count = "1132" -cite = "measured on predev f830faa691 (ARCHITECT 2026-10-07, Law 9); the drain is THE DESIGN §1 / DoD item 2: core names no instance; LOWERED 1193 -> 1175, predev 9c0a394771's measure: the drain landed without giving its row back (Law 9: the ceiling ratchets down); LOWERED 1175 -> 1173, the merge-base d7929b661d's measure, which GetBusbar/busbar#570 holds (its scrape double spells no plane alias) (Law 9: the ceiling ratchets down to today's number); LOWERED 1173 -> 1172, the gate's measure on this branch merged with predev 1617e9f019: Q128 A4's plane_driver/gated.rs drain; REBALANCED 1172 -> 1168 (Q128, PR #686 architect ruling, BUSBAR-1.6.0.md l.475-483): the cross-plane cells moved verbatim into the composition root's crates/busbar/tests/, which takes the same 4 hits (busbar × plane 822 -> 826); LOWERED 1168 -> 1141 by row 157: the hits drained with the deleted HOT trust and guard slots (plane_host/trust.rs, plane_host/guard.rs); LOWERED 1141 -> 1132 by row 156: the hits drained with the deleted kernel call log (calllog.rs, plane/calllog.rs)" +count = "1128" +cite = "measured on predev f830faa691 (ARCHITECT 2026-10-07, Law 9); the drain is THE DESIGN §1 / DoD item 2: core names no instance; LOWERED 1193 -> 1175, predev 9c0a394771's measure: the drain landed without giving its row back (Law 9: the ceiling ratchets down); LOWERED 1175 -> 1173, the merge-base d7929b661d's measure, which GetBusbar/busbar#570 holds (its scrape double spells no plane alias) (Law 9: the ceiling ratchets down to today's number); LOWERED 1173 -> 1172, the gate's measure on this branch merged with predev 1617e9f019: Q128 A4's plane_driver/gated.rs drain; REBALANCED 1172 -> 1168 (Q128, PR #686 architect ruling, BUSBAR-1.6.0.md l.475-483): the cross-plane cells moved verbatim into the composition root's crates/busbar/tests/, which takes the same 4 hits (busbar × plane 822 -> 826); LOWERED 1168 -> 1141 by row 157: the hits drained with the deleted HOT trust and guard slots (plane_host/trust.rs, plane_host/guard.rs); LOWERED 1141 -> 1132 by row 156: the hits drained with the deleted kernel call log (calllog.rs, plane/calllog.rs); LOWERED 1132 -> 1128 by this PR: the deletion of the kernel's dead ask-state seal (audit K2-H3) removes four hits, its two `crate::mcp::roots` doc links and its two `\\x6dcp` seal domains" [[law0]] crate = "busbar-kernel" @@ -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" 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/accepted-differences.json b/testing/shadow-oracle/accepted-differences.json index 2205196a65..cf3905f084 100644 --- a/testing/shadow-oracle/accepted-differences.json +++ b/testing/shadow-oracle/accepted-differences.json @@ -2248,11 +2248,11 @@ "changelog": "1.6.0 Changed: a streamed answer whose upstream connection fails after its first byte is billed for the tokens that streamed before the failure. 1.5.5 billed no tokens for it. A mid-stream cut is not a refund.", "by": "owner (Matthew Jackson), BUSBAR-1.6.0.md Part 2 #62 (OWNER-LOCKED 2026-09-20)", "cells": "^llm\\|[a-z]+\\|[a-z]+\\|request\\|stream_upstream_cut$", - "expected_cells": 0, + "expected_cells": 1, "classes": [ "effects.usage" ], - "rationale": "v1.5.5 crates/busbar/src/proxy/response_body.rs:279-283 set `stream_failed` on an upstream transport error after the first byte of a streamed answer, which suppressed the Drop-time token billing: the caller was billed no tokens for what had streamed (the request fee and, on a streamed answer, the request-budget unit were kept). Spec Part 2 #62 (OWNER-LOCKED 2026-09-20, BUSBAR-1.6.0.md l.2252: a stream cut off mid-answer, breaker and timeout named among the causes, bills for what was actually delivered up to the cut; the plane reports the units actually streamed) governs 1.6.0: the kernel's far end marks a cut answer's last piece PIECE_CUT (status row 155, ARCHITECT 2026-10-13), and the llm plane ends the caller's answer in 1.5.5's in-band error and reports the units its stream stated to the caller up to the cut; units the far end produced that never reached the caller are not counted. The request fee and the budget unit are unchanged from 1.5.5. A buffered answer cut mid-body is unchanged: the caller's 502, no tokens. No oracle cell drops an upstream connection after the first byte of a stream: the cells are OWED, `cells` names them and expected_cells is 0, the way the client-cancel entry above writes its owed cell. The premise probes the existing streamed failover cell.", + "rationale": "v1.5.5 crates/busbar/src/proxy/response_body.rs:279-283 set `stream_failed` on an upstream transport error after the first byte of a streamed answer, which suppressed the Drop-time token billing: the caller was billed no tokens for what had streamed (the request fee and, on a streamed answer, the request-budget unit were kept). Spec Part 2 #62 (OWNER-LOCKED 2026-09-20, BUSBAR-1.6.0.md l.2252: a stream cut off mid-answer, breaker and timeout named among the causes, bills for what was actually delivered up to the cut; the plane reports the units actually streamed) governs 1.6.0: the kernel's far end marks a cut answer's last piece PIECE_CUT (status row 155, ARCHITECT 2026-10-13), and the llm plane ends the caller's answer in 1.5.5's in-band error and reports the units its stream stated to the caller up to the cut; units the far end produced that never reached the caller are not counted. The request fee and the budget unit are unchanged from 1.5.5. A buffered answer cut mid-body is unchanged: the caller's 502, no tokens. The six `llm|||request|stream_upstream_cut` cells drop the upstream connection after the first event of a stream (busbar-release #352; golden-add run 38488778377). One diverges: anthropic's first event states 11 input tokens, which 1.6.0 bills and 1.5.5 did not. The other five state no usage before the cut (gemini's first frame states its whole usage, which 1.5.5 billed too), so their usage is 1.5.5's. The premise probes the existing streamed failover cell. The signed line, BUSBAR-1.6.0.md l.2252 (Part 2 #62), verbatim: \"A mid-stream cut is NOT a refund — the customer pays for what actually streamed. OWNER-LOCKED 2026-09-20 (\"LOCKED AGREED\").\" and \"busbar bills for what was actually delivered up to the cut — it does not refund or zero the charge.\"", "premise": { "cell": "route.failover|fb|member-down-stream-openai", "status": 200 diff --git a/testing/shadow-oracle/cells.json b/testing/shadow-oracle/cells.json index c9d1fcefe8..a818f7cf4c 100644 --- a/testing/shadow-oracle/cells.json +++ b/testing/shadow-oracle/cells.json @@ -55,11 +55,11 @@ } ], "counts": { - "total": 2518, + "total": 2525, "by_plane": { "a2a": 468, - "core": 989, - "llm": 149, + "core": 990, + "llm": 155, "mcp": 912 }, "by_family": { @@ -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, @@ -87,7 +87,7 @@ "hooks": 11, "http.crosscut": 23, "ledger": 5, - "llm.wire": 142, + "llm.wire": 148, "mcp": 913, "money.reserved-tier": 2, "neutrality": 22, @@ -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", @@ -23046,6 +23057,23 @@ "outcome": "over_budget_total", "why": "group BUDGET total already exhausted, no per-day request cap on the bucket -> refused at Admit (native 429, metric: budget, names the bucket) -- distinct from `over_budget`'s requests-cap refusal; the Anthropic-dialect projection is `billing_error`, not `rate_limit_error`" }, + { + "id": "llm|anthropic|anthropic|request|stream_upstream_cut", + "plane": "llm", + "family": "llm.wire", + "ingress_dialect": "anthropic", + "egress_dialect": "anthropic", + "cross_protocol": false, + "transport": "http", + "op": "chat", + "outcome": "stream_upstream_cut", + "why": "the upstream connection drops after the FIRST event of a streamed answer (the mock's `cut`: the head and the first frame, then the socket closes; bedrock's eventstream, which has no blank-line frame, half its body): what the caller receives after its first byte, and what is billed for what streamed. A fresh boot: the cut records a breaker failure on the lane.", + "fresh": true, + "stream": true, + "mock_control": { + "cut": true + } + }, { "id": "llm|anthropic|anthropic|request|stream_upstream_error", "plane": "llm", @@ -23382,6 +23410,23 @@ "outcome": "over_budget_total", "why": "group BUDGET total already exhausted, no per-day request cap on the bucket -> refused at Admit (native 429, metric: budget, names the bucket) -- distinct from `over_budget`'s requests-cap refusal; the Anthropic-dialect projection is `billing_error`, not `rate_limit_error`" }, + { + "id": "llm|bedrock|bedrock|request|stream_upstream_cut", + "plane": "llm", + "family": "llm.wire", + "ingress_dialect": "bedrock", + "egress_dialect": "bedrock", + "cross_protocol": false, + "transport": "http", + "op": "chat", + "outcome": "stream_upstream_cut", + "why": "the upstream connection drops after the FIRST event of a streamed answer (the mock's `cut`: the head and the first frame, then the socket closes; bedrock's eventstream, which has no blank-line frame, half its body): what the caller receives after its first byte, and what is billed for what streamed. A fresh boot: the cut records a breaker failure on the lane.", + "fresh": true, + "stream": true, + "mock_control": { + "cut": true + } + }, { "id": "llm|bedrock|bedrock|request|stream_upstream_error", "plane": "llm", @@ -23705,6 +23750,23 @@ "outcome": "over_budget_total", "why": "group BUDGET total already exhausted, no per-day request cap on the bucket -> refused at Admit (native 429, metric: budget, names the bucket) -- distinct from `over_budget`'s requests-cap refusal; the Anthropic-dialect projection is `billing_error`, not `rate_limit_error`" }, + { + "id": "llm|cohere|cohere|request|stream_upstream_cut", + "plane": "llm", + "family": "llm.wire", + "ingress_dialect": "cohere", + "egress_dialect": "cohere", + "cross_protocol": false, + "transport": "http", + "op": "chat", + "outcome": "stream_upstream_cut", + "why": "the upstream connection drops after the FIRST event of a streamed answer (the mock's `cut`: the head and the first frame, then the socket closes; bedrock's eventstream, which has no blank-line frame, half its body): what the caller receives after its first byte, and what is billed for what streamed. A fresh boot: the cut records a breaker failure on the lane.", + "fresh": true, + "stream": true, + "mock_control": { + "cut": true + } + }, { "id": "llm|cohere|cohere|request|stream_upstream_error", "plane": "llm", @@ -24041,6 +24103,23 @@ "outcome": "over_budget_total", "why": "group BUDGET total already exhausted, no per-day request cap on the bucket -> refused at Admit (native 429, metric: budget, names the bucket) -- distinct from `over_budget`'s requests-cap refusal; the Anthropic-dialect projection is `billing_error`, not `rate_limit_error`" }, + { + "id": "llm|gemini|gemini|request|stream_upstream_cut", + "plane": "llm", + "family": "llm.wire", + "ingress_dialect": "gemini", + "egress_dialect": "gemini", + "cross_protocol": false, + "transport": "http", + "op": "chat", + "outcome": "stream_upstream_cut", + "why": "the upstream connection drops after the FIRST event of a streamed answer (the mock's `cut`: the head and the first frame, then the socket closes; bedrock's eventstream, which has no blank-line frame, half its body): what the caller receives after its first byte, and what is billed for what streamed. A fresh boot: the cut records a breaker failure on the lane.", + "fresh": true, + "stream": true, + "mock_control": { + "cut": true + } + }, { "id": "llm|gemini|gemini|request|stream_upstream_error", "plane": "llm", @@ -24364,6 +24443,23 @@ "outcome": "over_budget_total", "why": "group BUDGET total already exhausted, no per-day request cap on the bucket -> refused at Admit (native 429, metric: budget, names the bucket) -- distinct from `over_budget`'s requests-cap refusal; the Anthropic-dialect projection is `billing_error`, not `rate_limit_error`" }, + { + "id": "llm|openai|openai|request|stream_upstream_cut", + "plane": "llm", + "family": "llm.wire", + "ingress_dialect": "openai", + "egress_dialect": "openai", + "cross_protocol": false, + "transport": "http", + "op": "chat", + "outcome": "stream_upstream_cut", + "why": "the upstream connection drops after the FIRST event of a streamed answer (the mock's `cut`: the head and the first frame, then the socket closes; bedrock's eventstream, which has no blank-line frame, half its body): what the caller receives after its first byte, and what is billed for what streamed. A fresh boot: the cut records a breaker failure on the lane.", + "fresh": true, + "stream": true, + "mock_control": { + "cut": true + } + }, { "id": "llm|openai|openai|request|stream_upstream_error", "plane": "llm", @@ -24703,6 +24799,23 @@ "outcome": "over_budget_total", "why": "group BUDGET total already exhausted, no per-day request cap on the bucket -> refused at Admit (native 429, metric: budget, names the bucket) -- distinct from `over_budget`'s requests-cap refusal; the Anthropic-dialect projection is `billing_error`, not `rate_limit_error`" }, + { + "id": "llm|responses|responses|request|stream_upstream_cut", + "plane": "llm", + "family": "llm.wire", + "ingress_dialect": "responses", + "egress_dialect": "responses", + "cross_protocol": false, + "transport": "http", + "op": "chat", + "outcome": "stream_upstream_cut", + "why": "the upstream connection drops after the FIRST event of a streamed answer (the mock's `cut`: the head and the first frame, then the socket closes; bedrock's eventstream, which has no blank-line frame, half its body): what the caller receives after its first byte, and what is billed for what streamed. A fresh boot: the cut records a breaker failure on the lane.", + "fresh": true, + "stream": true, + "mock_control": { + "cut": true + } + }, { "id": "llm|responses|responses|request|stream_upstream_error", "plane": "llm", diff --git a/testing/shadow-oracle/golden/1.5.5/cells/billing__budget__downgrade.json b/testing/shadow-oracle/golden/1.5.5/cells/billing__budget__downgrade.json new file mode 100644 index 0000000000..f352388e9b --- /dev/null +++ b/testing/shadow-oracle/golden/1.5.5/cells/billing__budget__downgrade.json @@ -0,0 +1 @@ +{"applied":["audit.hash"],"body":{"json":{"counters":["busbar_requests_total{ingress_protocol=\"openai\",pool=\"frontier\",outcome=\"ok\"} 2","busbar_requests_total{ingress_protocol=\"openai\",pool=\"value\",outcome=\"ok\"} 1","busbar_upstream_attempts_total{pool=\"frontier\",lane=\"m-frontier\"} 2","busbar_upstream_attempts_total{pool=\"value\",lane=\"m-value\"} 1"],"downgraded_body":{"choices":[{"finish_reason":"stop","index":0,"logprobs":null,"message":{"content":"oracle-marker","refusal":null,"role":"assistant"}}],"model":"m-value","object":"chat.completion","usage":{"completion_tokens":7,"prompt_tokens":11,"total_tokens":18}},"downgraded_status":"200","first_status":"200","frontier_upstream_requests":"2","group_usage":{"buckets":[{"budget_cap":25,"budget_remaining_cents":5,"pool":"frontier","requests":2,"spend_cents":20,"tokens":36,"window":"day"}],"enabled":true,"group":"oracle"},"key_usage":{"budget_period":"total","group":"oracle","id":"vk_","rate_headroom":null,"requests":3,"spend_cents":30,"tokens":54,"window_start":0},"mint_status":"201","second_status":"200","value_upstream_requests":"1"}},"effects":{"counters":["busbar_requests_total{ingress_protocol=\"openai\",pool=\"frontier\",outcome=\"ok\"} 2","busbar_requests_total{ingress_protocol=\"openai\",pool=\"value\",outcome=\"ok\"} 1","busbar_upstream_attempts_total{pool=\"frontier\",lane=\"m-frontier\"} 2","busbar_upstream_attempts_total{pool=\"value\",lane=\"m-value\"} 1"],"downgraded_body":{"choices":[{"finish_reason":"stop","index":0,"logprobs":null,"message":{"content":"oracle-marker","refusal":null,"role":"assistant"}}],"model":"m-value","object":"chat.completion","usage":{"completion_tokens":7,"prompt_tokens":11,"total_tokens":18}},"downgraded_status":"200","first_status":"200","frontier_upstream_requests":"2","group_usage":{"buckets":[{"budget_cap":25,"budget_remaining_cents":5,"pool":"frontier","requests":2,"spend_cents":20,"tokens":36,"window":"day"}],"enabled":true,"group":"oracle"},"key_usage":{"budget_period":"total","group":"oracle","id":"vk_","rate_headroom":null,"requests":3,"spend_cents":30,"tokens":54,"window_start":0},"mint_status":"201","second_status":"200","value_upstream_requests":"1"},"headers":{},"status":200} diff --git a/testing/shadow-oracle/golden/1.5.5/cells/llm__anthropic__anthropic__request__stream_upstream_cut.json b/testing/shadow-oracle/golden/1.5.5/cells/llm__anthropic__anthropic__request__stream_upstream_cut.json new file mode 100644 index 0000000000..a9d3643cf8 --- /dev/null +++ b/testing/shadow-oracle/golden/1.5.5/cells/llm__anthropic__anthropic__request__stream_upstream_cut.json @@ -0,0 +1 @@ +{"applied":["egress.body","egress.cred","egress.elapsed","egress.host","hdr.date","id.wire","metrics.timing"],"body":{"text":"event: message_start\ndata: {\"message\":{\"container\":null,\"content\":[],\"id\":\"msg_oracle\",\"model\":\"m-anthropic\",\"role\":\"assistant\",\"stop_details\":null,\"stop_reason\":null,\"stop_sequence\":null,\"type\":\"message\",\"usage\":{\"cache_creation\":null,\"cache_creation_input_tokens\":null,\"cache_read_input_tokens\":null,\"inference_geo\":null,\"input_tokens\":11,\"output_tokens\":0,\"output_tokens_details\":null,\"server_tool_use\":null,\"service_tier\":null}},\"type\":\"message_start\"}\n\nevent: error\ndata: {\"error\":{\"message\":\"The response stream was interrupted.\",\"type\":\"The response stream was interrupted.\"},\"type\":\"error\"}\n\n"},"effects":{"audit":{"added":0,"items":[]},"egress":[{"body":{"max_tokens":64,"messages":[{"content":"ping","role":"user"}],"model":"m-anthropic","stream":true},"headers":{"accept":"text/event-stream","anthropic-version":"2023-06-01","content-length":"99","content-type":"application/json","host":"127.0.0.1:","user-agent":"Anthropic/Python 0.39.0","x-api-key":""},"method":"POST","path":"/v1/messages","response":{"elapsed":"<1s","retry_after":null,"status":200}}],"metrics":{"busbar_lane_available{pool=\"m-anthropic\",lane=\"m-anthropic\"}":-1,"busbar_lane_state{pool=\"m-anthropic\",lane=\"m-anthropic\"}":1,"busbar_request_duration_seconds_count{ingress_protocol=\"anthropic\",pool=\"m-anthropic\"}":1,"busbar_requests_total{ingress_protocol=\"anthropic\",pool=\"m-anthropic\",outcome=\"ok\"}":1,"busbar_upstream_attempts_total{pool=\"m-anthropic\",lane=\"m-anthropic\"}":1},"usage":{"requests":1}},"headers":{"content-type":"text/event-stream","request-id":"req_","transfer-encoding":"chunked"},"status":200} diff --git a/testing/shadow-oracle/golden/1.5.5/cells/llm__bedrock__bedrock__request__stream_upstream_cut.json b/testing/shadow-oracle/golden/1.5.5/cells/llm__bedrock__bedrock__request__stream_upstream_cut.json new file mode 100644 index 0000000000..15b1d5f6c2 --- /dev/null +++ b/testing/shadow-oracle/golden/1.5.5/cells/llm__bedrock__bedrock__request__stream_upstream_cut.json @@ -0,0 +1 @@ +{"applied":["audit.hash","egress.body","egress.elapsed","egress.host","eventstream.frames","hdr.date","metrics.timing"],"body":{"eventstream":[["messageStart",{"role":"assistant"}],["contentBlockDelta",{"contentBlockIndex":0,"delta":{"text":"oracle-marker"}}],["InternalServerException",{"message":"The response stream was interrupted."}]]},"effects":{"audit":{"added":0,"items":[]},"egress":[{"body":{"messages":[{"content":[{"text":"ping"}],"role":"user"}]},"headers":{"accept":"application/vnd.amazon.eventstream","content-length":"58","content-type":"application/json","host":"127.0.0.1:","user-agent":"Boto3/1.35.0 md/Botocore#1.35.0"},"method":"POST","path":"/model/m-bedrock/converse-stream","response":{"elapsed":"<1s","retry_after":null,"status":200}}],"metrics":{"busbar_lane_available{pool=\"m-bedrock\",lane=\"m-bedrock\"}":-1,"busbar_lane_state{pool=\"m-bedrock\",lane=\"m-bedrock\"}":2,"busbar_request_duration_seconds_count{ingress_protocol=\"bedrock\",pool=\"m-bedrock\"}":1,"busbar_requests_total{ingress_protocol=\"bedrock\",pool=\"m-bedrock\",outcome=\"ok\"}":1,"busbar_upstream_attempts_total{pool=\"m-bedrock\",lane=\"m-bedrock\"}":1},"usage":{"requests":1}},"headers":{"content-type":"application/vnd.amazon.eventstream","transfer-encoding":"chunked","x-amzn-requestid":""},"status":200} diff --git a/testing/shadow-oracle/golden/1.5.5/cells/llm__cohere__cohere__request__stream_upstream_cut.json b/testing/shadow-oracle/golden/1.5.5/cells/llm__cohere__cohere__request__stream_upstream_cut.json new file mode 100644 index 0000000000..de9b2acdba --- /dev/null +++ b/testing/shadow-oracle/golden/1.5.5/cells/llm__cohere__cohere__request__stream_upstream_cut.json @@ -0,0 +1 @@ +{"applied":["egress.body","egress.cred","egress.elapsed","egress.host","hdr.date","metrics.timing"],"body":{"text":"data: {\"delta\":{\"message\":{\"content\":[],\"role\":\"assistant\"}},\"id\":\"cohere-oracle\",\"type\":\"message-start\"}\n\ndata: {\"delta\":{\"finish_reason\":\"ERROR\",\"usage\":{\"tokens\":{\"input_tokens\":0,\"output_tokens\":0}}},\"type\":\"message-end\"}\n\n"},"effects":{"audit":{"added":0,"items":[]},"egress":[{"body":{"messages":[{"content":"ping","role":"user"}],"model":"m-cohere","stream":true},"headers":{"accept":"text/event-stream","authorization":"","content-length":"80","content-type":"application/json","host":"127.0.0.1:","user-agent":"cohere-python/5.11.0"},"method":"POST","path":"/v2/chat","response":{"elapsed":"<1s","retry_after":null,"status":200}}],"metrics":{"busbar_lane_available{pool=\"m-cohere\",lane=\"m-cohere\"}":-1,"busbar_lane_state{pool=\"m-cohere\",lane=\"m-cohere\"}":1,"busbar_request_duration_seconds_count{ingress_protocol=\"cohere\",pool=\"m-cohere\"}":1,"busbar_requests_total{ingress_protocol=\"cohere\",pool=\"m-cohere\",outcome=\"ok\"}":1,"busbar_upstream_attempts_total{pool=\"m-cohere\",lane=\"m-cohere\"}":1},"usage":{"requests":1}},"headers":{"content-type":"text/event-stream","transfer-encoding":"chunked"},"status":200} diff --git a/testing/shadow-oracle/golden/1.5.5/cells/llm__gemini__gemini__request__stream_upstream_cut.json b/testing/shadow-oracle/golden/1.5.5/cells/llm__gemini__gemini__request__stream_upstream_cut.json new file mode 100644 index 0000000000..ab896b7f5e --- /dev/null +++ b/testing/shadow-oracle/golden/1.5.5/cells/llm__gemini__gemini__request__stream_upstream_cut.json @@ -0,0 +1 @@ +{"applied":["egress.body","egress.cred","egress.elapsed","egress.host","hdr.date","key.id","metrics.timing"],"body":{"text":"data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"oracle-marker\"}],\"role\":\"model\"},\"finishReason\":\"STOP\",\"index\":0}],\"modelVersion\":\"m-gemini\",\"usageMetadata\":{\"candidatesTokenCount\":7,\"promptTokenCount\":11,\"totalTokenCount\":18}}\n\n"},"effects":{"audit":{"added":0,"items":[]},"egress":[{"body":{"contents":[{"parts":[{"text":"ping"}],"role":"user"}]},"headers":{"accept":"text/event-stream","content-length":"56","content-type":"application/json","host":"127.0.0.1:","user-agent":"google-genai-sdk/0.8.0 gl-python/3.11","x-goog-api-key":""},"method":"POST","path":"/v1beta/models/m-gemini%3AstreamGenerateContent","response":{"elapsed":"<1s","retry_after":null,"status":200}}],"metrics":{"busbar_bucket_budget_remaining_cents{bucket=\"group:oracle@day\",group=\"oracle\",window=\"day\"}":-250,"busbar_bucket_spend_cents{bucket=\"group:oracle@day\",group=\"oracle\",window=\"day\"}":250,"busbar_bucket_tokens{bucket=\"\",model=\"m-gemini\",tier=\"input\"}":11,"busbar_bucket_tokens{bucket=\"\",model=\"m-gemini\",tier=\"output\"}":7,"busbar_bucket_tokens{bucket=\"group:oracle@day\",group=\"oracle\",window=\"day\",model=\"m-gemini\",tier=\"input\"}":11,"busbar_bucket_tokens{bucket=\"group:oracle@day\",group=\"oracle\",window=\"day\",model=\"m-gemini\",tier=\"output\"}":7,"busbar_key_spend_cents{key=\"\"}":250,"busbar_key_tokens_total{key=\"\"}":18,"busbar_request_duration_seconds_count{ingress_protocol=\"gemini\",pool=\"m-gemini\"}":1,"busbar_requests_total{ingress_protocol=\"gemini\",pool=\"m-gemini\",outcome=\"ok\"}":1,"busbar_upstream_attempts_total{pool=\"m-gemini\",lane=\"m-gemini\"}":1},"usage":{"requests":1,"spend_cents":250,"tokens":18}},"headers":{"content-type":"text/event-stream","transfer-encoding":"chunked"},"status":200} diff --git a/testing/shadow-oracle/golden/1.5.5/cells/llm__openai__openai__request__stream_upstream_cut.json b/testing/shadow-oracle/golden/1.5.5/cells/llm__openai__openai__request__stream_upstream_cut.json new file mode 100644 index 0000000000..2a13775410 --- /dev/null +++ b/testing/shadow-oracle/golden/1.5.5/cells/llm__openai__openai__request__stream_upstream_cut.json @@ -0,0 +1 @@ +{"applied":["egress.body","egress.cred","egress.elapsed","egress.host","hdr.date","metrics.timing","ts.unix"],"body":{"text":"data: {\"choices\":[{\"delta\":{\"content\":\"oracle-marker\",\"role\":\"assistant\"},\"finish_reason\":null,\"index\":0}],\"created\":0,\"id\":\"chatcmpl-oracle\",\"model\":\"m-openai-chat\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"error\":{\"code\":null,\"message\":\"The response stream was interrupted.\",\"param\":null,\"type\":\"server_error\"}}\n\n"},"effects":{"audit":{"added":0,"items":[]},"egress":[{"body":{"messages":[{"content":"ping","role":"user"}],"model":"m-openai-chat","stream":true,"stream_options":{"include_usage":true}},"headers":{"accept":"text/event-stream","authorization":"","content-length":"125","content-type":"application/json","host":"127.0.0.1:","user-agent":"OpenAI/Python 1.54.0"},"method":"POST","path":"/v1/chat/completions","response":{"elapsed":"<1s","retry_after":null,"status":200}}],"metrics":{"busbar_lane_available{pool=\"m-openai-chat\",lane=\"m-openai-chat\"}":-1,"busbar_lane_state{pool=\"m-openai-chat\",lane=\"m-openai-chat\"}":1,"busbar_request_duration_seconds_count{ingress_protocol=\"openai\",pool=\"m-openai-chat\"}":1,"busbar_requests_total{ingress_protocol=\"openai\",pool=\"m-openai-chat\",outcome=\"ok\"}":1,"busbar_upstream_attempts_total{pool=\"m-openai-chat\",lane=\"m-openai-chat\"}":1},"usage":{"requests":1}},"headers":{"content-type":"text/event-stream","transfer-encoding":"chunked"},"status":200} diff --git a/testing/shadow-oracle/golden/1.5.5/cells/llm__responses__responses__request__stream_upstream_cut.json b/testing/shadow-oracle/golden/1.5.5/cells/llm__responses__responses__request__stream_upstream_cut.json new file mode 100644 index 0000000000..599992f3ce --- /dev/null +++ b/testing/shadow-oracle/golden/1.5.5/cells/llm__responses__responses__request__stream_upstream_cut.json @@ -0,0 +1 @@ +{"applied":["egress.body","egress.cred","egress.elapsed","egress.host","hdr.date","id.wire","metrics.timing","ts.unix"],"body":{"text":"data: {\"response\":{\"id\":\"resp_oracle\",\"model\":\"m-openai-responses\",\"object\":\"response\",\"status\":\"in_progress\"},\"sequence_number\":0,\"type\":\"response.created\"}\n\nevent: response.failed\ndata: {\"response\":{\"created_at\":0,\"error\":{\"code\":\"server_error\",\"message\":\"The response stream was interrupted.\"},\"id\":\"resp_\",\"model\":\"gpt-4o\",\"object\":\"response\",\"output\":[],\"status\":\"failed\"},\"sequence_number\":0,\"type\":\"response.failed\"}\n\n"},"effects":{"audit":{"added":0,"items":[]},"egress":[{"body":{"input":"ping","model":"m-openai-responses","stream":true},"headers":{"accept":"text/event-stream","authorization":"","content-length":"59","content-type":"application/json","host":"127.0.0.1:","user-agent":"OpenAI/Python 1.54.0"},"method":"POST","path":"/v1/responses","response":{"elapsed":"<1s","retry_after":null,"status":200}}],"metrics":{"busbar_lane_available{pool=\"m-openai-responses\",lane=\"m-openai-responses\"}":-1,"busbar_lane_state{pool=\"m-openai-responses\",lane=\"m-openai-responses\"}":2,"busbar_request_duration_seconds_count{ingress_protocol=\"responses\",pool=\"m-openai-responses\"}":1,"busbar_requests_total{ingress_protocol=\"responses\",pool=\"m-openai-responses\",outcome=\"ok\"}":1,"busbar_upstream_attempts_total{pool=\"m-openai-responses\",lane=\"m-openai-responses\"}":1},"usage":{"requests":1}},"headers":{"content-type":"text/event-stream","transfer-encoding":"chunked"},"status":200} diff --git a/testing/shadow-oracle/golden/1.5.5/ledger.tsv b/testing/shadow-oracle/golden/1.5.5/ledger.tsv index 9e16a38c99..d6aa100f3e 100644 --- a/testing/shadow-oracle/golden/1.5.5/ledger.tsv +++ b/testing/shadow-oracle/golden/1.5.5/ledger.tsv @@ -2414,3 +2414,10 @@ boot.refusal|BOOT-103|validate PASS exit 1; ops.scrape|reload|sink-removed PASS script scrape-after-reload.sh: status 200 plugins.abi|json-contract|store-sqlite@1.5-line PASS capture neutrality|models-empty|surface PASS capture +billing|budget|downgrade PASS script budget-downgrade.sh: status 200 +llm|anthropic|anthropic|request|stream_upstream_cut PASS HTTP 200; usage Δ {"requests":1} +llm|bedrock|bedrock|request|stream_upstream_cut PASS HTTP 200; usage Δ {"requests":1} +llm|cohere|cohere|request|stream_upstream_cut PASS HTTP 200; usage Δ {"requests":1} +llm|gemini|gemini|request|stream_upstream_cut PASS HTTP 200; usage Δ {"requests":1,"spend_cents":250,"tokens":18} +llm|openai|openai|request|stream_upstream_cut PASS HTTP 200; usage Δ {"requests":1} +llm|responses|responses|request|stream_upstream_cut PASS HTTP 200; usage Δ {"requests":1} diff --git a/testing/shadow-oracle/golden/1.5.5/meta.json b/testing/shadow-oracle/golden/1.5.5/meta.json index 4bc848dd67..0f557a9fd8 100644 --- a/testing/shadow-oracle/golden/1.5.5/meta.json +++ b/testing/shadow-oracle/golden/1.5.5/meta.json @@ -1,7 +1,7 @@ { "binary": "/1.5.5/busbar", "version": "busbar 1.5.5", - "recorded": 1015, + "recorded": 1022, "binary_sha256": "84bde0a047fe368b661e9f6f14b2916390c40b71a6d91cdff1dbfa350aaf80e7", "harness_rev": "b3a611c252825abced4537b654f91ed02dae9d627c7625b16327a5f5c09645be", "host_triple": "x86_64-unknown-linux-gnu", diff --git a/testing/shadow-oracle/oracle-rust.pin b/testing/shadow-oracle/oracle-rust.pin index 7ab3540d6e..7a98c3c91b 100644 --- a/testing/shadow-oracle/oracle-rust.pin +++ b/testing/shadow-oracle/oracle-rust.pin @@ -330,11 +330,24 @@ # (red, never a fallback to another architecture's). The aarch64 asset is the oracle built from # release_ref on the aarch64 prover (release profile, LTO off, --locked: as dev-bin.yml builds it). # +# 2026-10-13 (ARCHITECT ruling, busbar status row 155; busbar #833): moved 7ab21912d664 -> db64d9218d8b, +# busbar-release main after the qa -> main promotion (#351), which carries #348 (277a9ef): the corpus generator +# adds the literal script cell billing|budget|downgrade (scripts/budget-downgrade.sh), whose cells.json row, +# owed-baseline row and golden (busbar-release oracle-rerecord run 38471635786, recorded from the published +# 1.5.5) land with this commit. The digests are dev-bin's busbar-oracle-linux--.sha256. +# +# 2026-10-13 (ARCHITECT ruling, busbar status row 155; #62): moved db64d9218d8b -> 7e606a7aa904, busbar-release +# main after the dev -> qa -> main promotion (#353, #354), which carries #352 (733d619): the corpus generator adds +# the six literal cells llm|||request|stream_upstream_cut, whose cells.json rows, owed-baseline rows and +# goldens (busbar-release oracle-rerecord run 38488778377, recorded from the published 1.5.5) land with this +# commit's PR; it also carries #349 (busbar-release slimmed to busbar's tools; no corpus change). The digests are +# dev-bin's busbar-oracle-linux--.sha256. +# # To move the pin: set release_ref to the new busbar-release commit and each sha256_linux_ to the # sha256 of its dev-bin busbar-oracle-linux-- asset (the .sha256 beside it). release_repo=https://github.com/GetBusbar/busbar-release.git -release_ref=7ab21912d664d11c7df6cc98fe78eed6b0119177 -sha256_linux_x86_64=8d1a34e8711e398b9fbcc72e97e47c98eab9804d0004d119466e9dece11001f1 -sha256_linux_aarch64=aa1ee2277d2bb04df451a90f6d4fb3b8eda377b397e3c8867e21b393a8fe06ef +release_ref=7e606a7aa904a729b74a5e4644eb7f08dfc7b51a +sha256_linux_x86_64=25b196dfab7dad0827ea061a581e37c5d912c3a9a14033ea20f3e181091a0fcf +sha256_linux_aarch64=381e9ac9c3d29fe51947f1d6ee3d315d32edc42c15c47dd92387f2b983073510 crate=busbar-release-oracle bin=busbar-oracle diff --git a/testing/shadow-oracle/owed-baseline.txt b/testing/shadow-oracle/owed-baseline.txt index 289089ddc8..1dd9096f2a 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 @@ -773,6 +774,7 @@ llm|anthropic|anthropic|request|ok_stream llm|anthropic|anthropic|request|out_of_scope llm|anthropic|anthropic|request|over_budget llm|anthropic|anthropic|request|over_budget_total +llm|anthropic|anthropic|request|stream_upstream_cut llm|anthropic|anthropic|request|unauthenticated llm|anthropic|anthropic|request|upstream_down llm|anthropic|bedrock|request|ok @@ -798,6 +800,7 @@ llm|bedrock|bedrock|request|ok_stream llm|bedrock|bedrock|request|out_of_scope llm|bedrock|bedrock|request|over_budget llm|bedrock|bedrock|request|over_budget_total +llm|bedrock|bedrock|request|stream_upstream_cut llm|bedrock|bedrock|request|unauthenticated llm|bedrock|bedrock|request|upstream_down llm|bedrock|cohere|request|ok @@ -823,6 +826,7 @@ llm|cohere|cohere|request|ok_stream llm|cohere|cohere|request|out_of_scope llm|cohere|cohere|request|over_budget llm|cohere|cohere|request|over_budget_total +llm|cohere|cohere|request|stream_upstream_cut llm|cohere|cohere|request|unauthenticated llm|cohere|cohere|request|upstream_down llm|cohere|gemini|request|ok @@ -848,6 +852,7 @@ llm|gemini|gemini|request|ok_stream_array llm|gemini|gemini|request|out_of_scope llm|gemini|gemini|request|over_budget llm|gemini|gemini|request|over_budget_total +llm|gemini|gemini|request|stream_upstream_cut llm|gemini|gemini|request|unauthenticated llm|gemini|gemini|request|upstream_down llm|gemini|openai|request|ok @@ -873,6 +878,7 @@ llm|openai|openai|request|ok_stream llm|openai|openai|request|out_of_scope llm|openai|openai|request|over_budget llm|openai|openai|request|over_budget_total +llm|openai|openai|request|stream_upstream_cut llm|openai|openai|request|unauthenticated llm|openai|openai|request|upstream_down llm|openai|responses|request|ok @@ -898,6 +904,7 @@ llm|responses|responses|request|ok_stream llm|responses|responses|request|out_of_scope llm|responses|responses|request|over_budget llm|responses|responses|request|over_budget_total +llm|responses|responses|request|stream_upstream_cut llm|responses|responses|request|unauthenticated llm|responses|responses|request|upstream_down money.reserved-tier|absent|anthropic-cache 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