From 16aed47fde0f12deac3340bd140fbbbe6fd3bd0d Mon Sep 17 00:00:00 2001 From: ualtinok Date: Wed, 8 Jul 2026 14:12:00 +0200 Subject: [PATCH 01/37] cortexkit-lease: shared-mode leases (acquire_shared) (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * cortexkit-lease: shared-mode leases (acquire_shared) Shared holders coexist with each other and block the exclusive writer (and vice versa) — reader-side protection for shared resources like the cross-module model cache: a reader takes a shared lease on a blob digest while validating/mmap-ing so GC (exclusive) can never delete under it. Shared handles do not bump the fence epoch (they are not writers); they report the last persisted writer epoch for observability. Semantics proven in tests: shared+shared coexist, shared blocks exclusive until the LAST shared holder drops, exclusive blocks shared, epoch neutrality, and a cross-process shared-vs-exclusive check (unix: python-fcntl child; Windows LockFileEx semantics are exercised by the same-process tests since its locks are per-handle). try_lock_shared is called via the fs2 trait fully-qualified: std 1.89+ added an inherent File::try_lock_shared that would shadow it. * fmt --- crates/cortexkit-lease/src/lib.rs | 214 +++++++++++++++++++++++++++++- 1 file changed, 213 insertions(+), 1 deletion(-) diff --git a/crates/cortexkit-lease/src/lib.rs b/crates/cortexkit-lease/src/lib.rs index 237ac80..f316ecd 100644 --- a/crates/cortexkit-lease/src/lib.rs +++ b/crates/cortexkit-lease/src/lib.rs @@ -118,6 +118,22 @@ pub trait LeaseStore: Send + Sync { /// Acquire the lease, or `Err(Held)` if a live writer holds it. The returned /// handle must outlive the writer; dropping it releases the lease. fn acquire(&self, key: &LeaseKey) -> Result, LeaseError>; + + /// Acquire the lease in SHARED mode: any number of shared holders may + /// coexist, but a shared holder blocks [`LeaseStore::acquire`] (exclusive) + /// and an exclusive holder blocks shared acquisition. + /// + /// Use for reader-side protection of shared resources: e.g. a model-cache + /// consumer takes a shared lease on a blob's digest while validating or + /// mmap-ing it, so a GC (exclusive holder) can never delete the file out + /// from under a live reader, while concurrent readers never serialize each + /// other. + /// + /// Shared handles do NOT bump the fence epoch (they are not writers; the + /// epoch fences durable writes). [`LeaseHandle::epoch`] on a shared handle + /// returns the last persisted writer epoch at acquisition time, for + /// observability only — never use it as a write fence. + fn acquire_shared(&self, key: &LeaseKey) -> Result, LeaseError>; } /// File-based lease store: one lock file per key under `base_dir`. The OS advisory @@ -143,7 +159,8 @@ impl FileLeaseStore { } } -/// A file-backed held lease: holds the OS advisory lock for its lifetime. +/// A file-backed held lease: holds the OS advisory lock (exclusive or shared) +/// for its lifetime. #[derive(Debug)] struct FileLeaseHandle { epoch: u64, @@ -202,6 +219,45 @@ impl LeaseStore for FileLeaseStore { key: key.clone(), })) } + + fn acquire_shared(&self, key: &LeaseKey) -> Result, LeaseError> { + std::fs::create_dir_all(&self.base_dir).map_err(LeaseError::Io)?; + let path = self.lease_path(key); + let mut file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&path) + .map_err(LeaseError::Io)?; + + // Shared liveness gate: only an exclusive holder contends; other shared + // holders coexist. On unix this is flock(LOCK_SH|LOCK_NB); on Windows, + // LockFileEx without LOCKFILE_EXCLUSIVE_LOCK (both via fs2). + // Fully-qualified call: std >= 1.89 has an inherent File::try_lock_shared + // (returning TryLockError) that would otherwise shadow the fs2 trait + // method this crate's error handling is built around. + match FileExt::try_lock_shared(&file) { + Ok(()) => {} + Err(e) if is_lock_contended(&e) => { + return Err(LeaseError::Held { key: key.clone() }); + } + Err(e) => return Err(LeaseError::Io(e)), + } + + // Read-only peek at the persisted writer epoch: shared holders are not + // writers, so the epoch is NOT bumped (it fences durable writes only). + let epoch = read_epoch(&mut file).map_err(|e| { + let _ = file.unlock(); + LeaseError::Io(e) + })?; + + Ok(Box::new(FileLeaseHandle { + epoch, + file, + key: key.clone(), + })) + } } /// Whether a `try_lock_exclusive` error means "another live holder owns the lock" @@ -215,6 +271,16 @@ fn is_lock_contended(e: &std::io::Error) -> bool { e.raw_os_error() == fs2::lock_contended_error().raw_os_error() } +/// Read the persisted epoch without modifying it (0 if new/empty). Called while +/// holding a shared OS lock; must not write (concurrent shared holders read the +/// same file). +fn read_epoch(file: &mut File) -> std::io::Result { + let mut buf = String::new(); + file.seek(SeekFrom::Start(0))?; + file.read_to_string(&mut buf)?; + Ok(buf.trim().parse().unwrap_or(0)) +} + /// Read the persisted epoch (0 if new/empty), increment, write it back, return the /// new value. Called while holding the OS lock. fn bump_epoch(file: &mut File) -> std::io::Result { @@ -314,6 +380,152 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[test] + fn shared_holders_coexist_but_block_exclusive() { + let (store, dir) = tmp_store(); + let k = key("shared"); + + let s1 = store.acquire_shared(&k).expect("first shared"); + let s2 = store + .acquire_shared(&k) + .expect("second shared holder coexists"); + + // A shared holder blocks the exclusive writer — this is the property + // the model-cache GC relies on (never delete under a live reader). + match store.acquire(&k) { + Err(LeaseError::Held { key }) => assert_eq!(key.scope_key, "shared"), + other => panic!("exclusive must be Held while shared holders live, got {other:?}"), + } + + drop(s1); + // Still one shared holder alive: exclusive must STILL be blocked. + match store.acquire(&k) { + Err(LeaseError::Held { .. }) => {} + other => { + panic!("exclusive must stay Held until the last shared holder drops, got {other:?}") + } + } + + drop(s2); + let g = store + .acquire(&k) + .expect("exclusive after all shared holders released"); + drop(g); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn exclusive_holder_blocks_shared() { + let (store, dir) = tmp_store(); + let k = key("excl-blocks-shared"); + + let g = store.acquire(&k).expect("exclusive"); + match store.acquire_shared(&k) { + Err(LeaseError::Held { key }) => assert_eq!(key.scope_key, "excl-blocks-shared"), + other => panic!("shared must be Held while exclusive holder lives, got {other:?}"), + } + drop(g); + let s = store + .acquire_shared(&k) + .expect("shared after exclusive released"); + drop(s); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn shared_acquisition_does_not_bump_the_write_epoch() { + let (store, dir) = tmp_store(); + let k = key("epoch-neutral"); + + let g = store.acquire(&k).expect("writer"); + assert_eq!(g.epoch(), 1); + drop(g); + + // Shared holders observe the persisted epoch but never advance it. + let s1 = store.acquire_shared(&k).expect("shared"); + assert_eq!(s1.epoch(), 1, "shared handle reports last writer epoch"); + drop(s1); + let s2 = store.acquire_shared(&k).expect("shared again"); + assert_eq!(s2.epoch(), 1); + drop(s2); + + let g2 = store.acquire(&k).expect("writer again"); + assert_eq!( + g2.epoch(), + 2, + "writer epoch continues from 1: shared holders did not consume epochs" + ); + drop(g2); + let _ = std::fs::remove_dir_all(dir); + } + + // Unix-only: the child uses fcntl.flock. On Windows the same-process tests + // above still exercise the real LockFileEx shared/exclusive semantics via + // fs2, because LockFileEx locks are per-handle (two handles in one process + // behave like two processes for contention purposes). + #[cfg(unix)] + #[test] + fn shared_lease_across_processes_blocks_exclusive() { + // Cross-PROCESS proof (not just same-process flock semantics): a child + // process holds a shared lease while the parent tries exclusive. + // flock/LockFileEx semantics are per-open-file-description, so the + // same-process tests above could in principle pass with per-fd + // semantics that differ across processes; this pins the real contract. + let (store, dir) = tmp_store(); + let k = key("xproc"); + + // Learn the exact lock file path by acquiring+releasing once (also + // seeds the epoch file). + let g = store.acquire(&k).expect("seed"); + drop(g); + let lock_path = { + let mut entries = std::fs::read_dir(&dir).expect("lease dir"); + let entry = entries.next().expect("one lease file").expect("dir entry"); + entry.path() + }; + + // Child: hold a SHARED flock on the lease file for 2 seconds. + // `flock(1)` from util-linux is absent on macOS, so use a tiny python + // child — python is available on every dev/CI platform we run. + let mut child = std::process::Command::new("python3") + .arg("-c") + .arg(format!( + "import fcntl,time\nf=open({lock_path:?},'r+')\nfcntl.flock(f,fcntl.LOCK_SH)\nprint('held',flush=True)\ntime.sleep(2)", + )) + .stdout(std::process::Stdio::piped()) + .spawn() + .expect("spawn shared-holder child"); + + // Wait until the child confirms it holds the shared lock. + { + use std::io::BufRead; + let stdout = child.stdout.take().expect("child stdout"); + let mut line = String::new(); + std::io::BufReader::new(stdout) + .read_line(&mut line) + .expect("child readiness line"); + assert_eq!(line.trim(), "held"); + } + + // Parent: exclusive must be Held while the child's shared lock lives. + match store.acquire(&k) { + Err(LeaseError::Held { .. }) => {} + other => { + panic!("exclusive must be Held under cross-process shared lock, got {other:?}") + } + } + // Shared, however, coexists with the child's shared lock. + let s = store + .acquire_shared(&k) + .expect("shared coexists with cross-process shared holder"); + drop(s); + + child.wait().expect("child exit"); + let g = store.acquire(&k).expect("exclusive after child released"); + drop(g); + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn epoch_persists_across_store_instances() { let (store, dir) = tmp_store(); From eb1ae99474fd847052af3f4d102024ee013bcac1 Mon Sep 17 00:00:00 2001 From: ualtinok Date: Sat, 18 Jul 2026 14:09:16 +0200 Subject: [PATCH 02/37] cortexkit-model-catalog: shared models.dev representation (types + parsing, no data) (#2) Two consumers read the catalog for different reasons (broca: capabilities on the serving path; astrocyte: pricing) and must parse the same shape so schema drift cannot make them disagree silently. Money discipline: dollar rates are converted once, at the parse boundary, to exact integer nanodollars per million tokens via decimal string scaling; a rate that cannot scale exactly is a parse error, never a rounded guess; a missing rate is None, never $0. Unmodeled fields are preserved verbatim in raw passthrough. --- Cargo.toml | 2 +- crates/cortexkit-model-catalog/Cargo.toml | 16 + crates/cortexkit-model-catalog/src/lib.rs | 458 ++++++++++++++++++++++ 3 files changed, 475 insertions(+), 1 deletion(-) create mode 100644 crates/cortexkit-model-catalog/Cargo.toml create mode 100644 crates/cortexkit-model-catalog/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index e607185..9ad1880 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["crates/cortexkit-paths", "crates/cortexkit-lease", "crates/cortexkit-store-types", "crates/cortexkit-store", "crates/cortexkit-store-postgres", "crates/cortexkit-cache-core"] +members = ["crates/cortexkit-paths", "crates/cortexkit-lease", "crates/cortexkit-store-types", "crates/cortexkit-store", "crates/cortexkit-store-postgres", "crates/cortexkit-cache-core", "crates/cortexkit-model-catalog"] # cortexkit/commons — neutral home for cross-product CortexKit primitives. # Shared by subc, AFT, and Magic Context. Each crate is published independently diff --git a/crates/cortexkit-model-catalog/Cargo.toml b/crates/cortexkit-model-catalog/Cargo.toml new file mode 100644 index 0000000..e800916 --- /dev/null +++ b/crates/cortexkit-model-catalog/Cargo.toml @@ -0,0 +1,16 @@ +# cortexkit-model-catalog — the shared REPRESENTATION of the models.dev model +# catalog: types + parsing only, NO bundled data. Consumers (broca for +# capabilities, astrocyte for pricing) parse the same shape by construction; +# each brings its own snapshot and owns its own derived stores. +[package] +name = "cortexkit-model-catalog" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +description = "Shared parsing + types for the models.dev model catalog (no data): providers, models, capabilities, and exact integer-nanodollar cost rates." + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/crates/cortexkit-model-catalog/src/lib.rs b/crates/cortexkit-model-catalog/src/lib.rs new file mode 100644 index 0000000..537f5f6 --- /dev/null +++ b/crates/cortexkit-model-catalog/src/lib.rs @@ -0,0 +1,458 @@ +//! Shared representation of the models.dev model catalog: types + parsing, +//! deliberately NO bundled data. +//! +//! Two CortexKit consumers read the catalog for different reasons — broca for +//! capabilities/limits on the serving path, astrocyte for pricing — and the +//! fleet rule is that they must parse the SAME shape so a catalog schema +//! drift cannot make them disagree silently. This crate is that shape. +//! Consumers bring their own snapshot bytes and own their derived stores. +//! +//! Money discipline: models.dev publishes dollar-per-million-token rates as +//! JSON decimal numbers. This crate converts them ONCE, at the parse +//! boundary, into exact integer NANODOLLARS per million tokens +//! ([`RateNanosPerMtok`]) via decimal string scaling — no float ever reaches +//! a consumer's money path. A rate the decimal cannot represent exactly in +//! nanodollars is a parse error, never a rounded guess. `None` = "no +//! published rate", which is NOT zero (free) — consumers must distinguish +//! them. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Integer nanodollars per million tokens. $3/M tokens = 3_000_000_000. +pub type RateNanosPerMtok = i64; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CatalogParseError { + Json(String), + /// A cost number that cannot scale exactly to integer nanodollars + /// (more than 9 fractional digits, out of range, or not a plain decimal). + InexactRate { + provider: String, + model: String, + field: &'static str, + value: String, + }, +} + +impl std::fmt::Display for CatalogParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CatalogParseError::Json(e) => write!(f, "catalog json: {e}"), + CatalogParseError::InexactRate { provider, model, field, value } => write!( + f, + "catalog rate {provider}/{model}.{field} = {value} cannot scale exactly to nanodollars" + ), + } + } +} + +impl std::error::Error for CatalogParseError {} + +/// The parsed catalog: provider id → provider entry. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct CatalogDoc { + pub providers: BTreeMap, +} + +/// One provider, with the fields both consumers rely on. Unmodeled catalog +/// fields are preserved verbatim in `raw` (ingestion-only passthrough) so a +/// schema addition never silently drops data. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct ProviderEntry { + pub id: String, + pub name: Option, + pub api: Option, + pub npm: Option, + pub models: BTreeMap, + pub raw: Value, +} + +/// One model offered by a provider. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct ModelEntry { + /// The wire model id (what goes in a request's `model` field). + pub id: String, + pub display_name: Option, + pub family: Option, + pub capabilities: Capabilities, + pub limits: Limits, + pub cost: CostSchedule, + pub release_date: Option, + pub status: Option, + pub raw: Value, +} + +/// Capability flags (mirrors the catalog's booleans). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct Capabilities { + #[serde(default)] + pub attachment: bool, + #[serde(default)] + pub reasoning: bool, + #[serde(default)] + pub temperature: bool, + #[serde(default)] + pub tool_call: bool, +} + +/// Token limits (mirrors the catalog). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct Limits { + #[serde(default)] + pub context: Option, + #[serde(default)] + pub max_input: Option, + #[serde(default)] + pub max_output: Option, +} + +/// Per-token-class rates in exact integer nanodollars per million tokens. +/// +/// `None` = the catalog did not state a rate — NOT the same as zero (free). +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct CostSchedule { + pub input: Option, + pub output: Option, + pub cache_read: Option, + pub cache_write: Option, + pub reasoning: Option, + pub input_audio: Option, + pub output_audio: Option, + /// Context-size pricing tiers, ascending by `min_context`. Empty = flat. + pub tiers: Vec, +} + +/// One context-size pricing tier: rates that apply at/above `min_context`. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct CostTier { + pub min_context: u64, + pub input: Option, + pub output: Option, + pub cache_read: Option, + pub cache_write: Option, +} + +impl CatalogDoc { + /// Parse a models.dev snapshot (the top-level `{ provider_id: {...} }` + /// document). Unknown fields are preserved in `raw`, never dropped. + pub fn parse(json: &str) -> Result { + let root: Value = + serde_json::from_str(json).map_err(|e| CatalogParseError::Json(e.to_string()))?; + let obj = root + .as_object() + .ok_or_else(|| CatalogParseError::Json("top level is not an object".into()))?; + let mut providers = BTreeMap::new(); + for (provider_id, entry) in obj { + providers.insert(provider_id.clone(), parse_provider(provider_id, entry)?); + } + Ok(Self { providers }) + } + + pub fn model( + &self, + provider_id: &str, + model_id: &str, + ) -> Option<(&ProviderEntry, &ModelEntry)> { + let provider = self.providers.get(provider_id)?; + let model = provider.models.get(model_id)?; + Some((provider, model)) + } +} + +fn parse_provider(id: &str, entry: &Value) -> Result { + let mut models = BTreeMap::new(); + if let Some(model_map) = entry.get("models").and_then(Value::as_object) { + for (model_id, model_entry) in model_map { + models.insert(model_id.clone(), parse_model(id, model_id, model_entry)?); + } + } + Ok(ProviderEntry { + id: id.to_string(), + name: entry.get("name").and_then(Value::as_str).map(String::from), + api: entry.get("api").and_then(Value::as_str).map(String::from), + npm: entry.get("npm").and_then(Value::as_str).map(String::from), + models, + raw: entry.clone(), + }) +} + +fn parse_model(provider: &str, id: &str, entry: &Value) -> Result { + let capabilities = Capabilities { + attachment: flag(entry, "attachment"), + reasoning: flag(entry, "reasoning"), + temperature: flag(entry, "temperature"), + tool_call: flag(entry, "tool_call"), + }; + let limits = entry + .get("limit") + .map(|l| Limits { + context: l.get("context").and_then(Value::as_u64), + max_input: l.get("input").and_then(Value::as_u64), + max_output: l.get("output").and_then(Value::as_u64), + }) + .unwrap_or_default(); + let cost = match entry.get("cost") { + None => CostSchedule::default(), + Some(c) => parse_cost(provider, id, c)?, + }; + Ok(ModelEntry { + id: id.to_string(), + display_name: entry.get("name").and_then(Value::as_str).map(String::from), + family: entry + .get("family") + .and_then(Value::as_str) + .map(String::from), + capabilities, + limits, + cost, + release_date: entry + .get("release_date") + .and_then(Value::as_str) + .map(String::from), + status: entry + .get("status") + .and_then(Value::as_str) + .map(String::from), + raw: entry.clone(), + }) +} + +fn flag(entry: &Value, key: &str) -> bool { + entry.get(key).and_then(Value::as_bool).unwrap_or(false) +} + +fn parse_cost( + provider: &str, + model: &str, + cost: &Value, +) -> Result { + let rate = |field: &'static str| -> Result, CatalogParseError> { + match cost.get(field) { + None | Some(Value::Null) => Ok(None), + Some(v) => { + dollars_to_nanos(v) + .map(Some) + .map_err(|value| CatalogParseError::InexactRate { + provider: provider.to_string(), + model: model.to_string(), + field, + value, + }) + } + } + }; + let mut tiers = Vec::new(); + if let Some(list) = cost.get("tiers").and_then(Value::as_array) { + for tier in list { + let trate = + |field: &'static str| -> Result, CatalogParseError> { + match tier.get(field) { + None | Some(Value::Null) => Ok(None), + Some(v) => dollars_to_nanos(v).map(Some).map_err(|value| { + CatalogParseError::InexactRate { + provider: provider.to_string(), + model: model.to_string(), + field, + value, + } + }), + } + }; + tiers.push(CostTier { + min_context: tier + .get("context_over") + .or_else(|| tier.get("min_context")) + .and_then(Value::as_u64) + .unwrap_or(0), + input: trate("input")?, + output: trate("output")?, + cache_read: trate("cache_read")?, + cache_write: trate("cache_write")?, + }); + } + tiers.sort_by_key(|t| t.min_context); + } + Ok(CostSchedule { + input: rate("input")?, + output: rate("output")?, + cache_read: rate("cache_read")?, + cache_write: rate("cache_write")?, + reasoning: rate("reasoning")?, + input_audio: rate("input_audio")?, + output_audio: rate("output_audio")?, + tiers, + }) +} + +/// Convert a catalog dollar rate (JSON number) to exact integer nanodollars +/// via DECIMAL STRING scaling — floats never do money arithmetic. +/// +/// The JSON number's shortest-roundtrip decimal form is scaled by 10^9 +/// exactly; more than 9 fractional digits, exponents beyond range, or a +/// non-finite value is an error (returns the offending textual value). +fn dollars_to_nanos(v: &Value) -> Result { + let n = v.as_number().ok_or_else(|| v.to_string())?; + decimal_str_to_nanos(&n.to_string()).ok_or_else(|| n.to_string()) +} + +fn decimal_str_to_nanos(s: &str) -> Option { + // Split off an exponent (serde prints e.g. 1e-7 for tiny rates). + let (mantissa, exp) = match s.find(['e', 'E']) { + Some(idx) => { + let exp: i32 = s[idx + 1..].parse().ok()?; + (&s[..idx], exp) + } + None => (s, 0), + }; + let negative = mantissa.starts_with('-'); + let mantissa = mantissa.trim_start_matches(['-', '+']); + let (int_part, frac_part) = match mantissa.find('.') { + Some(idx) => (&mantissa[..idx], &mantissa[idx + 1..]), + None => (mantissa, ""), + }; + if int_part.is_empty() && frac_part.is_empty() { + return None; + } + if !int_part.chars().all(|c| c.is_ascii_digit()) + || !frac_part.chars().all(|c| c.is_ascii_digit()) + { + return None; + } + // digits = int_part + frac_part, decimal point sits after int_part.len(), + // then shift by exp. Effective fractional digits = frac.len() - exp. + let digits: String = format!("{int_part}{frac_part}"); + let digits_trimmed = digits.trim_start_matches('0'); + let value: i128 = if digits_trimmed.is_empty() { + 0 + } else { + digits_trimmed.parse().ok()? + }; + // value × 10^(exp - frac_len) dollars → nanos = value × 10^(9 + exp - frac_len) + let shift = 9 + exp - frac_part.len() as i32; + if shift < 0 { + return None; // sub-nanodollar precision: cannot represent exactly + } + let scaled = value.checked_mul(10i128.checked_pow(shift as u32)?)?; + let scaled = if negative { -scaled } else { scaled }; + RateNanosPerMtok::try_from(scaled).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decimal_scaling_is_exact() { + assert_eq!(decimal_str_to_nanos("3"), Some(3_000_000_000)); + assert_eq!(decimal_str_to_nanos("3.75"), Some(3_750_000_000)); + assert_eq!(decimal_str_to_nanos("0.3"), Some(300_000_000)); + // The classic float trap: 0.1 + 0.2 style artifacts cannot arise — + // "0.07" scales as digits, not as a binary float. + assert_eq!(decimal_str_to_nanos("0.07"), Some(70_000_000)); + assert_eq!(decimal_str_to_nanos("0"), Some(0)); + // Exponent forms (serde prints tiny rates this way). + assert_eq!(decimal_str_to_nanos("1e-7"), Some(100)); + assert_eq!(decimal_str_to_nanos("2.5e-3"), Some(2_500_000)); + // Sub-nanodollar precision is an ERROR, not a rounded guess. + assert_eq!(decimal_str_to_nanos("1e-10"), None); + assert_eq!(decimal_str_to_nanos("0.0000000001"), None); + } + + fn snapshot() -> &'static str { + r#"{ + "anthropic": { + "name": "Anthropic", + "api": "https://api.anthropic.com", + "models": { + "claude-test-4": { + "name": "Claude Test 4", + "reasoning": true, + "tool_call": true, + "limit": { "context": 200000, "output": 64000 }, + "cost": { + "input": 3, "output": 15, + "cache_read": 0.3, "cache_write": 3.75 + } + }, + "claude-unpriced": { "name": "No cost row" } + } + }, + "somehost": { + "models": { + "tiered": { + "cost": { + "input": 1.25, "output": 10, + "tiers": [ + { "context_over": 200000, "input": 2.5, "output": 15 } + ] + } + } + } + } + }"# + } + + #[test] + fn parses_providers_models_rates() { + let doc = CatalogDoc::parse(snapshot()).unwrap(); + let (provider, model) = doc.model("anthropic", "claude-test-4").unwrap(); + assert_eq!(provider.name.as_deref(), Some("Anthropic")); + assert!(model.capabilities.reasoning); + assert_eq!(model.limits.context, Some(200_000)); + assert_eq!(model.cost.input, Some(3_000_000_000)); + assert_eq!(model.cost.output, Some(15_000_000_000)); + assert_eq!(model.cost.cache_read, Some(300_000_000)); + assert_eq!(model.cost.cache_write, Some(3_750_000_000)); + // No published reasoning rate: None, NOT zero. + assert_eq!(model.cost.reasoning, None); + } + + #[test] + fn missing_cost_block_is_all_none_not_zero() { + let doc = CatalogDoc::parse(snapshot()).unwrap(); + let (_, model) = doc.model("anthropic", "claude-unpriced").unwrap(); + assert_eq!(model.cost, CostSchedule::default()); + assert_eq!(model.cost.input, None, "no rate is None, never $0"); + } + + #[test] + fn tiers_parse_sorted() { + let doc = CatalogDoc::parse(snapshot()).unwrap(); + let (_, model) = doc.model("somehost", "tiered").unwrap(); + assert_eq!(model.cost.tiers.len(), 1); + assert_eq!(model.cost.tiers[0].min_context, 200_000); + assert_eq!(model.cost.tiers[0].input, Some(2_500_000_000)); + } + + #[test] + fn raw_passthrough_preserves_unmodeled_fields() { + let doc = + CatalogDoc::parse(r#"{ "p": { "future_field": {"x": 1}, "models": {} } }"#).unwrap(); + let provider = doc.providers.get("p").unwrap(); + assert_eq!(provider.raw.get("future_field").unwrap()["x"], 1); + } + + #[test] + fn inexact_rate_is_a_loud_error() { + let err = + CatalogDoc::parse(r#"{ "p": { "models": { "m": { "cost": { "input": 1e-10 } } } } }"#) + .unwrap_err(); + match err { + CatalogParseError::InexactRate { + provider, + model, + field, + .. + } => { + assert_eq!( + (provider.as_str(), model.as_str(), field), + ("p", "m", "input") + ); + } + other => panic!("expected InexactRate, got {other:?}"), + } + } +} From ca0baa7b7ed150f8af337a6177b66efcfaf3a1b0 Mon Sep 17 00:00:00 2001 From: ualtinok Date: Sat, 18 Jul 2026 14:14:04 +0200 Subject: [PATCH 03/37] cortexkit-model-catalog: reject negative rates at the parse boundary (#3) No catalog publishes a negative price; a corrupted snapshot must fail loud (NegativeRate naming provider/model/field, same shape as InexactRate) rather than flow silently into consumers' signed money paths. Guards base rates and tier rates through one shared conversion gate. --- crates/cortexkit-model-catalog/src/lib.rs | 77 +++++++++++++++++------ 1 file changed, 59 insertions(+), 18 deletions(-) diff --git a/crates/cortexkit-model-catalog/src/lib.rs b/crates/cortexkit-model-catalog/src/lib.rs index 537f5f6..885d99a 100644 --- a/crates/cortexkit-model-catalog/src/lib.rs +++ b/crates/cortexkit-model-catalog/src/lib.rs @@ -35,6 +35,14 @@ pub enum CatalogParseError { field: &'static str, value: String, }, + /// A NEGATIVE rate. No catalog publishes one; a corrupted snapshot must + /// fail loud here rather than flow into consumers' signed money paths. + NegativeRate { + provider: String, + model: String, + field: &'static str, + value: String, + }, } impl std::fmt::Display for CatalogParseError { @@ -45,6 +53,9 @@ impl std::fmt::Display for CatalogParseError { f, "catalog rate {provider}/{model}.{field} = {value} cannot scale exactly to nanodollars" ), + CatalogParseError::NegativeRate { provider, model, field, value } => { + write!(f, "catalog rate {provider}/{model}.{field} = {value} is negative") + } } } } @@ -229,19 +240,27 @@ fn parse_cost( model: &str, cost: &Value, ) -> Result { + let convert = |field: &'static str, v: &Value| -> Result { + let nanos = dollars_to_nanos(v).map_err(|value| CatalogParseError::InexactRate { + provider: provider.to_string(), + model: model.to_string(), + field, + value, + })?; + if nanos < 0 { + return Err(CatalogParseError::NegativeRate { + provider: provider.to_string(), + model: model.to_string(), + field, + value: v.to_string(), + }); + } + Ok(nanos) + }; let rate = |field: &'static str| -> Result, CatalogParseError> { match cost.get(field) { None | Some(Value::Null) => Ok(None), - Some(v) => { - dollars_to_nanos(v) - .map(Some) - .map_err(|value| CatalogParseError::InexactRate { - provider: provider.to_string(), - model: model.to_string(), - field, - value, - }) - } + Some(v) => convert(field, v).map(Some), } }; let mut tiers = Vec::new(); @@ -251,14 +270,7 @@ fn parse_cost( |field: &'static str| -> Result, CatalogParseError> { match tier.get(field) { None | Some(Value::Null) => Ok(None), - Some(v) => dollars_to_nanos(v).map(Some).map_err(|value| { - CatalogParseError::InexactRate { - provider: provider.to_string(), - model: model.to_string(), - field, - value, - } - }), + Some(v) => convert(field, v).map(Some), } }; tiers.push(CostTier { @@ -435,6 +447,35 @@ mod tests { assert_eq!(provider.raw.get("future_field").unwrap()["x"], 1); } + #[test] + fn negative_rate_is_a_loud_error() { + // A corrupted snapshot's negative price must fail parse, not flow + // silently into consumers' signed money paths. + let err = + CatalogDoc::parse(r#"{ "p": { "models": { "m": { "cost": { "output": -15 } } } } }"#) + .unwrap_err(); + match err { + CatalogParseError::NegativeRate { + provider, + model, + field, + .. + } => { + assert_eq!( + (provider.as_str(), model.as_str(), field), + ("p", "m", "output") + ); + } + other => panic!("expected NegativeRate, got {other:?}"), + } + // Tier rates are guarded by the same gate. + let err = CatalogDoc::parse( + r#"{ "p": { "models": { "m": { "cost": { "tiers": [ { "context_over": 1, "input": -1 } ] } } } } }"#, + ) + .unwrap_err(); + assert!(matches!(err, CatalogParseError::NegativeRate { .. })); + } + #[test] fn inexact_rate_is_a_loud_error() { let err = From 980f1ffdea5c75186b9649390523676d97b26abb Mon Sep 17 00:00:00 2001 From: ualtinok Date: Sat, 18 Jul 2026 20:13:10 +0200 Subject: [PATCH 04/37] cortexkit-model-catalog: round sub-nanodollar precision half-even at the parse boundary (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * cortexkit-model-catalog: round sub-nanodollar precision half-even at the parse boundary Real models.dev snapshots carry IEEE-754 shortest-roundtrip artifacts from the upstream pipeline (0.8299999999999998 for an intended 0.83); the strict reject made a whole real-world snapshot unparseable. Digits beyond the nanodollar resolution now round half-even (error < 0.5 nano per Mtok); the dangerous case stays loud: a NONZERO rate that would round to ZERO is still rejected, so a real price can never silently become a free model. Verified against a live 3.2MB models.dev api.json (167 providers, 145 artifact rates). * harden rounding compare: checked doubling, absurd-precision rate is a loud error rem*2 could wrap i128 for a ~47-fractional-digit significand (divisor 10^38, rem > i128::MAX/2) — a wrapping multiply in a money parse path. checked_mul makes it a parse error (fail closed); test proves the exact overflow shape returns None rather than panicking or wrapping, and that sane long-fraction values still round. --- crates/cortexkit-model-catalog/src/lib.rs | 85 +++++++++++++++++++++-- 1 file changed, 78 insertions(+), 7 deletions(-) diff --git a/crates/cortexkit-model-catalog/src/lib.rs b/crates/cortexkit-model-catalog/src/lib.rs index 885d99a..a51328d 100644 --- a/crates/cortexkit-model-catalog/src/lib.rs +++ b/crates/cortexkit-model-catalog/src/lib.rs @@ -303,8 +303,13 @@ fn parse_cost( /// via DECIMAL STRING scaling — floats never do money arithmetic. /// /// The JSON number's shortest-roundtrip decimal form is scaled by 10^9 -/// exactly; more than 9 fractional digits, exponents beyond range, or a -/// non-finite value is an error (returns the offending textual value). +/// exactly. Precision beyond nanodollars ROUNDS HALF-EVEN at this boundary: +/// real catalogs carry upstream float artifacts (models.dev publishes rates +/// like `0.8299999999999998` for an intended `0.83`), and the rounding error +/// is below the money resolution (< 0.5 nanodollar per million tokens). +/// The one dangerous case stays a loud error: a NONZERO rate that would +/// round to ZERO (e.g. `1e-10`) is rejected — rounding it would fabricate a +/// free model, the exact silent-$0 the fleet's money rules ban. fn dollars_to_nanos(v: &Value) -> Result { let n = v.as_number().ok_or_else(|| v.to_string())?; decimal_str_to_nanos(&n.to_string()).ok_or_else(|| n.to_string()) @@ -344,10 +349,36 @@ fn decimal_str_to_nanos(s: &str) -> Option { }; // value × 10^(exp - frac_len) dollars → nanos = value × 10^(9 + exp - frac_len) let shift = 9 + exp - frac_part.len() as i32; - if shift < 0 { - return None; // sub-nanodollar precision: cannot represent exactly - } - let scaled = value.checked_mul(10i128.checked_pow(shift as u32)?)?; + let scaled = if shift >= 0 { + value.checked_mul(10i128.checked_pow(shift as u32)?)? + } else { + // Sub-nanodollar digits: round half-even at the money resolution. + // Upstream float artifacts ("0.8299999999999998") land here; the + // error is < 0.5 nano per Mtok. A NONZERO value rounding to ZERO is + // refused — that would fabricate a free model from a real price. + let divisor = 10i128.checked_pow((-shift) as u32)?; + let quot = value / divisor; + let rem = value % divisor; + // Checked doubling: for an absurd ~38-digit fractional significand, + // divisor approaches i128::MAX and rem*2 could wrap — money math + // never wraps, so overflow makes the rate a loud parse error. + let doubled = rem.checked_mul(2)?; + let rounded = match doubled.cmp(&divisor) { + std::cmp::Ordering::Greater => quot + 1, + std::cmp::Ordering::Less => quot, + std::cmp::Ordering::Equal => { + if quot % 2 == 0 { + quot + } else { + quot + 1 + } + } + }; + if rounded == 0 && value != 0 { + return None; // nonzero price must never become $0 + } + rounded + }; let scaled = if negative { -scaled } else { scaled }; RateNanosPerMtok::try_from(scaled).ok() } @@ -368,11 +399,51 @@ mod tests { // Exponent forms (serde prints tiny rates this way). assert_eq!(decimal_str_to_nanos("1e-7"), Some(100)); assert_eq!(decimal_str_to_nanos("2.5e-3"), Some(2_500_000)); - // Sub-nanodollar precision is an ERROR, not a rounded guess. + // A nonzero rate that would round to ZERO stays an ERROR — rounding + // it would fabricate a free model from a real price. assert_eq!(decimal_str_to_nanos("1e-10"), None); assert_eq!(decimal_str_to_nanos("0.0000000001"), None); } + #[test] + fn upstream_float_artifacts_round_half_even() { + // Real models.dev data: IEEE-754 shortest-roundtrip artifacts from + // the upstream pipeline. The intended decimal is recovered exactly. + assert_eq!( + decimal_str_to_nanos("0.8299999999999998"), + Some(830_000_000) + ); + assert_eq!( + decimal_str_to_nanos("1.7999999999999998"), + Some(1_800_000_000) + ); + assert_eq!( + decimal_str_to_nanos("0.49299999999999994"), + Some(493_000_000) + ); + // Half-even at the boundary digit: 10 fractional digits ...5 exact. + assert_eq!(decimal_str_to_nanos("0.0000000015"), Some(2)); // 1.5 → 2 (even) + assert_eq!(decimal_str_to_nanos("0.0000000025"), Some(2)); // 2.5 → 2 (even) + // True zero stays zero (zero is not "rounded to zero"). + assert_eq!(decimal_str_to_nanos("0.0000000000"), Some(0)); + } + + #[test] + fn pathological_significand_is_error_never_wrap() { + // 47 fractional digits: significand 8.6e37 still parses as i128 and + // reaches the rounding branch with divisor 10^38 and rem = 8.6e37 > + // i128::MAX/2 — rem*2 would wrap without the checked multiply (debug: + // panic; release: a wrapped compare). Must be a loud None instead. + let s = "0.00000000086000000000000000000000000000000000000"; + assert_eq!(decimal_str_to_nanos(s), None); + // Sane long-fraction values still round normally (no overreach): 29 + // sub-nano digits with a small significand stay in checked range. + assert_eq!( + decimal_str_to_nanos("0.99999999999999999999999999999999999999"), + Some(1_000_000_000) + ); + } + fn snapshot() -> &'static str { r#"{ "anthropic": { From 8067cf819a5ad058953d3c18539bed6a4da64986 Mon Sep 17 00:00:00 2001 From: ualtinok Date: Sun, 19 Jul 2026 23:14:03 +0200 Subject: [PATCH 05/37] Add cortexkit-provider-usage: shared wire types for the quota usage.get payload (#5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure serde types (ProviderUsage, Usage, RateWindow, AccountInfo, SavedResets, CreditExpiry, ExtraWindow) extracted from ai-provider-quota's model.rs so every consumer of the usage.get wire — the quota module that produces it, ALF's router reads, astrocyte's capacity axis, and the ck quota renderer — compiles against one definition and the shape cannot drift without a shared-crate PR each side reviews. Shape, not policy: read-time transform semantics (the quota module's banked-reset relaxation, which zeroes used_percent and carries the provider truth in raw_used_percent) are producer behavior documented on the fields but not enforced by these types. serde-only, no logic; serde_json is a dev-dependency for the wire-shape tests. The reserved prepaid-Balance seam is intentionally NOT included (it was never populated and is wire-neutral); it joins this crate additively when the balance axis is designed. Follows the cortexkit-model-catalog precedent. --- Cargo.toml | 2 +- crates/cortexkit-provider-usage/Cargo.toml | 25 ++ crates/cortexkit-provider-usage/src/lib.rs | 279 +++++++++++++++++++++ 3 files changed, 305 insertions(+), 1 deletion(-) create mode 100644 crates/cortexkit-provider-usage/Cargo.toml create mode 100644 crates/cortexkit-provider-usage/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index 9ad1880..cb5069f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["crates/cortexkit-paths", "crates/cortexkit-lease", "crates/cortexkit-store-types", "crates/cortexkit-store", "crates/cortexkit-store-postgres", "crates/cortexkit-cache-core", "crates/cortexkit-model-catalog"] +members = ["crates/cortexkit-paths", "crates/cortexkit-lease", "crates/cortexkit-store-types", "crates/cortexkit-store", "crates/cortexkit-store-postgres", "crates/cortexkit-cache-core", "crates/cortexkit-model-catalog", "crates/cortexkit-provider-usage"] # cortexkit/commons — neutral home for cross-product CortexKit primitives. # Shared by subc, AFT, and Magic Context. Each crate is published independently diff --git a/crates/cortexkit-provider-usage/Cargo.toml b/crates/cortexkit-provider-usage/Cargo.toml new file mode 100644 index 0000000..a68f4bc --- /dev/null +++ b/crates/cortexkit-provider-usage/Cargo.toml @@ -0,0 +1,25 @@ +# cortexkit-provider-usage — the shared WIRE REPRESENTATION of the ai-provider +# quota `usage.get` payload: pure serde types only, NO fetch/transform logic. +# Consumers (the quota module that produces it, ALF's router reads, astrocyte's +# capacity axis, the ck CLI renderer) all compile against ONE definition so the +# shape cannot drift without a shared-crate PR every side reviews. +# +# SHAPE, NOT POLICY: read-time transform semantics — e.g. the quota module's +# banked-reset relaxation, which zeroes `used_percent` and carries the provider +# truth in `raw_used_percent` — are PRODUCER behavior documented on the fields +# below but NOT enforced by these types. A consumer renders whatever the wire +# says; this crate makes no guarantee about how a producer derived the numbers. +[package] +name = "cortexkit-provider-usage" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +description = "Shared wire types for the ai-provider-quota usage.get payload: ProviderUsage, Usage, RateWindow, and account/reset metadata. Pure serde, no logic." + +[dependencies] +serde = { version = "1", features = ["derive"] } + +[dev-dependencies] +serde_json = "1" diff --git a/crates/cortexkit-provider-usage/src/lib.rs b/crates/cortexkit-provider-usage/src/lib.rs new file mode 100644 index 0000000..be7508a --- /dev/null +++ b/crates/cortexkit-provider-usage/src/lib.rs @@ -0,0 +1,279 @@ +//! Shared wire types for the `ai-provider-quota` module's `usage.get` payload. +//! +//! The quota module serves an array of [`ProviderUsage`] per request; ALF's +//! router (`codexbar-window-extractors.ts`), astrocyte's capacity axis, and the +//! `ck quota` renderer all consume that shape. This crate is the single +//! definition those consumers compile against, so the wire shape cannot drift +//! without a shared-crate PR every side reviews. +//! +//! # Shape, not policy +//! +//! These are pure data types. Read-time transform semantics are PRODUCER +//! behavior documented on the relevant fields but NOT enforced here: +//! - **Banked-reset relaxation:** the quota module may zero +//! [`RateWindow::used_percent`] (the EFFECTIVE number consumers pace on) and +//! carry the provider-reported truth in [`RateWindow::raw_used_percent`]. +//! A consumer renders whatever the wire says; a sudden `0 → high` transition +//! is an honest disarm (credits spent / auth broke), not a glitch. +//! - **Cache-only partial arrays:** the quota module never blocks on a fetch, +//! so a result may omit providers not yet swept. Missing ≠ zero. +//! - **Degraded entries ride in-band:** a provider fetch failure is a normal +//! [`ProviderUsage`] carrying `error`, not a request-level failure. +//! +//! # Serialization contract consumers depend on +//! - camelCase keys (`usedPercent`, `resetsAt`, `windowMinutes`, +//! `extraRateWindows`, `rawUsedPercent`, `accountInfo`, `savedResets`). +//! - A healthy entry MUST NOT carry `error` (consumers skip truthy-`error` +//! entries), so it is omitted when absent. +//! - A window is emitted when it has a `usedPercent`; `resetsAt` is OPTIONAL and +//! omitted when the provider reports no reset (never fabricated). + +use serde::{Deserialize, Serialize}; + +/// One rate-limit window: how much of a quota pool is spent and when it resets. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RateWindow { + /// 0..100 percent of the window's quota consumed. This is the EFFECTIVE + /// number consumers pace on: when banked-reset relaxation applies it is + /// zeroed, and the provider-reported percent moves to `raw_used_percent`. + pub used_percent: f64, + /// The provider-reported percent when `used_percent` has been relaxed to + /// an effective value (banked resets guarantee the window resets before + /// the wall). Present only on relaxed windows; human-facing UIs should + /// display this truth alongside the effective number. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub raw_used_percent: Option, + /// ISO 8601 / RFC 3339 timestamp when the window resets. Omitted when the + /// provider reports no reset (e.g. an idle session window with nothing + /// pending) — never fabricated. + #[serde(skip_serializing_if = "Option::is_none")] + pub resets_at: Option, + /// Window length in minutes. Omitted when the provider does not report one; + /// the consumer then paces on utilization alone rather than a burn rate. + #[serde(skip_serializing_if = "Option::is_none")] + pub window_minutes: Option, +} + +/// A per-model window bundled under one account (e.g. Antigravity's Geminis). +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ExtraWindow { + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub window: Option, +} + +/// The window topology for one account: up to three account-wide pools plus an +/// optional list of per-model pools. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct Usage { + #[serde(skip_serializing_if = "Option::is_none")] + pub primary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub secondary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tertiary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub extra_rate_windows: Option>, +} + +/// Account labels and subscription information supplied by a provider or vault. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +pub struct AccountInfo { + #[serde(skip_serializing_if = "Option::is_none", default)] + pub email: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub org_name: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub plan_type: Option, +} + +impl AccountInfo { + pub fn is_empty(&self) -> bool { + self.email.is_none() && self.org_name.is_none() && self.plan_type.is_none() + } +} + +/// One saved reset credit and its expiry time. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CreditExpiry { + pub expires_at: String, +} + +/// Saved reset credits reported by Codex's read-only credits endpoint. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +pub struct SavedResets { + #[serde(default)] + pub available_count: u32, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub soonest_expires_at: Option, + #[serde(default)] + pub credits: Vec, +} + +fn account_info_is_empty(value: &Option) -> bool { + value.as_ref().map(AccountInfo::is_empty).unwrap_or(true) +} + +/// One provider/account's usage entry. The `/usage` response is an array of +/// these. A fetch failure becomes an entry carrying `error` (silent-degrade), +/// never a failure of the whole array. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ProviderUsage { + /// CodexBar provider name (e.g. "codex"), which consumers map to their own id. + pub provider: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub account: Option, + /// Which retrieval path produced this (e.g. "oauth") — observability only. + #[serde(skip_serializing_if = "Option::is_none")] + pub source: Option, + #[serde(skip_serializing_if = "account_info_is_empty", default)] + pub account_info: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub fetched_at: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub saved_resets: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub usage: Option, + /// Present only on a degraded entry. The consumer skips any entry with a + /// truthy `error`. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl ProviderUsage { + /// A healthy entry with resolved windows. + pub fn healthy(provider: &str, account: Option, source: &str, usage: Usage) -> Self { + Self { + provider: provider.to_string(), + account, + source: Some(source.to_string()), + account_info: None, + fetched_at: None, + saved_resets: None, + usage: Some(usage), + error: None, + } + } + + /// A degraded entry: the provider is named so the consumer can correlate, + /// but it carries only an error string and no windows. + pub fn degraded(provider: &str, error: impl std::fmt::Display) -> Self { + Self { + provider: provider.to_string(), + account: None, + source: None, + account_info: None, + fetched_at: None, + saved_resets: None, + usage: None, + error: Some(error.to_string()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn account_info_is_omitted_when_empty_and_keeps_partial_labels() { + let bare = ProviderUsage::healthy( + "codex", + None, + "oauth", + Usage { + primary: Some(RateWindow { + used_percent: 10.0, + raw_used_percent: None, + resets_at: None, + window_minutes: Some(300), + }), + ..Default::default() + }, + ); + let json = serde_json::to_string(&bare).unwrap(); + assert!( + !json.contains("accountInfo"), + "empty accountInfo must be omitted" + ); + + let mut labeled = bare.clone(); + labeled.account_info = Some(AccountInfo { + email: Some("a@b.com".to_string()), + org_name: None, + plan_type: Some("pro".to_string()), + }); + let json = serde_json::to_string(&labeled).unwrap(); + assert!(json.contains("\"email\":\"a@b.com\"")); + assert!(json.contains("\"planType\":\"pro\"")); + assert!(!json.contains("orgName"), "absent orgName must be omitted"); + } + + #[test] + fn saved_resets_use_camel_case_and_round_trip() { + let entry = ProviderUsage { + saved_resets: Some(SavedResets { + available_count: 2, + soonest_expires_at: Some("2026-07-31T20:11:35Z".to_string()), + credits: vec![CreditExpiry { + expires_at: "2026-07-31T20:11:35Z".to_string(), + }], + }), + ..ProviderUsage::degraded("codex", "x") + }; + let json = serde_json::to_string(&entry).unwrap(); + assert!(json.contains("\"savedResets\"")); + assert!(json.contains("\"availableCount\":2")); + assert!(json.contains("\"soonestExpiresAt\"")); + let back: ProviderUsage = serde_json::from_str(&json).unwrap(); + assert_eq!(back, entry); + } + + #[test] + fn raw_used_percent_is_absent_from_unrelaxed_windows_and_camel_case_when_present() { + let unrelaxed = RateWindow { + used_percent: 41.0, + raw_used_percent: None, + resets_at: Some("2026-07-20T00:00:00Z".to_string()), + window_minutes: Some(10080), + }; + let json = serde_json::to_string(&unrelaxed).unwrap(); + assert!( + !json.contains("rawUsedPercent"), + "unrelaxed window must not carry the field" + ); + + let relaxed = RateWindow { + used_percent: 0.0, + raw_used_percent: Some(70.0), + resets_at: Some("2026-07-20T00:00:00Z".to_string()), + window_minutes: Some(10080), + }; + let json = serde_json::to_string(&relaxed).unwrap(); + assert!(json.contains("\"rawUsedPercent\":70.0")); + let back: RateWindow = serde_json::from_str(&json).unwrap(); + assert_eq!(back, relaxed); + } + + #[test] + fn healthy_entry_omits_error_and_degraded_entry_omits_usage() { + let healthy = ProviderUsage::healthy("codex", None, "oauth", Usage::default()); + let json = serde_json::to_string(&healthy).unwrap(); + assert!(!json.contains("error")); + + let degraded = ProviderUsage::degraded("codex", "no session"); + let json = serde_json::to_string(°raded).unwrap(); + assert!(json.contains("\"error\":\"no session\"")); + assert!(!json.contains("usage")); + } +} From 55b14006787d5219864bedb03708b65a69d02709 Mon Sep 17 00:00:00 2001 From: ualtinok Date: Mon, 20 Jul 2026 09:07:44 +0200 Subject: [PATCH 06/37] cortexkit-provider-usage 0.2.0: add optional apiProvider field (#6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ProviderUsage.api_provider (wire: apiProvider, camelCase, omitted when absent) carrying the canonical models.dev provider slug alongside the CodexBar provider name — e.g. "openai" for provider=="codex", "anthropic" for "claude", "google" for "gemini", "xai" for "grok". Three consumers (ALF's router, the ck CLI, astrocyte's capacity axis) each hand-roll the same CodexBar→canonical translation today; this field lets them key on one canonical name instead. Producers populate it when the canonical name is known and leave it absent for providers with no models.dev counterpart (consumers fall back to provider). Additive + skip-if-none: unpopulated entries serialize byte-identically to 0.1. --- crates/cortexkit-provider-usage/Cargo.toml | 2 +- crates/cortexkit-provider-usage/src/lib.rs | 27 ++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/cortexkit-provider-usage/Cargo.toml b/crates/cortexkit-provider-usage/Cargo.toml index a68f4bc..f4962d7 100644 --- a/crates/cortexkit-provider-usage/Cargo.toml +++ b/crates/cortexkit-provider-usage/Cargo.toml @@ -11,7 +11,7 @@ # says; this crate makes no guarantee about how a producer derived the numbers. [package] name = "cortexkit-provider-usage" -version = "0.1.0" +version = "0.2.0" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/crates/cortexkit-provider-usage/src/lib.rs b/crates/cortexkit-provider-usage/src/lib.rs index be7508a..b6a4d29 100644 --- a/crates/cortexkit-provider-usage/src/lib.rs +++ b/crates/cortexkit-provider-usage/src/lib.rs @@ -131,6 +131,15 @@ fn account_info_is_empty(value: &Option) -> bool { pub struct ProviderUsage { /// CodexBar provider name (e.g. "codex"), which consumers map to their own id. pub provider: String, + /// Canonical API provider identifier — the models.dev slug for the same + /// provider (e.g. "openai" when `provider == "codex"`, "anthropic" for + /// "claude", "google" for "gemini", "xai" for "grok"). Present when the + /// producer knows the canonical name; absent for providers with no models.dev + /// counterpart, where consumers fall back to `provider`. Lets every consumer + /// key on one canonical name instead of each maintaining its own + /// CodexBar-name → canonical map. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub api_provider: Option, #[serde(skip_serializing_if = "Option::is_none")] pub account: Option, /// Which retrieval path produced this (e.g. "oauth") — observability only. @@ -155,6 +164,7 @@ impl ProviderUsage { pub fn healthy(provider: &str, account: Option, source: &str, usage: Usage) -> Self { Self { provider: provider.to_string(), + api_provider: None, account, source: Some(source.to_string()), account_info: None, @@ -170,6 +180,7 @@ impl ProviderUsage { pub fn degraded(provider: &str, error: impl std::fmt::Display) -> Self { Self { provider: provider.to_string(), + api_provider: None, account: None, source: None, account_info: None, @@ -276,4 +287,20 @@ mod tests { assert!(json.contains("\"error\":\"no session\"")); assert!(!json.contains("usage")); } + + #[test] + fn api_provider_is_camel_case_present_when_set_and_omitted_when_absent() { + let mut entry = ProviderUsage::healthy("codex", None, "oauth", Usage::default()); + let json = serde_json::to_string(&entry).unwrap(); + assert!( + !json.contains("apiProvider"), + "absent api_provider must be omitted" + ); + + entry.api_provider = Some("openai".to_string()); + let json = serde_json::to_string(&entry).unwrap(); + assert!(json.contains("\"apiProvider\":\"openai\"")); + let back: ProviderUsage = serde_json::from_str(&json).unwrap(); + assert_eq!(back, entry); + } } From f725fdc3e24e739cb0767c8c0667100ebd1264e8 Mon Sep 17 00:00:00 2001 From: ualtinok Date: Tue, 21 Jul 2026 19:32:33 +0200 Subject: [PATCH 07/37] cortexkit-provider-usage 0.3.0: add optional usedCount + totalCount to RateWindow (#7) * cortexkit-provider-usage 0.3.0: add optional usedCount + totalCount to RateWindow Additive absolute-count fields for windows where the provider knows (or can derive) the consumed and total values. Enables human-facing UIs to show '10,336 / 40,000' alongside the percentage for richer context (e.g. qwen-cloud token-plan where the percentage is entitled but the absolute pair makes the enforcement gap legible). Both fields are skip-if-none + camelCase, so unpopulated windows serialize byte-identically to 0.2.0. * drop accidental self-referencing symlink --- crates/cortexkit-provider-usage/Cargo.toml | 2 +- crates/cortexkit-provider-usage/src/lib.rs | 50 +++++++++++++++++++++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/crates/cortexkit-provider-usage/Cargo.toml b/crates/cortexkit-provider-usage/Cargo.toml index f4962d7..c2fc221 100644 --- a/crates/cortexkit-provider-usage/Cargo.toml +++ b/crates/cortexkit-provider-usage/Cargo.toml @@ -11,7 +11,7 @@ # says; this crate makes no guarantee about how a producer derived the numbers. [package] name = "cortexkit-provider-usage" -version = "0.2.0" +version = "0.3.0" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/crates/cortexkit-provider-usage/src/lib.rs b/crates/cortexkit-provider-usage/src/lib.rs index b6a4d29..a8470bd 100644 --- a/crates/cortexkit-provider-usage/src/lib.rs +++ b/crates/cortexkit-provider-usage/src/lib.rs @@ -22,7 +22,8 @@ //! //! # Serialization contract consumers depend on //! - camelCase keys (`usedPercent`, `resetsAt`, `windowMinutes`, -//! `extraRateWindows`, `rawUsedPercent`, `accountInfo`, `savedResets`). +//! `extraRateWindows`, `rawUsedPercent`, `accountInfo`, `savedResets`, +//! `usedCount`, `totalCount`). //! - A healthy entry MUST NOT carry `error` (consumers skip truthy-`error` //! entries), so it is omitted when absent. //! - A window is emitted when it has a `usedPercent`; `resetsAt` is OPTIONAL and @@ -53,6 +54,15 @@ pub struct RateWindow { /// the consumer then paces on utilization alone rather than a burn rate. #[serde(skip_serializing_if = "Option::is_none")] pub window_minutes: Option, + /// Absolute consumed count in the window (e.g. tokens, requests). Present + /// only when the provider reports or derives it; human-facing UIs can show + /// "10,336 / 40,000" alongside the percentage for richer context. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub used_count: Option, + /// Absolute total cap for the window. Present alongside `used_count` when + /// the provider knows the ceiling; omitted otherwise. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub total_count: Option, } /// A per-model window bundled under one account (e.g. Antigravity's Geminis). @@ -208,6 +218,8 @@ mod tests { raw_used_percent: None, resets_at: None, window_minutes: Some(300), + used_count: None, + total_count: None, }), ..Default::default() }, @@ -257,6 +269,8 @@ mod tests { raw_used_percent: None, resets_at: Some("2026-07-20T00:00:00Z".to_string()), window_minutes: Some(10080), + used_count: None, + total_count: None, }; let json = serde_json::to_string(&unrelaxed).unwrap(); assert!( @@ -269,6 +283,8 @@ mod tests { raw_used_percent: Some(70.0), resets_at: Some("2026-07-20T00:00:00Z".to_string()), window_minutes: Some(10080), + used_count: None, + total_count: None, }; let json = serde_json::to_string(&relaxed).unwrap(); assert!(json.contains("\"rawUsedPercent\":70.0")); @@ -303,4 +319,36 @@ mod tests { let back: ProviderUsage = serde_json::from_str(&json).unwrap(); assert_eq!(back, entry); } + + #[test] + fn used_count_and_total_count_are_camel_case_and_omitted_when_absent() { + let window = RateWindow { + used_percent: 25.8, + raw_used_percent: None, + resets_at: Some("2026-07-26T14:09:00Z".to_string()), + window_minutes: Some(10080), + used_count: None, + total_count: None, + }; + let json = serde_json::to_string(&window).unwrap(); + assert!( + !json.contains("usedCount"), + "absent used_count must be omitted" + ); + assert!( + !json.contains("totalCount"), + "absent total_count must be omitted" + ); + + let enriched = RateWindow { + used_count: Some(10336.0), + total_count: Some(40000.0), + ..window + }; + let json = serde_json::to_string(&enriched).unwrap(); + assert!(json.contains("\"usedCount\":10336.0")); + assert!(json.contains("\"totalCount\":40000.0")); + let back: RateWindow = serde_json::from_str(&json).unwrap(); + assert_eq!(back, enriched); + } } From e1323024135513a483d9dca1b0510d7c5b1e7900 Mon Sep 17 00:00:00 2001 From: ualtinok Date: Tue, 28 Jul 2026 23:05:56 +0200 Subject: [PATCH 08/37] provider-usage: add errorClass, a machine-readable reason for a degraded entry (#8) Every degraded entry looks alike on this wire. A provider nobody configured and a provider whose credential broke this morning both arrive as an entry with an error string, and the only thing telling them apart is that string -- which is prose with no stability promise, and which consumers are explicitly told not to parse. The consequence is a state change with no observable: a provider that worked yesterday and failed today moves a count by one and produces no other signal. On the current host that is 3 genuinely failing providers hidden among 25 that are unconfigured and always will be. errorClass carries the reason as a stable name derived from the producer taxonomy rather than from the message text. Classes today: credential_absent, credential_unusable, credential_rejected, no_quota_reported, upstream_failed, decode_failed. It is a String rather than an enum deliberately. The class list will grow, and on an observability surface an unrecognised value must not become a parse failure -- that would make a provider vanish from the output at exactly the moment its state changed. Consumers render an unknown class as degraded-with-unknown-reason, and a test pins that an unknown value decodes intact. Additive and non-breaking: absent on healthy entries, omitted when unset, and a producer that never sets it serializes exactly as before, which is also pinned by a test. degraded() is unchanged for existing callers; degraded_with_class() is the constructor for producers that know the class. --- crates/cortexkit-provider-usage/Cargo.toml | 2 +- crates/cortexkit-provider-usage/src/lib.rs | 117 +++++++++++++++++++++ 2 files changed, 118 insertions(+), 1 deletion(-) diff --git a/crates/cortexkit-provider-usage/Cargo.toml b/crates/cortexkit-provider-usage/Cargo.toml index c2fc221..cba7f2a 100644 --- a/crates/cortexkit-provider-usage/Cargo.toml +++ b/crates/cortexkit-provider-usage/Cargo.toml @@ -11,7 +11,7 @@ # says; this crate makes no guarantee about how a producer derived the numbers. [package] name = "cortexkit-provider-usage" -version = "0.3.0" +version = "0.4.0" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/crates/cortexkit-provider-usage/src/lib.rs b/crates/cortexkit-provider-usage/src/lib.rs index a8470bd..cf82a5e 100644 --- a/crates/cortexkit-provider-usage/src/lib.rs +++ b/crates/cortexkit-provider-usage/src/lib.rs @@ -167,6 +167,38 @@ pub struct ProviderUsage { /// truthy `error`. #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, + /// A stable, machine-readable name for *why* a degraded entry failed, + /// published beside the human-readable `error`. + /// + /// `error` is prose with no stability promise, so consumers are told not to + /// branch on it — which leaves them no way to separate failures that mean + /// something from failures that are a permanent, correct state. A host that + /// never configured a provider and a host whose credential broke this + /// morning both produce a degraded entry, and only the second is worth + /// anyone's attention. + /// + /// Classes currently produced: + /// + /// | Value | Meaning | + /// |---|---| + /// | `credential_absent` | No credential was found. Permanent and correct on a host that never configured this provider; nothing to fix. | + /// | `credential_unusable` | A credential was found but cannot be used as it stands (empty, incomplete, or refused by the credential store). Someone configured this and it needs fixing. | + /// | `credential_rejected` | The upstream rejected the credential (401/403). Usually means logging in again. | + /// | `no_quota_reported` | The credential works and the account genuinely has no quota to report. Not a failure. | + /// | `upstream_failed` | The upstream could not be reached or returned an error status. Usually transient. | + /// | `decode_failed` | The response arrived but was not the expected shape. | + /// + /// **This list will grow.** A consumer must render an unrecognised class as + /// a degraded entry with an unknown reason — never drop the entry, and + /// never fold it into an existing bucket. It is a `String` rather than an + /// enum for exactly that reason: on an observability surface, meeting an + /// unknown value must not turn into a parse failure that makes a provider + /// disappear at the moment its state changed. + /// + /// Absent on healthy entries, and absent from any producer that predates + /// this field. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub error_class: Option, } impl ProviderUsage { @@ -182,6 +214,7 @@ impl ProviderUsage { saved_resets: None, usage: Some(usage), error: None, + error_class: None, } } @@ -198,6 +231,25 @@ impl ProviderUsage { saved_resets: None, usage: None, error: Some(error.to_string()), + error_class: None, + } + } + + /// A degraded entry that also names *why* it failed. + /// + /// Prefer this over [`Self::degraded`] wherever the producer knows the + /// class: without it a consumer can only tell an unconfigured provider from + /// a broken one by reading prose it has been told not to parse. See + /// [`ProviderUsage::error_class`] for the classes and for the rule that an + /// unrecognised one must still render. + pub fn degraded_with_class( + provider: &str, + error: impl std::fmt::Display, + error_class: impl Into, + ) -> Self { + Self { + error_class: Some(error_class.into()), + ..Self::degraded(provider, error) } } } @@ -351,4 +403,69 @@ mod tests { let back: RateWindow = serde_json::from_str(&json).unwrap(); assert_eq!(back, enriched); } + + /// The field is additive: a producer that does not set it must serialize + /// exactly as before, or adding it changes every existing entry on the wire. + #[test] + fn an_entry_without_a_class_serializes_as_it_did_before_the_field_existed() { + let entry = ProviderUsage::degraded("codex", "no session: nothing configured"); + let json = serde_json::to_string(&entry).unwrap(); + + assert!(!json.contains("errorClass"), "absent class must be omitted"); + // Not vacuous: the entry really is a degraded one carrying its message, + // so this cannot pass by serializing something empty. + assert!(json.contains("\"error\":\"no session: nothing configured\"")); + + let back: ProviderUsage = serde_json::from_str(&json).unwrap(); + assert_eq!(back, entry); + assert_eq!(back.error_class, None); + } + + #[test] + fn a_class_round_trips_under_its_camel_case_wire_name() { + let entry = ProviderUsage::degraded_with_class( + "gemini", + "credential unusable: gemini creds have no refresh_token", + "credential_unusable", + ); + let json = serde_json::to_string(&entry).unwrap(); + + assert!(json.contains("\"errorClass\":\"credential_unusable\"")); + let back: ProviderUsage = serde_json::from_str(&json).unwrap(); + assert_eq!(back, entry); + } + + /// The classes are open by design, so a consumer built today must survive a + /// producer that ships a class it has never heard of. Modelling the field as + /// a `String` is what buys that: an enum would make this a parse failure, + /// and on an observability surface a parse failure means the entry vanishes + /// at the moment its state changed. + #[test] + fn an_unknown_class_decodes_rather_than_failing() { + let json = r#"{"provider":"someprovider","error":"something new","errorClass":"a_class_from_the_future"}"#; + + let entry: ProviderUsage = + serde_json::from_str(json).expect("an unrecognised class must not fail to decode"); + + assert_eq!( + entry.error_class.as_deref(), + Some("a_class_from_the_future") + ); + // The rest of the entry survives intact, so a consumer can still render + // it as degraded-with-unknown-reason rather than dropping it. + assert_eq!(entry.provider, "someprovider"); + assert_eq!(entry.error.as_deref(), Some("something new")); + } + + /// A healthy entry must never carry a class: the field's presence is itself + /// a signal, and a class on a working provider would be a contradiction a + /// consumer has to resolve. + #[test] + fn a_healthy_entry_carries_no_class() { + let entry = ProviderUsage::healthy("codex", None, "oauth", Usage::default()); + assert_eq!(entry.error_class, None); + assert!(!serde_json::to_string(&entry) + .unwrap() + .contains("errorClass")); + } } From 49bcaa267df97498280b33a9d29c8bc93f622c4f Mon Sep 17 00:00:00 2001 From: ualtinok Date: Fri, 31 Jul 2026 20:25:06 +0200 Subject: [PATCH 09/37] store: make the database, its WAL and lease files owner-only (#9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SQLite sets no mode of its own, so open_sqlite left the file mode to the caller's umask and the shipped default was world-readable 0644. Measured on a real deployment: 11 of 11 module stores at 0644, including cortexkit-credentials. A crate that already decides WAL mode, busy timeout and foreign keys has taken responsibility for how the file behaves on disk. Leaving permissions to callers means the decision is made by the ambient umask, which is to say not made at all. A group-readable store is not a configuration this crate supports anyway: it hands out an exclusive single-writer lease, so an out-of-band reader is already outside the contract. The exposure, stated honestly rather than as an implied multi-user threat: a single-account host has no other human to read these files. What 0644 does expose them to is every process running as this user — every module, every worker, every tool — and anything that copies the tree: a backup, a restore, an install into a shared location, a container bind-mount. THE WAL IS THE HALF THAT GETS MISSED. Recently committed rows live there until a checkpoint, so protecting only the database leaves the newest data readable while the database file itself reads as correct. On the measured host the WALs are routinely larger than their databases — alfonso-core's is 23MB. Lease files are hardened too, in cortexkit-lease where they are created. Their exposure is integrity rather than privacy: the lease carries the persisted epoch that is the single-writer fence token, so a writable lease file lets a stale writer's fence be forged. Applied on OPEN rather than only at creation, because every store already deployed was created at 0644 — a creation-time-only fix protects exactly the installations with no history. A path that is not a regular file is refused rather than adjusted: following a symlink would chmod a file the caller never named, which is a privilege-escalation primitive wearing a hardening step's clothes. Each assertion is mutation-proved separately. Worth recording that the first version of the WAL test could not fail: it used a FIRST open, where SQLite creates the WAL inheriting the database's already-corrected mode. Dropping '-wal' from the protected suffixes passed it. The test now reopens a store with a leftover permissive WAL — the state an unclean shutdown leaves behind, and the only one where a permissive WAL can be waiting at open time. --- crates/cortexkit-lease/src/lib.rs | 154 ++++++++++++++++++++++++++++++ crates/cortexkit-store/src/lib.rs | 117 ++++++++++++++++++++++- 2 files changed, 270 insertions(+), 1 deletion(-) diff --git a/crates/cortexkit-lease/src/lib.rs b/crates/cortexkit-lease/src/lib.rs index f316ecd..da8f95b 100644 --- a/crates/cortexkit-lease/src/lib.rs +++ b/crates/cortexkit-lease/src/lib.rs @@ -35,6 +35,55 @@ use std::{ use fs2::FileExt; +/// Force owner-only permissions on a file this process owns the lifecycle of. +/// +/// Files created through `File::create` or `OpenOptions::create` get their mode +/// from the process umask, which on a default system means `0644` — readable by +/// every other account and, more to the point, by every other process running +/// as this user. That is the exposure that actually exists on a +/// single-account machine: every module, every worker, every tool, plus +/// anything that copies the tree (a backup, a restore, an `install` into a +/// shared location, a container bind-mount). +/// +/// Applied on OPEN rather than only at creation, because a file that already +/// exists carries whatever mode it was given — by an older build, a copy, or a +/// restore. A creation-time-only fix protects exactly the installations with no +/// history and leaves the ones that matter permissive forever. +/// +/// A path that is not a regular file is REFUSED rather than adjusted. Following +/// a symlink here would chmod a file the caller never named, which is a +/// privilege-escalation primitive wearing a hardening step's clothes. +/// +/// A missing file is not an error: callers pass optional sidecars (a WAL that +/// exists only while the journal is active) and the absence of a file is +/// nothing to protect. +pub fn protect_file(path: &std::path::Path) -> std::io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let metadata = match std::fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error), + }; + if !metadata.is_file() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!( + "{} is not a regular file; refusing to change its permissions", + path.display() + ), + )); + } + if metadata.permissions().mode() & 0o777 != 0o600 { + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; + } + } + #[cfg(not(unix))] + let _ = path; + Ok(()) +} + /// Identifies the thing being single-writer-guarded, namespaced so distinct /// modules cannot collide on a shared lease root. /// @@ -196,6 +245,11 @@ impl LeaseStore for FileLeaseStore { .truncate(false) .open(&path) .map_err(LeaseError::Io)?; + // The lease is the single-writer fence, so a world-WRITABLE lease file is + // an integrity question rather than a privacy one: anything able to write + // the persisted epoch can forge the fence token that readers use to + // detect a stale writer. + protect_file(&path).map_err(LeaseError::Io)?; // Liveness gate: a live holder still owns the lock, so the try-lock fails // with the OS "contended" error. @@ -230,6 +284,7 @@ impl LeaseStore for FileLeaseStore { .truncate(false) .open(&path) .map_err(LeaseError::Io)?; + protect_file(&path).map_err(LeaseError::Io)?; // Shared liveness gate: only an exclusive holder contends; other shared // holders coexist. On unix this is flock(LOCK_SH|LOCK_NB); on Windows, @@ -323,6 +378,105 @@ mod tests { (FileLeaseStore::new(&dir), dir) } + /// An acquired lease file is owner-only on disk, including one that already + /// exists with a permissive mode. + /// + /// The pre-existing half is the case that bites: every lease already on a + /// deployed machine was created at the umask default, so correcting only at + /// creation time would protect exactly the installations with no history. + /// + /// The lease is the single-writer FENCE, so the exposure here is integrity + /// rather than privacy — anything able to write the persisted epoch can + /// forge the token readers use to detect a stale writer. + /// + /// Mutation-proved: removing the `protect_file` call from `acquire` fails + /// this on the pre-existing case. + #[cfg(unix)] + #[test] + fn an_acquired_lease_file_is_owner_only() { + use std::os::unix::fs::PermissionsExt; + + let (store, dir) = tmp_store(); + let k = key("perm"); + + // Reproduce a lease left behind by an older build at the umask default. + std::fs::create_dir_all(&dir).expect("create lease dir"); + let path = store.lease_path(&k); + std::fs::write(&path, b"").expect("pre-create lease file"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)) + .expect("set permissive mode"); + + let guard = store.acquire(&k).expect("acquire"); + let mode = std::fs::metadata(&path) + .expect("stat lease") + .permissions() + .mode() + & 0o777; + assert_eq!( + mode, 0o600, + "the lease file stayed group/world writable at {mode:o}" + ); + + drop(guard); + let _ = std::fs::remove_dir_all(&dir); + } + + /// `protect_file` refuses a symlink rather than following it. + /// + /// Following one would change the mode of a file the caller never named, + /// which is a privilege-escalation primitive wearing a hardening step's + /// clothes. The assertion is that the TARGET's mode is unchanged, not + /// merely that an error came back — an implementation could chmod the + /// target and still return Err. + #[cfg(unix)] + #[test] + fn protect_file_refuses_a_symlink_and_leaves_its_target_untouched() { + use std::os::unix::fs::PermissionsExt; + + let dir = std::env::temp_dir().join(format!( + "cortexkit-lease-symlink-{}-{}", + std::process::id(), + fnv1a_hex(&format!("{:?}", std::time::Instant::now())) + )); + std::fs::create_dir_all(&dir).expect("create dir"); + let target = dir.join("target"); + std::fs::write(&target, b"not mine").expect("write target"); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o644)) + .expect("set target mode"); + let link = dir.join("link"); + std::os::unix::fs::symlink(&target, &link).expect("symlink"); + + assert!( + protect_file(&link).is_err(), + "a symlink must be refused rather than followed" + ); + let mode = std::fs::metadata(&target) + .expect("stat target") + .permissions() + .mode() + & 0o777; + assert_eq!( + mode, 0o644, + "the symlink target was chmod-ed through the link" + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + /// A path that does not exist is not an error: callers pass optional + /// sidecars (a WAL that exists only while the journal is active), and the + /// absence of a file is nothing to protect. Without this, a first open of a + /// fresh database would fail on its missing WAL. + #[test] + fn protect_file_ignores_a_missing_path() { + let missing = std::env::temp_dir().join(format!( + "cortexkit-lease-absent-{}-{}", + std::process::id(), + fnv1a_hex(&format!("{:?}", std::time::Instant::now())) + )); + assert!(protect_file(&missing).is_ok()); + } + #[test] fn acquire_then_second_holder_is_rejected() { let (store, dir) = tmp_store(); diff --git a/crates/cortexkit-store/src/lib.rs b/crates/cortexkit-store/src/lib.rs index e8cfd0d..b4f0db7 100644 --- a/crates/cortexkit-store/src/lib.rs +++ b/crates/cortexkit-store/src/lib.rs @@ -102,7 +102,7 @@ mod sqlite_backend { time::{Duration, SystemTime, UNIX_EPOCH}, }; - use cortexkit_lease::{FileLeaseStore, LeaseHandle}; + use cortexkit_lease::{protect_file, FileLeaseStore, LeaseHandle}; use rusqlite::Connection; /// A lease-guarded, migrated sqlite store. Holds the single-writer lease for @@ -291,6 +291,34 @@ mod sqlite_backend { conn.pragma_update(None, "foreign_keys", "ON") .map_err(|e| StoreError::Backend(e.to_string()))?; + // Owner-only, decided here rather than left to the caller's umask. + // + // SQLite sets no mode of its own, so without this the shipped default is + // world-readable `0644` — measured across every module store on a real + // deployment. A crate that already decides WAL mode, busy timeout and + // foreign keys has taken responsibility for how this file behaves on + // disk; leaving permissions to callers means the decision is made by the + // ambient umask, which is to say not made at all. + // + // The WAL and SHM siblings are the half that gets missed. Recently + // committed rows live in the WAL until a checkpoint, so protecting only + // the database file leaves the NEWEST data permissive while the database + // itself reads as correct — and on real hosts the WAL is routinely + // larger than the database. + // + // Ordering: after the pragma that ENABLES WAL, so the sibling files + // exist to be protected on a first open rather than being created + // unprotected immediately afterwards. + // + // A group-readable store is not a configuration this crate supports: it + // hands out an exclusive single-writer lease, so an out-of-band reader + // is already outside the contract. That need is a read replica or an + // export operation, not a looser file mode. + for suffix in ["", "-wal", "-shm"] { + protect_file(Path::new(&format!("{path}{suffix}"))) + .map_err(|e| StoreError::Backend(e.to_string()))?; + } + Ok(SqliteStore { conn: Mutex::new(conn), epoch, @@ -371,6 +399,93 @@ pub use sqlite_backend::{open_sqlite, SqliteStore}; mod tests { use super::*; + /// The database file AND its WAL sibling are owner-only on disk after + /// `open_sqlite`, including a database that already exists permissively. + /// + /// Two properties, asserted separately because they fail independently. + /// SQLite sets no mode, so without this the shipped default is `0644`. + /// And the WAL is the half that gets missed: recently committed rows live + /// there until a checkpoint, so protecting only the database leaves the + /// NEWEST data readable while the database file itself looks correct. + /// + /// Asserts the mode ON DISK rather than that `open_sqlite` returned Ok — a + /// hardening step that silently did nothing would still return Ok. + /// + /// The scenario is REOPENING an already-deployed store, which is the only + /// shape in which the WAL assertion means anything. A first open cannot + /// exercise it: SQLite creates a fresh WAL inheriting the database's mode, + /// so by then the database is already `0600` and the WAL follows for free. + /// A permissive WAL only exists because a PREVIOUS process wrote one under + /// the old umask — exactly the state every deployed machine is in. + /// + /// Mutation-proved, both suffixes independently: dropping `""` fails the + /// database assertion, dropping `"-wal"` fails the WAL assertion, and + /// neither is carried by the other. An earlier version of this test used a + /// first open and the `"-wal"` mutation SURVIVED it — the assertion was + /// there, it just could not fail. + #[cfg(unix)] + #[test] + fn reopening_a_permissive_store_protects_the_database_and_its_wal() { + use std::os::unix::fs::PermissionsExt; + + let (root, descriptor) = tmp(); + let StorageBackend::Sqlite { path } = &descriptor.backend else { + panic!("sqlite descriptor"); + }; + let path = std::path::PathBuf::from(path); + let wal = std::path::PathBuf::from(format!("{}-wal", path.display())); + + // First open: create a real database, then close it. + { + let store = open_sqlite(&descriptor).expect("first open"); + store + .migrate( + "perm", + &[Migration { + version: 1, + statements: "CREATE TABLE t (k TEXT);", + }], + ) + .expect("migrate"); + } + + // A clean close checkpoints and REMOVES the WAL, so one has to be put + // back deliberately. That is not artificial: a WAL surviving on disk is + // precisely what an unclean shutdown leaves behind, and it is the only + // state in which a permissive WAL can be waiting at open time. + std::fs::write(&wal, b"").expect("leave a WAL behind"); + + // Reproduce the deployed state: both files permissive, as every store + // created before this hardening actually is on disk. + for file in [&path, &wal] { + std::fs::set_permissions(file, std::fs::Permissions::from_mode(0o644)) + .expect("set permissive mode"); + } + + let store = open_sqlite(&descriptor).expect("reopen"); + + let mode = |p: &std::path::Path| { + std::fs::metadata(p) + .unwrap_or_else(|error| panic!("stat {}: {error}", p.display())) + .permissions() + .mode() + & 0o777 + }; + assert_eq!( + mode(&path), + 0o600, + "the database stayed group/world readable on reopen" + ); + assert_eq!( + mode(&wal), + 0o600, + "the WAL stayed group/world readable while the database looked correct" + ); + + drop(store); + let _ = std::fs::remove_dir_all(&root); + } + /// A unique temp root + a descriptor whose sqlite file lives under it. The /// lease is derived from the db path (its parent), so no separate lease dir. fn tmp() -> (std::path::PathBuf, StorageDescriptor) { From 94486ed5da5df3b3717287da6a8b201e69fbf048 Mon Sep 17 00:00:00 2001 From: ualtinok Date: Thu, 6 Aug 2026 21:55:56 +0200 Subject: [PATCH 10/37] docs: releasing a crate The release procedure was undocumented, and its one visible artifact -- three lightweight tags -- reads like a convention that does not exist. The trigger matches on ref name, so tag shape cannot matter; that is not discoverable without reading the workflow, and someone will otherwise spend attention on it during an incident deciding whether a silent release is their tag's fault. Also records what to do when a tag produces no run: check service health first, confirm nothing is queued before retagging at the same commit (the concurrency group deliberately does not protect that case), and bind any wait to the published version rather than to a run appearing. --- docs/releasing.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 docs/releasing.md diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..e577fa9 --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,46 @@ +# Releasing a crate + +Each crate in this workspace versions and publishes independently. A release is +triggered by pushing a tag; there is no manual publish step. + +## Procedure + +1. Bump the crate's version in its `Cargo.toml` and merge to `master`. +2. Wait for CI to pass on that commit. +3. Tag it `-v` — for example `cortexkit-paths-v0.1.1` — and push + the tag. + +The workflow re-runs the full test matrix (Linux, macOS, Windows) before +publishing, reusing the CI workflow rather than copying it, so a release cannot +ship code that would fail CI. It then parses the crate and version out of the tag +and refuses to publish if they disagree with that crate's `Cargo.toml`, which +catches tagging a stale version. + +## Tag shape does not matter + +Every tag in this repository's history happens to be lightweight, which reads like +a convention and is not one. The trigger matches on the tag's **ref name** +(`*-v*`); annotated and lightweight tags push the same ref, and the object type +behind it is invisible to the trigger. Either works. + +This is written down because the inference is natural, the correction is not +discoverable without reading the workflow, and someone will otherwise spend +attention on it at the exact moment they have none — during an incident, deciding +whether a silent release is their tag's fault. + +## When a tag produces no run + +Check whether the release service is degraded before changing anything. A failed +publish and a pending one look identical: no run appears for the ref in either +case. + +If the service is healthy and there is still no run, delete and re-push the tag. +Before doing so, **confirm no run is already queued for it** — a retag at the +*same* commit lands in the same concurrency group, so a stuck queued run would +swallow the retry. The group deliberately includes the commit SHA, which protects +a retag at a *different* commit and not this case. + +Bind any wait to **the crates.io version changing**, not to a run appearing. The +published version is the fact; the workflow run is one mechanism for producing it. +A green run that publishes nothing cannot satisfy the first check, and a publish +that happens by another route still can. From 28f28f4c2b2f2492ad157f5de1c847142886f904 Mon Sep 17 00:00:00 2001 From: ualtinok Date: Fri, 7 Aug 2026 13:53:08 +0200 Subject: [PATCH 11/37] Say what absence means for every optional field (#10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Say what absence means for every optional field Sixteen of the twenty-four optional fields on ProviderUsage carried no doc comment at all. A consumer reading the type — which is where a Rust consumer stands, rather than in the producer's markdown contract — had to infer what each absence meant, and the inference that reads as unremarkable is usually the fail-open one. Doc comments only; no shape, serde attribute, or behaviour changes. Three of these are load-bearing rather than tidy: - The three window slots CAN HAVE HOLES. Each is filled from its own optional upstream field, so `secondary` may be absent while `tertiary` is present. A consumer stopping at the first gap misses real limits. - `saved_resets` absent is NOT "zero credits held". It also covers the credit-inventory lookup having failed on that fetch, since that lookup is separate from the usage fetch and may fail without degrading the entry. - `account` absent means the producer could not resolve an identity, not that the provider has one account — so an unlabelled entry is not evidence that a labelled one does not exist. The rest state the same thing in smaller ways: an absent `org_name` is not a personal account, an absent `plan_type` is not a missing plan, an absent `window` on an ExtraWindow is a limit whose figure could not be read rather than an absent limit. * State the consequence on the two fields most likely to be misread A doc comment that states a fact invites agreement; one that states what breaks invites care. Two of these were facts only. `raw_used_percent` said what it is and that UIs should display it, and never said which number to pace on. The natural reading — the raw figure is the truer one — routes work away from an account whose credit is about to be spent, and the credit expires whether or not it is used, so the cautious-looking reading is the lossy one. `source` said "observability only", which is true and does not stop anyone keying on it. It is per-lane rather than per-account and changes between polls with nothing having changed about the account, so a consumer treating a change as an event sees phantom transitions. --- crates/cortexkit-provider-usage/src/lib.rs | 83 +++++++++++++++++++++- 1 file changed, 81 insertions(+), 2 deletions(-) diff --git a/crates/cortexkit-provider-usage/src/lib.rs b/crates/cortexkit-provider-usage/src/lib.rs index cf82a5e..abd0453 100644 --- a/crates/cortexkit-provider-usage/src/lib.rs +++ b/crates/cortexkit-provider-usage/src/lib.rs @@ -41,8 +41,20 @@ pub struct RateWindow { pub used_percent: f64, /// The provider-reported percent when `used_percent` has been relaxed to /// an effective value (banked resets guarantee the window resets before - /// the wall). Present only on relaxed windows; human-facing UIs should - /// display this truth alongside the effective number. + /// the wall). + /// + /// **Pace on `used_percent`, not on this.** The effective number is the real + /// headroom: a reset that is going to happen has already been accounted for. + /// Treating this as the truer figure routes work away from an account whose + /// credit is about to be spent — and the credit expires whether or not it is + /// used, so the cautious-looking reading is the lossy one. Display it beside + /// the effective number in a human-facing view, where a zero next to real + /// consumption would otherwise look like a fault. + /// + /// Emitted **only where the two diverge**, so its absence means they agree + /// and falling back to `used_percent` is exact rather than approximate. + /// Rendering a placeholder for absence would be wrong on every unrelaxed + /// window, which is nearly all of them. #[serde(skip_serializing_if = "Option::is_none", default)] pub raw_used_percent: Option, /// ISO 8601 / RFC 3339 timestamp when the window resets. Omitted when the @@ -69,10 +81,19 @@ pub struct RateWindow { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ExtraWindow { + /// Human-facing label. Absent when the producer has no display text for this + /// window; render `id` instead rather than dropping the entry. #[serde(skip_serializing_if = "Option::is_none")] pub title: Option, + /// Stable identifier to match on. Absent when the producer cannot name the + /// window stably. **Not unique across providers** — one provider's ids are + /// model names, another's its own scope labels — so key on + /// `(provider, id)`, never on `id` alone. #[serde(skip_serializing_if = "Option::is_none")] pub id: Option, + /// The figures for this window. Absent means the provider **named a limit it + /// could not read a figure for**, which is not the same as no limit: the + /// entry is still evidence the limit exists. #[serde(skip_serializing_if = "Option::is_none")] pub window: Option, } @@ -82,12 +103,30 @@ pub struct ExtraWindow { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] #[serde(rename_all = "camelCase")] pub struct Usage { + /// The provider's **shortest** window, not its most constrained one. Absent + /// when the provider reported no window of that cadence. + /// + /// The three slots are positions, not a ranking, and **they can have holes**: + /// each is filled from its own optional upstream field, so `secondary` may be + /// absent while `tertiary` is present. Walk all three plus + /// `extra_rate_windows` rather than stopping at the first gap, and take the + /// maximum when asking how much headroom an account has. #[serde(skip_serializing_if = "Option::is_none")] pub primary: Option, + /// The next cadence up, typically weekly. Absent means not reported — never + /// that the window exists at zero. See [`Usage::primary`] on slot holes. #[serde(skip_serializing_if = "Option::is_none")] pub secondary: Option, + /// A third account-wide window where a provider has one. Absent means not + /// reported. See [`Usage::primary`] on slot holes. #[serde(skip_serializing_if = "Option::is_none")] pub tertiary: Option, + /// Windows whose meaning has no slot — per-model pools, scoped weeklies. + /// Absent means the provider published none. + /// + /// These are **real limits**, not extras in the dispensable sense: a consumer + /// ignoring this list silently ignores whichever limits did not fit three + /// slots. #[serde(skip_serializing_if = "Option::is_none")] pub extra_rate_windows: Option>, } @@ -96,10 +135,17 @@ pub struct Usage { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)] #[serde(rename_all = "camelCase")] pub struct AccountInfo { + /// Account email. Absent when the upstream does not identify the account that + /// way — not a signal about the account itself. #[serde(skip_serializing_if = "Option::is_none", default)] pub email: Option, + /// Organisation label. Absent when the upstream reports none; absent does not + /// mean a personal account. #[serde(skip_serializing_if = "Option::is_none", default)] pub org_name: Option, + /// The upstream's **own** plan label, not a normalised vocabulary, so it is + /// not comparable across providers. Display and grouping only. Absent when + /// the upstream states no plan. #[serde(skip_serializing_if = "Option::is_none", default)] pub plan_type: Option, } @@ -123,6 +169,8 @@ pub struct CreditExpiry { pub struct SavedResets { #[serde(default)] pub available_count: u32, + /// When the next credit lapses. Absent means **no credit states an expiry**, + /// which is not the same as none expiring soon. #[serde(skip_serializing_if = "Option::is_none", default)] pub soonest_expires_at: Option, #[serde(default)] @@ -150,17 +198,48 @@ pub struct ProviderUsage { /// CodexBar-name → canonical map. #[serde(skip_serializing_if = "Option::is_none", default)] pub api_provider: Option, + /// The account this entry describes, as the credential store identifies it. + /// + /// Absent means the producer **could not resolve an identity for this + /// credential**, not that the provider has one account. Some credentials + /// carry no account identity at all (a bare API key), and an entry is also + /// emitted unlabelled while an identity is still being confirmed — so an + /// unlabelled entry is not evidence that a labelled one does not exist. #[serde(skip_serializing_if = "Option::is_none")] pub account: Option, /// Which retrieval path produced this (e.g. "oauth") — observability only. + /// + /// **Per lane, not per account, and it moves.** One account can be reached + /// through more than one credential path, and which one answers is decided + /// per fetch by whichever is healthy. So the same account can report one + /// value on a poll and another on the next with nothing having changed about + /// the account, the credential, or anything the consumer did. Do not key on + /// it, branch on it, or treat a change in it as an event. #[serde(skip_serializing_if = "Option::is_none")] pub source: Option, + /// Display labels for the account. Absent when the upstream supplies none of + /// them; carries no operational meaning. #[serde(skip_serializing_if = "account_info_is_empty", default)] pub account_info: Option, + /// When this entry's figures were last **successfully** fetched — producer + /// time, per entry, never a common instant across the array. + /// + /// Absent means this credential has never had a successful fetch. It keeps + /// its old value while a failure is being retried, so it ages honestly rather + /// than pausing; never restamp it with your own poll time. #[serde(skip_serializing_if = "Option::is_none", default)] pub fetched_at: Option, + /// Banked quota-reset credits held by this account. + /// + /// Absent means there is no credit inventory to report — which includes the + /// inventory lookup having **failed** on this fetch, since it is separate + /// from the usage fetch and may fail without degrading the entry. Absent is + /// therefore not "zero credits held". #[serde(skip_serializing_if = "Option::is_none", default)] pub saved_resets: Option, + /// The windows. Absent on a degraded entry, and on an entry whose credential + /// works but whose account reports no quota at all — read `error` and + /// `error_class` to tell those apart, rather than inferring from this field. #[serde(skip_serializing_if = "Option::is_none")] pub usage: Option, /// Present only on a degraded entry. The consumer skips any entry with a From 48a7ea340beda7662053ba021eb5c63603824be0 Mon Sep 17 00:00:00 2001 From: ualtinok Date: Fri, 7 Aug 2026 14:00:17 +0200 Subject: [PATCH 12/37] provider-usage: 0.4.1 for the absence documentation The absence docs merged at version 0.4.0, which is already published and immutable -- so commons master and the published 0.4.0 differed in content at the same version number, and every registry consumer (including the crate's own author) still compiled against the sixteen bare fields. docs.rs renders published releases, so the type a consumer hovers was the undocumented one. Doc-only patch bump. A caret pin on 0.4 picks it up on the next update with no manifest change. --- crates/cortexkit-provider-usage/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/cortexkit-provider-usage/Cargo.toml b/crates/cortexkit-provider-usage/Cargo.toml index cba7f2a..6128ac2 100644 --- a/crates/cortexkit-provider-usage/Cargo.toml +++ b/crates/cortexkit-provider-usage/Cargo.toml @@ -11,7 +11,7 @@ # says; this crate makes no guarantee about how a producer derived the numbers. [package] name = "cortexkit-provider-usage" -version = "0.4.0" +version = "0.4.1" edition.workspace = true license.workspace = true repository.workspace = true From bc88d51fafd5d4016fdf97bb041282aab05b5520 Mon Sep 17 00:00:00 2001 From: ualtinok Date: Sat, 8 Aug 2026 05:05:24 +0200 Subject: [PATCH 13/37] paths: address a project root after it has gone away A durably-paused run can only be ended by connecting to it, and the daemon refuses to bind a connection whose project root no longer exists. Cancel is the only exit from such a run, so both exits close together and the run sits intact and permanently unreachable. Thirteen were stranded on this host, ten of them by a directory rename, and repo renames keep feeding the class. The existing rejection is deliberate and documented, and it is load-bearing elsewhere: the projects registry uses it to DETECT dead roots, refusing registration of one and sorting vanished pairs into a missing bucket. So this adds a second constructor rather than changing the first, and the amended rationale on the strict one points at it. The fallback resolves the longest existing prefix and re-appends the rest, which is POSIX realpath's behaviour on a non-existent path -- canonicalize is the outlier in refusing partial resolution, so this matches a documented reference rather than inventing a rule. Lexical normalization was the obvious alternative and is wrong: consumers key durable state on the resolved string, and on macOS every temp directory is reached through a symlink, so a lexical result is a different string from the id minted while the root existed. The caller would bind successfully and address an empty lineage -- a confident "no such thing" rather than an error, which is worse than the bug. A dangling link reads as absent to both canonicalize and Path::exists because both follow links, so the naive walk-up stops one component too high and keeps the link's own name. Following it instead is what realpath does and is the choice that survives repair: if someone later creates the target, the strict constructor produces the same id, so ordinary maintenance cannot strand work admitted while the link dangled. Mutation-proved against three alternatives: lexical normalization reddens three tests, keeping the link name reddens the repair test, and relaxing the strict constructor reddens the refusal tests that the projects registry depends on. --- crates/cortexkit-paths/src/lib.rs | 223 ++++++++++++++++++++++++++++++ 1 file changed, 223 insertions(+) diff --git a/crates/cortexkit-paths/src/lib.rs b/crates/cortexkit-paths/src/lib.rs index 9190ef0..9765b0f 100644 --- a/crates/cortexkit-paths/src/lib.rs +++ b/crates/cortexkit-paths/src/lib.rs @@ -35,6 +35,12 @@ impl ProjectRootId { /// instead of being logically normalized. That policy avoids silently /// aliasing roots whose future meaning could change when missing path /// components or symlinks are later created. + /// + /// That rejection is load-bearing for callers who use it to DETECT a root + /// that has gone away, so this constructor keeps it. Callers that must still + /// address a vanished root -- ending or inspecting work that was admitted + /// while the root existed -- use [`Self::from_path_allowing_missing`], which + /// preserves the aliasing guarantee by a narrower means. pub fn from_path(path: impl AsRef) -> Result { let requested_path = path.as_ref().to_path_buf(); match fs::canonicalize(path.as_ref()) { @@ -51,6 +57,34 @@ impl ProjectRootId { } } + /// Resolve a path into a project-root id even when the path no longer exists. + /// + /// Resolves the longest prefix that still exists and re-appends the rest, + /// following any symlink encountered on the missing tail. This is the + /// behaviour of POSIX `realpath` on a non-existent path; [`fs::canonicalize`] + /// is the outlier in refusing partial resolution, so this matches a + /// documented reference rather than inventing a rule. + /// + /// WHY NOT LEXICAL NORMALIZATION: consumers key durable state on the + /// resolved string. On macOS every temporary directory is reached through a + /// symlink, so a lexically-normalized path is a DIFFERENT string from the id + /// minted while the root existed -- the caller would address an empty + /// lineage and receive a confident "no such thing" rather than an error. + /// That is one caller, one spelling, and two ids across time. + /// + /// WHAT THIS DOES NOT PROMISE: if a missing component later reappears as a + /// symlink pointing elsewhere, the id moves. [`Self::from_path`] does not + /// prevent that either -- it declines to answer while the component is + /// missing and then resolves through the new link exactly as this does, so + /// the hazard is shared rather than introduced here. The aliasing guarantee + /// the strict constructor exists for is preserved by refusing to create NEW + /// durable state under an id resolved this way; callers admit only + /// operations that read or end something already recorded. + pub fn from_path_allowing_missing(path: impl AsRef) -> Result { + let resolved = resolve_allowing_missing(path.as_ref(), 0)?; + Ok(Self(platform_project_root_path(resolved))) + } + /// Borrow the canonical path backing this identity. pub fn as_path(&self) -> &Path { &self.0 @@ -153,6 +187,78 @@ fn platform_project_root_path(canonical_path: PathBuf) -> PathBuf { canonical_path } +/// The kernel's own ceiling on symlink hops is typically 40 (`ELOOP`); matching +/// it means a chain this code refuses is one the OS would refuse too. +const MAX_SYMLINK_HOPS: u32 = 40; + +/// Resolve the longest existing prefix of `path` and re-append the missing tail. +/// +/// Recurses on the parent rather than looping so that following a symlink on the +/// missing tail re-enters the same resolution from the link's target. +fn resolve_allowing_missing(path: &Path, hops: u32) -> Result { + match fs::canonicalize(path) { + Ok(canonical_path) => return Ok(canonical_path), + Err(err) if err.kind() == io::ErrorKind::NotFound => {} + Err(source) => { + return Err(IdentityError::CanonicalizePath { + path: path.to_path_buf(), + source, + }); + } + } + + // `..` and `.` tails have no file name to re-append, so there is no honest + // way to reconstruct them once the path has stopped existing. Refuse rather + // than return a path that differs from what the caller named. + let (Some(parent), Some(tail)) = (path.parent(), path.file_name()) else { + return Err(IdentityError::NonExistentPath { + path: path.to_path_buf(), + }); + }; + // `Path::parent` yields an empty path for a bare relative name; that is the + // current directory, not the absence of a parent. + let parent = if parent.as_os_str().is_empty() { + Path::new(".") + } else { + parent + }; + + let resolved_parent = resolve_allowing_missing(parent, hops)?; + let candidate = resolved_parent.join(tail); + + // A DANGLING symlink reads as absent to `canonicalize` and to `Path::exists`, + // because both follow links. `symlink_metadata` is the only predicate that + // sees the link itself, and following it is what `realpath` does -- keeping + // the link's own name instead would move the id the moment someone repairs + // the link, so ordinary maintenance would silently strand whatever was + // admitted under it. + match fs::symlink_metadata(&candidate) { + Ok(metadata) if metadata.file_type().is_symlink() => { + if hops >= MAX_SYMLINK_HOPS { + return Err(IdentityError::CanonicalizePath { + path: path.to_path_buf(), + source: io::Error::new( + io::ErrorKind::InvalidData, + format!("symbolic link chain exceeded {MAX_SYMLINK_HOPS} hops"), + ), + }); + } + let target = + fs::read_link(&candidate).map_err(|source| IdentityError::CanonicalizePath { + path: candidate.clone(), + source, + })?; + let target = if target.is_absolute() { + target + } else { + resolved_parent.join(target) + }; + resolve_allowing_missing(&target, hops.saturating_add(1)) + } + _ => Ok(candidate), + } +} + #[cfg(windows)] fn platform_project_root_path(canonical_path: PathBuf) -> PathBuf { windows_non_verbatim_path(canonical_path) @@ -218,6 +324,16 @@ mod tests { static NEXT_TEST_DIR: AtomicUsize = AtomicUsize::new(0); + #[cfg(unix)] + fn symlink_dir(target: &Path, link: &Path) -> io::Result<()> { + std::os::unix::fs::symlink(target, link) + } + + #[cfg(windows)] + fn symlink_dir(target: &Path, link: &Path) -> io::Result<()> { + std::os::windows::fs::symlink_dir(target, link) + } + struct TestDir { path: PathBuf, } @@ -249,6 +365,113 @@ mod tests { } } + /// The property the whole constructor exists for: an id minted while the root + /// existed must still be reachable after it is gone. + /// + /// Written as an EQUALITY against the strict constructor's output rather than + /// against a hand-written expected string, because a literal would encode + /// whatever this author believed canonicalization does. The equality fails if + /// the fallback and the strict path ever disagree, which is the only thing + /// consumers keying durable state on the result actually require. + #[test] + fn id_survives_the_root_being_deleted() { + let temp = TestDir::new("vanished"); + let root = temp.child("project"); + fs::create_dir(&root).expect("create project root"); + + let while_present = ProjectRootId::from_path(&root).expect("canonicalize live root"); + fs::remove_dir(&root).expect("remove project root"); + + assert!( + ProjectRootId::from_path(&root).is_err(), + "the strict constructor must still refuse a vanished root, or callers that \ + use the refusal to DETECT a dead root would silently keep it" + ); + assert_eq!( + ProjectRootId::from_path_allowing_missing(&root).expect("resolve vanished root"), + while_present, + "a root deleted after admission must resolve to the id it was admitted \ + under, or the caller addresses an empty lineage and is told no such thing \ + exists rather than being given an error" + ); + } + + /// macOS reaches every temp directory through a symlink, so this is the case + /// that distinguishes resolving from lexical normalization on this host -- + /// and the one a lexical implementation silently gets wrong. + #[test] + fn missing_tail_resolves_through_a_symlinked_ancestor() { + let temp = TestDir::new("symlinked-ancestor"); + let real = temp.child("real"); + let link = temp.child("link"); + fs::create_dir(&real).expect("create real directory"); + symlink_dir(&real, &link).expect("create ancestor symlink"); + + let through_link = ProjectRootId::from_path_allowing_missing(link.join("gone")) + .expect("resolve through symlinked ancestor"); + let through_real = ProjectRootId::from_path_allowing_missing(real.join("gone")) + .expect("resolve through real ancestor"); + + assert_eq!( + through_link, through_real, + "a missing tail must resolve through a live symlinked ancestor, or two \ + spellings of one location mint two different ids" + ); + assert_ne!( + through_link.as_path(), + link.join("gone"), + "non-vacuity: if this equals the input the implementation is normalizing \ + lexically and the test above would pass for the wrong reason" + ); + } + + /// A dangling link reads as absent to both `canonicalize` and `Path::exists`, + /// so the naive walk-up stops one component too high and keeps the link's own + /// name. Following it is what `realpath` does, and it is the choice that + /// SURVIVES REPAIR: if someone later creates the target, the strict + /// constructor produces this same id, so ordinary maintenance cannot strand + /// work admitted while the link dangled. + #[test] + fn dangling_link_resolves_to_its_target_and_survives_the_link_being_repaired() { + let temp = TestDir::new("dangling"); + let target = temp.child("target"); + let link = temp.child("link"); + symlink_dir(&target, &link).expect("create dangling symlink"); + + let while_dangling = ProjectRootId::from_path_allowing_missing(link.join("session")) + .expect("resolve through dangling link"); + + fs::create_dir(&target).expect("create link target"); + fs::create_dir(target.join("session")).expect("create session directory"); + let after_repair = + ProjectRootId::from_path(link.join("session")).expect("canonicalize repaired path"); + + assert_eq!( + while_dangling, after_repair, + "repairing a dangling link must not move the id, or an act of maintenance \ + silently strands whatever was admitted while it dangled" + ); + } + + /// A link chain long enough to be an error must fail rather than recurse until + /// the stack gives out. Asserting the ERROR VARIANT, not merely that it failed: + /// a stack overflow is not a refusal. + #[test] + fn symlink_chain_beyond_the_hop_ceiling_is_refused() { + let temp = TestDir::new("loop"); + let first = temp.child("a"); + let second = temp.child("b"); + symlink_dir(&second, &first).expect("create first link"); + symlink_dir(&first, &second).expect("create second link"); + + let error = ProjectRootId::from_path_allowing_missing(first.join("gone")) + .expect_err("a symlink cycle must be refused"); + assert!( + matches!(error, IdentityError::CanonicalizePath { .. }), + "a cycle is an unresolvable path, not a missing one: {error:?}" + ); + } + #[test] fn path_spellings_to_same_root_have_equal_project_root_ids() { let temp = TestDir::new("spellings"); From 12d9ff3d72567b6347323cb19e0d7e857ec797f0 Mon Sep 17 00:00:00 2001 From: ualtinok Date: Sat, 8 Aug 2026 08:42:53 +0200 Subject: [PATCH 14/37] paths: 0.1.1 for the missing-root constructor from_path_allowing_missing landed in bc88d51 as an additive constructor. subc consumes this crate from crates.io at "0.1", so the new API needs a published version before the daemon can use it. Additive only -- from_path is unchanged, and entorhinal's dependency on its NonExistentPath rejection is untouched. --- crates/cortexkit-paths/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/cortexkit-paths/Cargo.toml b/crates/cortexkit-paths/Cargo.toml index 54cffb1..200f94f 100644 --- a/crates/cortexkit-paths/Cargo.toml +++ b/crates/cortexkit-paths/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cortexkit-paths" -version = "0.1.0" +version = "0.1.1" description = "Shared CortexKit-neutral path canonicalization primitives for project root identities." keywords = ["path", "canonicalization", "realpath", "cortexkit"] categories = ["filesystem"] From b44c47eedde9990c1dc1f21a50df6a9236ece5f9 Mon Sep 17 00:00:00 2001 From: ualtinok Date: Sun, 9 Aug 2026 21:53:01 +0200 Subject: [PATCH 15/37] paths: say that this canonical form is a cryptographic identity input CKCRED corrected an assessment I made in the push-notification room. I said a divergence in this crate had a bounded blast radius and would fail loudly as a path mismatch. That is true for MY consumer and false for theirs, and they checked precisely because the claim relieved them. ProjectRootId::from_path is the canonicalizer behind two vault identities: the keychain service name holding the master key, and the vault id that fences an admin-operation MAC to one vault. A canonicalization change is a breaking change to both, and it presents as a LOCKED VAULT OVER AN INTACT STORE or as every admin MAC failing verification -- never as a path mismatch. Nothing in either failure says two builds disagree about what a path is. Nobody could see this from either side. I assessed the blast radius of a crate whose consumers I do not build; they had treated it as a path helper because that is what the name says. The fact existed in neither repo's documentation. So the fix is the sentence, at the type, where someone changing the canonical form will meet it -- not in the consumer, which is where it is already known, and not in a design note, which is where it would not be read. Same shape as five defects found in the sealed-payload spec today: the rule was right and its reason lived somewhere the reader would never connect. Sharpened by their own framing: THE NAME IS THE TRAP. This reads as a path helper and is a canonicalizer for security identities, so the reader most likely to change it is the one least likely to suspect the consequence. Nothing behavioural. I published 0.1.1 from this crate today after auditing the callers I could see, and this consumer was not among them. --- crates/cortexkit-paths/src/lib.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/crates/cortexkit-paths/src/lib.rs b/crates/cortexkit-paths/src/lib.rs index 9765b0f..1fcfdeb 100644 --- a/crates/cortexkit-paths/src/lib.rs +++ b/crates/cortexkit-paths/src/lib.rs @@ -25,6 +25,24 @@ use std::{ /// main checkout. Because a linked worktree has its own checkout directory, the /// canonical worktree path is a distinct id from the canonical main-checkout /// path while alternate spellings of either path still converge. +/// A canonical project-root identity. +/// +/// THE CANONICAL FORM THIS PRODUCES IS A CRYPTOGRAPHIC IDENTITY INPUT IN AT +/// LEAST ONE CONSUMER, WHICH IS NOT VISIBLE FROM THIS CRATE. The vault hashes +/// the canonical directory to derive the keychain service name holding its +/// master key, and to derive the vault id that fences an admin-operation MAC to +/// one vault. A change to canonicalization is therefore a BREAKING CHANGE to +/// those identities. +/// +/// It does not present as one. The vault looks up a keychain item that does not +/// exist and reports a locked vault over an intact store, or two binaries derive +/// different vault ids and every admin MAC fails verification. Nothing says +/// "these two builds disagree about what this path is" -- so the usual +/// reassurance for a path helper, that a mistake surfaces as a loud path +/// mismatch, does not hold here. +/// +/// The name is the trap: this reads as a path helper and is a canonicalizer for +/// security identities. Route changes to the canonical form past the vault. #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub struct ProjectRootId(PathBuf); From aca7a53dcd830ecd7f424f010199d7f4f0bcb339 Mon Sep 17 00:00:00 2001 From: ualtinok Date: Sun, 9 Aug 2026 21:54:55 +0200 Subject: [PATCH 16/37] own commons: record the owner, the publication state, and one trap Ufuk confirmed this repo is mine to manage. Writing that down, because the question came up today from two seats and the repository could not answer it: no CODEOWNERS, no MAINTAINERS, and every commit attributed to the human account because git records the human rather than the seat. It went unowned while five repos built against it, and "nobody" was the accurate answer for months. THREE THINGS A CONSUMER CANNOT LEARN FROM THE CRATES THEMSELVES: 1. The README claimed every crate is published. Measured against crates.io: TWO OF EIGHT are. The other six are unpublished by omission -- none sets publish = false. Release tags are not the authority either: provider-usage has five published versions and four tags. 2. Publishing creates a SECOND DISTRIBUTION PATH and is near-irreversible. Already live: claustrum compiles two copies of cortexkit-paths at the same version, one path and one registry, agreeing only because the published bytes currently match a sibling checkout. Found by CKCRED, invisible from here -- cargo tree -i refuses the query as ambiguous, which is the only surface that shows it. 3. A path dependency records no source and no checksum in Cargo.lock, so changed code compiles into every consuming repo with no diff and nothing for --locked to catch. THE VERSION NUMBER IS THE ENTIRE CHANNEL. Bump on behaviour or emitted bytes, never on prose -- a version that moves for comments trains its readers to bump reflexively, which is how it stops meaning anything. And the trap, now on the crate and in the table: cortexkit-paths reads as a path helper and is the canonicalizer for two vault identities. Changing its canonical form presents as a locked vault over an intact store, never as a path mismatch. CODEOWNERS deliberately does NOT encode the vault seat's standing review duty. Agent seats are not GitHub accounts, so per-path lines naming the same account would look like a routing rule while routing nothing -- a mechanism that appears to enforce and does not is worse than a convention that admits what it is. --- .github/CODEOWNERS | 13 ++++++++++++ README.md | 50 ++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..9a21ffd --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,13 @@ +# Ownership of record. Git attributes every commit here to the human account, so +# the repository could not answer "who owns this" from its own history -- which +# is how it went unowned while several repos built against it. +# +# Owned by the subc seat: direction, review, releases. +* @ualtinok + +# NOT ENCODED HERE, DELIBERATELY: the vault seat holds a standing review +# obligation on cortexkit-store, cortexkit-lease, and cortexkit-paths +# canonicalization. Agent seats are not GitHub accounts, so CODEOWNERS cannot +# express it -- per-path lines naming the same account would look like a routing +# rule while routing nothing. The obligation is stated in README.md, where it is +# honest about being a convention rather than a mechanism. diff --git a/README.md b/README.md index e0753a0..75949ab 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,59 @@ Neutral home for cross-product [CortexKit](https://github.com/cortexkit) primitives — small, dependency-light building blocks shared across **subc**, **AFT**, and **Magic Context** that belong to no single product. -Each crate is published independently to [crates.io](https://crates.io). Product repos depend on the published version, or on a sibling path-dependency for local development. +## Ownership + +Maintained by the **subc** seat: direction, review, and releases. + +Two crates carry a standing review obligation to the **claustrum** (vault) seat, +who must be routed any change to: + +- `cortexkit-store` / `cortexkit-lease` — they run the only real-daemon test + exercising the single-writer lease across two processes. +- **`cortexkit-paths` canonicalization** — see the warning below. + +That is a duty carried, not a veto held. + +## Publication is per-crate, and most of these are NOT published + +Measured 2026-08-09 against crates.io rather than inferred from release tags: + +| state | crates | +|---|---| +| published | `cortexkit-paths` (0.1.1), `cortexkit-provider-usage` (0.4.1) | +| unpublished | the other six — by omission, none sets `publish = false` | + +Release tags are **not** the authority on what is published: `provider-usage` has +five versions on crates.io and four tags. Ask the registry. + +**Publishing a crate here is close to irreversible** — a version can be yanked but +never removed — and it creates a SECOND DISTRIBUTION PATH. A crate consumed only +by sibling path-dependencies has exactly one; publishing it means a consumer can +resolve a registry version while another repo's sibling checkout floats +elsewhere, and both can end up in one binary. That is already live: `claustrum` +compiles two copies of `cortexkit-paths` at the same version, one path and one +registry, agreeing only because the published bytes currently match. + +So: publish only when an external consumer genuinely cannot use a path +dependency, and set `publish = false` explicitly with the reason at the key when +the answer is no. + +## Version bumps are the only signal a path-dependency consumer gets + +`Cargo.lock` records a path dependency as a bare version string with **no source +and no checksum**, so changed code compiles into every consuming repo with no +lockfile diff and nothing for `--locked` to catch. The version number is the +entire channel. + +Bump on any change to observable behaviour or emitted bytes. Not for comments or +tests — a version that moves for prose trains readers to bump reflexively, which +is how it stops meaning anything. ## Crates | Crate | Description | |-------|-------------| -| [`cortexkit-paths`](crates/cortexkit-paths) | Path canonicalization → canonical project-root identity (`ProjectRootId`). Dependency-free, `#![forbid(unsafe_code)]`, cross-platform (incl. Windows verbatim/UNC/drive-case normalization). | +| [`cortexkit-paths`](crates/cortexkit-paths) | Path canonicalization → canonical project-root identity (`ProjectRootId`). Dependency-free, `#![forbid(unsafe_code)]`, cross-platform (incl. Windows verbatim/UNC/drive-case normalization). **Its canonical form is a cryptographic identity input** — the vault hashes it to derive the keychain service name holding its master key and the vault id fencing admin MACs. A canonicalization change breaks those and presents as a locked vault over an intact store, never as a path mismatch. The name reads as a path helper; it is not only that. | ## License From a85015183de096d7a3adb697c11a46c70cc233fa Mon Sep 17 00:00:00 2001 From: ualtinok Date: Sun, 9 Aug 2026 22:08:32 +0200 Subject: [PATCH 17/37] push-seal: the sealer, reviewed and committed as commons owner Written by CALLO, reviewed by me under the ownership Ufuk settled tonight. Two files plus a workspace member line. 10 tests, clippy -D warnings clean, fmt clean -- all re-run here rather than taken from the handover. WHAT IT IS: HPKE base-mode sealing for push payloads, so a notification can carry the question text while the push provider, the relay and Apple see ciphertext. The opener is a separate implementation in another repository. THE ONE REVIEW FINDING, AND IT WAS NOT A MISSING METHOD: OpenError has four variants and the shared corpus requires each negative vector to carry an expected_failure from a THREE-value wire vocabulary. Nothing mapped between them, so the corpus generator would have implemented that mapping -- a second independent statement of one fact, in a different repo, which is the class we closed three times tonight in the spec. wire_code() now lives beside the enum and the generator reads it. I FOUND IT BY PROBING RATHER THAN READING, and the probe is now a test. The spec's `empty_ct` vector must report `malformed`; I measured what an empty-ciphertext envelope actually yields and got Aead, because 33 bytes CLEARS the length gate and fails authentication instead of shape. The vector still gets the right answer -- but only because Malformed and Aead both map to `malformed`. That is correct and non-obvious, and CALLO's test now records it at the site, so the next reader does not conclude the length gate is what produces the answer. MUTATION-PROVED BEFORE COMMITTING, by me and not by report: flipping UnknownVersion to report `malformed` reddens every_open_failure_maps_to_the_wire_ vocabulary by name; restored, 10 green. The mapping is fenced rather than decorative. TWO DECISIONS RECORDED AT THEIR SITES RATHER THAN AGREED IN A ROOM: - publish = false, with the DOUBLE-RESOLUTION reason at the key rather than "no external consumer" -- the second framing invites publication the day one appears, and the hazard at that moment is that publishing creates a second path to the same bytes. Already live elsewhere: claustrum compiles two copies of cortexkit-paths, one path and one registry. - open() enforces no size cap deliberately; the bound is the transport's, and a second cap would duplicate a limit owned elsewhere and could disagree with it. The module docs lead with what breaks in CONSUMERS, above what the crate does, because a change here is a wire-format divergence in a repo that does not build this code -- and because a path dependency carries no content hash, the version number is the only channel through which a consumer learns sealed output moved. --- Cargo.toml | 2 +- crates/cortexkit-push-seal/Cargo.toml | 23 ++ crates/cortexkit-push-seal/src/lib.rs | 399 ++++++++++++++++++++++++++ 3 files changed, 423 insertions(+), 1 deletion(-) create mode 100644 crates/cortexkit-push-seal/Cargo.toml create mode 100644 crates/cortexkit-push-seal/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index cb5069f..9b7f23f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["crates/cortexkit-paths", "crates/cortexkit-lease", "crates/cortexkit-store-types", "crates/cortexkit-store", "crates/cortexkit-store-postgres", "crates/cortexkit-cache-core", "crates/cortexkit-model-catalog", "crates/cortexkit-provider-usage"] +members = ["crates/cortexkit-paths", "crates/cortexkit-lease", "crates/cortexkit-store-types", "crates/cortexkit-store", "crates/cortexkit-store-postgres", "crates/cortexkit-cache-core", "crates/cortexkit-model-catalog", "crates/cortexkit-provider-usage", "crates/cortexkit-push-seal"] # cortexkit/commons — neutral home for cross-product CortexKit primitives. # Shared by subc, AFT, and Magic Context. Each crate is published independently diff --git a/crates/cortexkit-push-seal/Cargo.toml b/crates/cortexkit-push-seal/Cargo.toml new file mode 100644 index 0000000..547fc20 --- /dev/null +++ b/crates/cortexkit-push-seal/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "cortexkit-push-seal" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +description = "Seals push-notification payloads with HPKE so only the recipient device can read them." + +# Deliberately unpublished. The hazard is NOT that no external consumer exists — +# that reasoning invites publication the day one appears. Publishing creates a +# SECOND distribution path to the same bytes: a consumer resolving a registry +# version while a sibling checkout floats to different code, compiling both into +# one binary. For a crate emitting a wire format that is a silent divergence +# between sealer and opener, not a build error. +publish = false + +[dependencies] +hpke = { version = "0.14", default-features = false, features = ["alloc", "getrandom", "x25519", "chacha"] } +rand_core = "0.9" + +[dev-dependencies] +hex = "0.4" diff --git a/crates/cortexkit-push-seal/src/lib.rs b/crates/cortexkit-push-seal/src/lib.rs new file mode 100644 index 0000000..7f453cf --- /dev/null +++ b/crates/cortexkit-push-seal/src/lib.rs @@ -0,0 +1,399 @@ +//! Seals push-notification payloads so that only the recipient device can read +//! them. +//! +//! # What breaks in consumers when this crate changes +//! +//! Stated first because it is not visible from inside this crate. The sealed +//! bytes are opened by a **separate implementation in another repository**, +//! which does not build this code. So a change here is not a local behaviour +//! change: +//! +//! - Changing the ciphersuite, `info`, the associated data, or the envelope +//! layout is a **wire-format divergence**. The opener fails with an +//! authentication error, which renders on the device as an undecryptable +//! notification — the same appearance as a locked phone. Nothing fails here. +//! - This crate is consumed by relative path, and a path dependency is recorded +//! in `Cargo.lock` with a version string and **no content hash**. So an +//! unchanged version means new code compiles into a consuming repository with +//! no lockfile diff anywhere. **The version number is the only channel through +//! which a consumer can learn that sealed output changed.** +//! +//! Therefore: **bump the version on any change to emitted bytes or behaviour.** +//! Not on comments or tests — a version that moves for prose trains its readers +//! to bump reflexively and then stops meaning anything. +//! +//! # The parameters, and why they are spelled out +//! +//! An HPKE ciphersuite is a triple. Naming two of its three parts leaves the +//! third to each implementation's default, and the two defaults were about to +//! disagree: RFC 9180's suite table opens with AES-128-GCM, while the opener's +//! platform offers exactly one X25519 suite. Every such disagreement produces +//! the same authentication failure, whose diagnosis points at the transport. +//! +//! | parameter | RFC 9180 codepoint | value | +//! |---|---|---| +//! | KEM | `0x0020` | `DHKEM(X25519, HKDF-SHA256)` | +//! | KDF | `0x0001` | `HKDF-SHA256` | +//! | AEAD | `0x0003` | `ChaCha20Poly1305` | +//! +//! The codepoints are recorded because they are what both implementations feed +//! to their libraries. A platform-specific suite name is a symbol for this +//! triple, not a wire fact, and two sides agreeing on a name that exists in only +//! one of their vocabularies have agreed about a string rather than about bytes. +//! +//! `info` is empty **because the recipient key is dedicated to this purpose**. +//! `info` is the key schedule's domain separator: it earns its keep when one key +//! serves several applications. If this key is ever shared with another protocol +//! — for instance to add sender authentication by reusing a transport static — +//! empty stops being safe and a fixed non-empty domain string becomes the +//! mitigation. The condition is written down rather than the conclusion, because +//! the reader who reuses the key is exactly the reader who cannot see why it +//! mattered. + +use hpke::{ + aead::ChaCha20Poly1305, kdf::HkdfSha256, kem::X25519HkdfSha256, Deserializable, OpModeR, + OpModeS, Serializable, +}; + +/// The one envelope version this crate emits and accepts. +pub const VERSION: u8 = 0x01; + +/// Normative plaintext cap, measured **before** sealing. +/// +/// The unit is load-bearing rather than decoration: "2048 bytes" reads as either +/// plaintext or sealed, both fit under the platform's payload limit at this +/// value, and the two readings diverge at a larger one. Plaintext is normative +/// because the composing party is the only one holding plaintext and the only +/// one that can decide what to drop; a sealed-byte cap would require it to model +/// this crate's overhead, which it would get wrong silently. +pub const MAX_PLAINTEXT_BYTES: usize = 2048; + +/// Length of the encapsulated key for the pinned KEM. +const ENC_LEN: usize = 32; + +/// Failure modes, kept separate because they have different diagnoses. +/// +/// Collapsing them makes a field report unactionable: "it did not open" has at +/// least four causes and only one of them is a defect in the bytes. +#[derive(Debug, PartialEq, Eq)] +pub enum SealError { + /// The plaintext exceeds [`MAX_PLAINTEXT_BYTES`]. Carries both numbers. + /// + /// Over-size is refused rather than truncated: the authentication tag covers + /// the whole ciphertext, so a truncated blob does not decrypt to a fragment, + /// it fails to decrypt entirely and renders as the generic placeholder — + /// indistinguishable from a device that has not been unlocked. + PlaintextTooLarge { limit: usize, observed: usize }, + /// The recipient public key is not a valid X25519 point. + BadRecipientKey, + /// The HPKE operation itself failed. + Hpke, +} + +/// Failure modes when opening. Separate from [`SealError`] deliberately. +#[derive(Debug, PartialEq, Eq)] +pub enum OpenError { + /// The envelope is shorter than a version byte plus an encapsulated key. + Malformed { observed: usize }, + /// The version byte is not one this build understands. + /// + /// Distinct from [`OpenError::Aead`] on purpose. The byte exists so that a + /// format change is loud; folding it into a generic failure would put a + /// format change into the same bucket as a corrupt payload, which is the + /// bucket that already has three other causes. + UnknownVersion { observed: u8 }, + /// The recipient private key is not a valid X25519 scalar. + BadRecipientKey, + /// Authentication failed: wrong key, wrong suite, wrong `info`, wrong + /// associated data, or altered bytes. These are indistinguishable here by + /// construction — the tag covers all of them. + Aead, +} + +/// Seals `plaintext` to `recipient_public_key`. +/// +/// Returns `version || enc || ciphertext`, where the version byte is also the +/// associated data, so it is authenticated. Left cleartext and unbound it would +/// not be covered by the tag, and flipping it would silently select a different +/// parse rather than failing. +pub fn seal(recipient_public_key: &[u8], plaintext: &[u8]) -> Result, SealError> { + if plaintext.len() > MAX_PLAINTEXT_BYTES { + return Err(SealError::PlaintextTooLarge { + limit: MAX_PLAINTEXT_BYTES, + observed: plaintext.len(), + }); + } + + let pk = ::PublicKey::from_bytes(recipient_public_key) + .map_err(|_| SealError::BadRecipientKey)?; + + let aad = [VERSION]; + let (enc, ciphertext) = + hpke::single_shot_seal::( + &OpModeS::Base, + &pk, + &[], + plaintext, + &aad, + ) + .map_err(|_| SealError::Hpke)?; + + let enc = enc.to_bytes(); + let mut out = Vec::with_capacity(1 + enc.len() + ciphertext.len()); + out.push(VERSION); + out.extend_from_slice(&enc); + out.extend_from_slice(&ciphertext); + Ok(out) +} + +impl OpenError { + /// The wire vocabulary a conformance vector reports. + /// + /// Lives here rather than in the corpus generator so the four-to-three + /// collapse has ONE home. A generator that restated it would be a second + /// independent statement of the same fact, free to drift from this one. + /// + /// **Three of the four variants collapse to `malformed`, and that is + /// deliberate rather than lossy.** `Aead` already covers a wrong key, a + /// wrong ciphersuite, a wrong `info`, a wrong associated data and altered + /// bytes — the authentication tag cannot separate them, and an opener that + /// could would be telling an attacker which part of the envelope was wrong. + /// `Malformed` and `BadRecipientKey` join it because they are equally + /// "this envelope is not usable", and splitting them on the wire would + /// imply a distinction the opener cannot honour. + /// + /// The non-obvious consequence, worth stating because it looks like a bug: + /// an envelope carrying a valid version and encapsulated key with an EMPTY + /// ciphertext clears the length gate and fails as `Aead`, yet still reports + /// `malformed`. The vector expecting `malformed` passes for a reason its + /// author did not choose. + pub fn wire_code(&self) -> &'static str { + match self { + OpenError::UnknownVersion { .. } => "unsupported_version", + OpenError::Malformed { .. } | OpenError::BadRecipientKey | OpenError::Aead => { + "malformed" + } + } + } +} + +/// Opens an envelope produced by [`seal`]. +/// +/// Present for tests and for generating the cross-language corpus. Production +/// opening happens in the recipient's own implementation. +/// +/// **No size cap here, deliberately.** `seal` enforces one because it is the +/// only party holding plaintext; the bound on the opening side is the +/// transport's, which caps bytes before they reach this code. A second cap here +/// would duplicate a limit owned elsewhere and could disagree with it — the +/// same two-numbers-one-fact hazard the plaintext cap exists to avoid. +pub fn open(recipient_private_key: &[u8], envelope: &[u8]) -> Result, OpenError> { + if envelope.len() < 1 + ENC_LEN { + return Err(OpenError::Malformed { + observed: envelope.len(), + }); + } + // Checked before anything else, and refused rather than skipped. + if envelope[0] != VERSION { + return Err(OpenError::UnknownVersion { + observed: envelope[0], + }); + } + + let sk = ::PrivateKey::from_bytes(recipient_private_key) + .map_err(|_| OpenError::BadRecipientKey)?; + let enc = ::EncappedKey::from_bytes(&envelope[1..1 + ENC_LEN]) + .map_err(|_| OpenError::Aead)?; + + let aad = [VERSION]; + hpke::single_shot_open::( + &OpModeR::Base, + &sk, + &enc, + &[], + &envelope[1 + ENC_LEN..], + &aad, + ) + .map_err(|_| OpenError::Aead) +} + +#[cfg(test)] +mod tests { + use super::*; + use hpke::{aead::Aead, kdf::Kdf, Kem as KemTrait}; + + fn keypair() -> (Vec, Vec) { + let (sk, pk) = X25519HkdfSha256::gen_keypair(); + (sk.to_bytes().to_vec(), pk.to_bytes().to_vec()) + } + + /// The suite is pinned by CODEPOINT, not by the type names above. + /// + /// Naming the types in the signatures makes a feature-flag change a compile + /// error, which is necessary and not sufficient: a library could rename or + /// re-point a type and still compile. The codepoints are what the opener's + /// implementation is agreeing to, so they are what this asserts. + #[test] + fn the_pinned_suite_is_the_one_the_opener_agreed_to() { + assert_eq!(X25519HkdfSha256::KEM_ID, 0x0020, "KEM codepoint"); + assert_eq!(HkdfSha256::KDF_ID, 0x0001, "KDF codepoint"); + assert_eq!(ChaCha20Poly1305::AEAD_ID, 0x0003, "AEAD codepoint"); + } + + #[test] + fn a_sealed_payload_opens_to_the_same_plaintext() { + let (sk, pk) = keypair(); + let sealed = seal(&pk, b"a question").expect("seal"); + assert_eq!(open(&sk, &sealed).expect("open"), b"a question"); + } + + #[test] + fn the_envelope_is_version_then_enc_then_ciphertext() { + let (_, pk) = keypair(); + let sealed = seal(&pk, b"x").expect("seal"); + assert_eq!(sealed[0], VERSION, "version byte leads"); + // 1 + 32 + ciphertext, and the ciphertext carries a 16-byte tag. + assert_eq!(sealed.len(), 1 + ENC_LEN + 1 + 16); + } + + /// Two seals of identical plaintext to one recipient must differ. + /// + /// This lives here rather than in the shared corpus because the corpus + /// cannot see it: every vector opens correctly under a sealer that reuses + /// one ephemeral key forever, since each vector is examined alone. Base-mode + /// confidentiality rests on a fresh ephemeral per message, so without this + /// there is no place the defect would surface. + #[test] + fn each_seal_uses_a_fresh_ephemeral() { + let (_, pk) = keypair(); + let a = seal(&pk, b"same").expect("seal"); + let b = seal(&pk, b"same").expect("seal"); + assert_ne!( + a[1..1 + ENC_LEN], + b[1..1 + ENC_LEN], + "encapsulated key must not repeat across messages" + ); + } + + #[test] + fn an_oversized_plaintext_is_refused_with_both_numbers() { + let (_, pk) = keypair(); + let too_big = vec![0u8; MAX_PLAINTEXT_BYTES + 1]; + assert_eq!( + seal(&pk, &too_big), + Err(SealError::PlaintextTooLarge { + limit: MAX_PLAINTEXT_BYTES, + observed: MAX_PLAINTEXT_BYTES + 1 + }) + ); + // Positive control: the boundary itself succeeds, so the refusal above + // is not satisfied by an implementation that refuses everything. + let at_limit = vec![0u8; MAX_PLAINTEXT_BYTES]; + assert!(seal(&pk, &at_limit).is_ok(), "the cap itself must seal"); + } + + #[test] + fn an_unknown_version_is_refused_as_a_version_rather_than_as_corruption() { + let (sk, pk) = keypair(); + let mut sealed = seal(&pk, b"q").expect("seal"); + sealed[0] = 0x02; + assert_eq!( + open(&sk, &sealed), + Err(OpenError::UnknownVersion { observed: 0x02 }), + "a format change must be distinguishable from a corrupt payload" + ); + } + + #[test] + fn a_truncated_envelope_is_malformed_rather_than_an_aead_failure() { + let (sk, pk) = keypair(); + let sealed = seal(&pk, b"q").expect("seal"); + let short = &sealed[..ENC_LEN]; + assert_eq!( + open(&sk, short), + Err(OpenError::Malformed { observed: ENC_LEN }) + ); + } + + /// The wire mapping is total and the collapse is exercised. + /// + /// Includes the empty-ciphertext case explicitly, because it reaches + /// `malformed` by a route nobody would predict: 33 bytes clears the length + /// gate, so it fails authentication rather than shape, and still reports + /// `malformed`. Without this case a vector expecting `malformed` passes + /// while the reasoning behind it goes unrecorded. + #[test] + fn every_open_failure_maps_to_the_wire_vocabulary() { + let (sk, pk) = keypair(); + let sealed = seal(&pk, b"q").expect("seal"); + + let mut wrong_version = sealed.clone(); + wrong_version[0] = 0x7f; + assert_eq!( + open(&sk, &wrong_version).unwrap_err().wire_code(), + "unsupported_version" + ); + + assert_eq!( + open(&sk, &sealed[..ENC_LEN]).unwrap_err().wire_code(), + "malformed", + "too short to split" + ); + + let empty_ct = &sealed[..1 + ENC_LEN]; + assert_eq!(open(&sk, empty_ct).unwrap_err(), OpenError::Aead); + assert_eq!(open(&sk, empty_ct).unwrap_err().wire_code(), "malformed"); + + let (other_sk, _) = keypair(); + assert_eq!( + open(&other_sk, &sealed).unwrap_err().wire_code(), + "malformed", + "a wrong key must not be distinguishable from other failures" + ); + + // Positive control: a valid envelope produces no failure to map. + assert!(open(&sk, &sealed).is_ok()); + } + + #[test] + fn the_wrong_recipient_cannot_open() { + let (_, pk) = keypair(); + let (other_sk, _) = keypair(); + let sealed = seal(&pk, b"q").expect("seal"); + assert_eq!(open(&other_sk, &sealed), Err(OpenError::Aead)); + } + + /// The associated data is load-bearing, and the no-AAD call compiles. + /// + /// An implementation that forgets to authenticate the version byte gets an + /// authentication failure — which lands in the same bucket as a wrong suite + /// and a wrong key. This proves the binding exists rather than trusting it. + #[test] + fn the_version_byte_is_authenticated_not_merely_present() { + let (sk, pk) = keypair(); + let sealed = seal(&pk, b"q").expect("seal"); + + let recipient = ::PrivateKey::from_bytes(&sk).unwrap(); + let enc = ::EncappedKey::from_bytes(&sealed[1..1 + ENC_LEN]) + .unwrap(); + + // Opening with NO associated data must fail, which is what proves the + // sealer bound it. + let without_aad = hpke::single_shot_open::( + &OpModeR::Base, + &recipient, + &enc, + &[], + &sealed[1 + ENC_LEN..], + &[], + ); + assert!( + without_aad.is_err(), + "the version byte must be bound as AAD" + ); + + // Positive control in the same test: with the correct AAD it opens, so + // the failure above is about the AAD rather than about the envelope. + assert_eq!(open(&sk, &sealed).expect("open"), b"q"); + } +} From 2090a0519cf461a1de03bc26a26ca0cd45db366d Mon Sep 17 00:00:00 2001 From: ualtinok Date: Sun, 9 Aug 2026 23:05:16 +0200 Subject: [PATCH 18/37] push-seal: hand-seal examples, so tonight's milestone needs no new code CKIOS proposes the right milestone for tonight: ONE notification, sealed by hand, that opens on the phone. Not the feature. It exercises every unknown -- key custody across two processes, seal/open agreement, the APNs environment (which is currently operator testimony rather than a measurement), and the deep link -- while each piece is small enough that a failure has one candidate cause. The room listed the sealer as STARTED and the corpus as NOT STARTED, and read that as blocking. It is not: sealing one payload needs the crate, not the corpus. The corpus proves CONFORMANCE between two implementations, which is a later question than whether a blob opens. So these three examples close that gap with no new library code: kp generate an X25519 recipient keypair handseal seal a payload to a public key, print hex, report the size delta handopen open one, or print the refusal AND its wire code MEASURED RATHER THAN ESTIMATED, on a realistic ask payload: 111 bytes plaintext -> 160 bytes sealed. 49 bytes of overhead: 1 version + 32 encapsulated key + 16 authentication tag. That number retires a question I had left open in the spec deliberately -- I refused to state a derived sealed-size figure before measuring one, because a derived number stated early becomes normative by accident. Now it is measured: the 2048-byte plaintext cap yields ~2097 sealed, comfortably inside APNs' 4KB ceiling, so the cap needs no revision. Round trip verified with its control: the correct key opens to the exact plaintext; a wrong recipient refuses as Aead, wire code `malformed`, which is the three-into-one collapse the spec requires rather than a leak about which part of the envelope was wrong. Examples rather than a binary or a script, so they live beside the crate they exercise and cannot drift from it. --- .../cortexkit-push-seal/examples/handopen.rs | 13 +++++++++++ .../cortexkit-push-seal/examples/handseal.rs | 22 +++++++++++++++++++ crates/cortexkit-push-seal/examples/kp.rs | 7 ++++++ 3 files changed, 42 insertions(+) create mode 100644 crates/cortexkit-push-seal/examples/handopen.rs create mode 100644 crates/cortexkit-push-seal/examples/handseal.rs create mode 100644 crates/cortexkit-push-seal/examples/kp.rs diff --git a/crates/cortexkit-push-seal/examples/handopen.rs b/crates/cortexkit-push-seal/examples/handopen.rs new file mode 100644 index 0000000..2d0b302 --- /dev/null +++ b/crates/cortexkit-push-seal/examples/handopen.rs @@ -0,0 +1,13 @@ +fn main() { + let a: Vec = std::env::args().collect(); + let hx = |s: &str| { + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()) + .collect::>() + }; + match cortexkit_push_seal::open(&hx(&a[1]), &hx(&a[2])) { + Ok(p) => println!("OPENED: {}", String::from_utf8_lossy(&p)), + Err(e) => println!("REFUSED: {:?} wire={}", e, e.wire_code()), + } +} diff --git a/crates/cortexkit-push-seal/examples/handseal.rs b/crates/cortexkit-push-seal/examples/handseal.rs new file mode 100644 index 0000000..795dd1c --- /dev/null +++ b/crates/cortexkit-push-seal/examples/handseal.rs @@ -0,0 +1,22 @@ +// Proves the crate can seal a real payload to a real recipient key TODAY. +// Run: cargo run -p cortexkit-push-seal --example handseal -- '' +fn main() { + let a: Vec = std::env::args().collect(); + let pk = (0..a[1].len()) + .step_by(2) + .map(|i| u8::from_str_radix(&a[1][i..i + 2], 16).unwrap()) + .collect::>(); + let sealed = cortexkit_push_seal::seal(&pk, a[2].as_bytes()).expect("seal"); + println!( + "{}", + sealed + .iter() + .map(|b| format!("{b:02x}")) + .collect::() + ); + eprintln!( + "plaintext {} bytes -> sealed {} bytes", + a[2].len(), + sealed.len() + ); +} diff --git a/crates/cortexkit-push-seal/examples/kp.rs b/crates/cortexkit-push-seal/examples/kp.rs new file mode 100644 index 0000000..ebe8035 --- /dev/null +++ b/crates/cortexkit-push-seal/examples/kp.rs @@ -0,0 +1,7 @@ +fn main() { + use hpke::{Kem, Serializable}; + let (sk, pk) = hpke::kem::X25519HkdfSha256::gen_keypair(); + let h = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::(); + println!("SK {}", h(&sk.to_bytes())); + println!("PK {}", h(&pk.to_bytes())); +} From 1e10ba95c72400e1f5f87534d7f376c05aa041c4 Mon Sep 17 00:00:00 2001 From: ualtinok Date: Mon, 10 Aug 2026 08:17:17 +0200 Subject: [PATCH 19/37] push-seal: say why this crate has no production caller yet A dead-code pass on this workspace flags seal() as unreachable outside tests and examples, which is true and is not a finding. The consumer is the notification submit endpoint and it is unbuilt, so there is no caller-shaped hole where a caller is required -- the distinction a later reader cannot make from the code alone. Deleting it would take the ciphersuite pinning and the envelope layout with it, and a separate implementation in another repository is already built against both. Nothing in this workspace would fail if they were lost. The examples carry the same weight: the handseal/handopen round trip is the only end-to-end exercise of the crate outside its own tests, so a cleanup that removes examples as clutter removes the only evidence the crate works against a real opener. --- crates/cortexkit-push-seal/src/lib.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/crates/cortexkit-push-seal/src/lib.rs b/crates/cortexkit-push-seal/src/lib.rs index 7f453cf..74af45e 100644 --- a/crates/cortexkit-push-seal/src/lib.rs +++ b/crates/cortexkit-push-seal/src/lib.rs @@ -22,6 +22,24 @@ //! Not on comments or tests — a version that moves for prose trains its readers //! to bump reflexively and then stops meaning anything. //! +//! # This crate has no production caller yet, and that is staged rather than dead +//! +//! `seal` is reached only from this crate's tests and its `handseal` example. +//! The consumer is the notification submit endpoint, which is unbuilt: the +//! surface that will call this holds the recipient key and does not exist yet. +//! +//! Recorded here because an uncalled function is indistinguishable from an +//! abandoned one, and the next reader running a dead-code pass arrives at this +//! file with no way to tell them apart. Deleting it would take the ciphersuite +//! pinning and the envelope layout with it — the two facts that a separate +//! implementation in another repository is already built against, and which +//! nothing in this workspace would fail to notice the loss of. +//! +//! The examples are not decoration either: `handseal` and `handopen` are how a +//! sealed payload is produced by hand before the endpoint exists, and the +//! round trip between them is the only end-to-end exercise of this crate +//! outside its own tests. +//! //! # The parameters, and why they are spelled out //! //! An HPKE ciphersuite is a triple. Naming two of its three parts leaves the From abc1147b4704d846307f3c0debe236af1f334e72 Mon Sep 17 00:00:00 2001 From: ualtinok Date: Mon, 10 Aug 2026 08:23:05 +0200 Subject: [PATCH 20/37] push-seal: name the wrong-key hazard where the mispaste happens kp prints SK then PK, both 64 hex characters once the label is stripped, and handseal takes PK. Taking the first line instead seals to a keypair nobody holds: the seal succeeds, the blob is well-formed, and the only symptom appears on the receiving device as a notification that cannot be opened -- one of the failure modes that is hardest to attribute, because every sending-side check passes. Says so at both ends, since a reader who has kp's output on screen is not necessarily reading handseal's source and vice versa. --- crates/cortexkit-push-seal/examples/handseal.rs | 7 +++++++ crates/cortexkit-push-seal/examples/kp.rs | 4 ++++ 2 files changed, 11 insertions(+) diff --git a/crates/cortexkit-push-seal/examples/handseal.rs b/crates/cortexkit-push-seal/examples/handseal.rs index 795dd1c..42b458c 100644 --- a/crates/cortexkit-push-seal/examples/handseal.rs +++ b/crates/cortexkit-push-seal/examples/handseal.rs @@ -1,5 +1,12 @@ // Proves the crate can seal a real payload to a real recipient key TODAY. // Run: cargo run -p cortexkit-push-seal --example handseal -- '' +// +// The key is the PK LINE from the `kp` example -- its SECOND line, 64 hex +// characters, no label. Taking the first line hands this the SECRET key +// instead: a 64-char secret seals successfully to a keypair nobody holds, and +// the failure appears only on the device as an undecryptable notification. +// Argument parsing is deliberately unforgiving so that a mispaste panics here +// rather than producing a blob addressed to nothing. fn main() { let a: Vec = std::env::args().collect(); let pk = (0..a[1].len()) diff --git a/crates/cortexkit-push-seal/examples/kp.rs b/crates/cortexkit-push-seal/examples/kp.rs index ebe8035..01a6d99 100644 --- a/crates/cortexkit-push-seal/examples/kp.rs +++ b/crates/cortexkit-push-seal/examples/kp.rs @@ -1,3 +1,7 @@ +// Prints two keys. PK is the recipient key that `handseal` takes; SK is the +// matching secret, needed only by `handopen` to verify a round trip. They are +// the same length in hex, so feeding the wrong one seals to a keypair nobody +// holds and fails silently on the device rather than here. fn main() { use hpke::{Kem, Serializable}; let (sk, pk) = hpke::kem::X25519HkdfSha256::gen_keypair(); From cca3031d4426b243357c74dc9eb29a2fd7a922cf Mon Sep 17 00:00:00 2001 From: ualtinok Date: Mon, 10 Aug 2026 08:34:27 +0200 Subject: [PATCH 21/37] push-seal: select the sealing key by label, and refuse prose by name The sealing key and the APNs device token are both 32 bytes rendered as 64 hex characters, so nothing about either value says which it is. X25519 accepts essentially any 32 bytes as a public key, so pasting the token here SEALS SUCCESSFULLY to a keypair nobody holds: the blob is well formed, it reaches the device, and the only symptom is a notification that cannot be opened -- which reads as a decryption fault and sends the search to the keys rather than to the paste. The opposite swap is loud, so the hazard is one-directional and silent in the direction that costs the most. handseal now accepts the labelled block the producing side emits and picks its own line out of it, which removes the ordering an operator would otherwise have to remember. A block naming only the token is refused with that named as the cause. Values carrying prose are refused rather than repaired: such a value is a failure message written where a key belongs, so the fault it describes happened before the paste and re-running will not help. Proved by round trip rather than by inspection -- a blob sealed through a token-first block opens with the key's secret, and the control that makes that meaningful is a token-sealed blob refusing against the same secret. --- .../cortexkit-push-seal/examples/handseal.rs | 114 ++++++++++++++++-- 1 file changed, 101 insertions(+), 13 deletions(-) diff --git a/crates/cortexkit-push-seal/examples/handseal.rs b/crates/cortexkit-push-seal/examples/handseal.rs index 42b458c..bfaa9af 100644 --- a/crates/cortexkit-push-seal/examples/handseal.rs +++ b/crates/cortexkit-push-seal/examples/handseal.rs @@ -1,19 +1,47 @@ // Proves the crate can seal a real payload to a real recipient key TODAY. -// Run: cargo run -p cortexkit-push-seal --example handseal -- '' // -// The key is the PK LINE from the `kp` example -- its SECOND line, 64 hex -// characters, no label. Taking the first line hands this the SECRET key -// instead: a 64-char secret seals successfully to a keypair nobody holds, and -// the failure appears only on the device as an undecryptable notification. -// Argument parsing is deliberately unforgiving so that a mispaste panics here -// rather than producing a blob addressed to nothing. +// Run: cargo run -p cortexkit-push-seal --example handseal -- '' +// +// The key argument accepts either bare hex or a labelled block pasted whole: +// +// push_seal_pubkey_hex: 63e0... +// apns_device_token_hex: 9f21... +// +// Labelled input is preferred and the label is what makes it safe. The sealing +// key and the device token are both 32 bytes rendered as 64 hex characters, so +// they are indistinguishable by shape, and X25519 accepts essentially any 32 +// bytes as a public key. A token pasted here therefore SEALS SUCCESSFULLY to a +// keypair nobody holds: the blob is well formed, it reaches the device, and the +// only symptom is a notification that cannot be opened -- which reads as a +// decryption fault and sends the investigation to the keys rather than to the +// paste. Selecting by label removes the ordering the operator would otherwise +// have to remember. +// +// Anything that is not hex is refused by name rather than repaired, because a +// value carrying prose is a failure message that was written where a key +// belongs, and the fault it describes happened before the paste. + fn main() { - let a: Vec = std::env::args().collect(); - let pk = (0..a[1].len()) + let args: Vec = std::env::args().collect(); + if args.len() < 3 { + eprintln!("usage: handseal ''"); + std::process::exit(2); + } + + let key_hex = match select_key(&args[1]) { + Ok(hex) => hex, + Err(why) => { + eprintln!("{why}"); + std::process::exit(2); + } + }; + + let pk: Vec = (0..key_hex.len()) .step_by(2) - .map(|i| u8::from_str_radix(&a[1][i..i + 2], 16).unwrap()) - .collect::>(); - let sealed = cortexkit_push_seal::seal(&pk, a[2].as_bytes()).expect("seal"); + .map(|i| u8::from_str_radix(&key_hex[i..i + 2], 16).expect("checked above")) + .collect(); + + let sealed = cortexkit_push_seal::seal(&pk, args[2].as_bytes()).expect("seal"); println!( "{}", sealed @@ -23,7 +51,67 @@ fn main() { ); eprintln!( "plaintext {} bytes -> sealed {} bytes", - a[2].len(), + args[2].len(), sealed.len() ); } + +/// Picks the sealing key out of either a bare hex string or a labelled block. +/// +/// A labelled block is selected by its `push_seal_pubkey_hex` line, so a paste +/// containing the device token alongside it cannot be taken by accident. Bare +/// input is accepted unchanged, which keeps the older single-value paste +/// working, and is the form that cannot protect against a swap. +fn select_key(raw: &str) -> Result { + const LABEL: &str = "push_seal_pubkey_hex"; + + if raw.contains(LABEL) { + let value = raw + .lines() + .find(|line| line.contains(LABEL)) + .and_then(|line| line.split(':').nth(1)) + .map(str::trim) + .ok_or_else(|| format!("found `{LABEL}` but no value after it"))?; + return validate(value); + } + + // A labelled block that names only the token is a swap caught before it can + // seal to nothing, and is worth its own message: the operator pasted the + // right kind of thing from the wrong row. + if raw.contains("apns_device_token_hex") { + return Err(format!( + "this block carries apns_device_token_hex but no {LABEL}. The device \ + token is not a sealing key; sealing to it would succeed and produce \ + a blob nobody can open." + )); + } + + validate(raw.trim()) +} + +/// Accepts exactly 64 lowercase-or-uppercase hex characters, refusing anything +/// else by naming what it found. +/// +/// Refusing rather than repairing is deliberate. A stray separator or prefix +/// means the paste was damaged, and quietly correcting it would seal to an +/// address the operator did not choose. +fn validate(value: &str) -> Result { + if value.is_empty() { + return Err("empty key".into()); + } + if let Some(bad) = value.chars().find(|c| !c.is_ascii_hexdigit()) { + return Err(format!( + "not a key: contains {bad:?}. A value carrying words, spaces or a 0x \ + prefix is a failure message written where a key belongs -- the fault \ + it names happened before the paste, so re-running this will not help." + )); + } + if value.len() != 64 { + return Err(format!( + "expected 64 hex characters, got {}. A 66-character value is usually \ + the `SK ` label taken with the secret key.", + value.len() + )); + } + Ok(value.to_string()) +} From 584ffdc7b55ebd83e00f8304864b12dce73911b2 Mon Sep 17 00:00:00 2001 From: ualtinok Date: Mon, 10 Aug 2026 08:38:30 +0200 Subject: [PATCH 22/37] push-seal: accept either separator in a labelled key block The producing side emits label=value; this example was written against label: value. A block pasted into the wrong parser failed with a message about hex characters, which points at the value when the mismatch is actually in the format -- the same misdirection the labelled form exists to remove. Splitting on either character costs one line and removes a coordination requirement between two repositories that would otherwise have to agree on punctuation and stay agreed. --- crates/cortexkit-push-seal/examples/handseal.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/cortexkit-push-seal/examples/handseal.rs b/crates/cortexkit-push-seal/examples/handseal.rs index bfaa9af..f890073 100644 --- a/crates/cortexkit-push-seal/examples/handseal.rs +++ b/crates/cortexkit-push-seal/examples/handseal.rs @@ -4,8 +4,10 @@ // // The key argument accepts either bare hex or a labelled block pasted whole: // -// push_seal_pubkey_hex: 63e0... -// apns_device_token_hex: 9f21... +// push_seal_pubkey_hex=63e0... +// apns_device_token_hex=9f21... +// +// Either `=` or `:` separates a label from its value. // // Labelled input is preferred and the label is what makes it safe. The sealing // key and the device token are both 32 bytes rendered as 64 hex characters, so @@ -66,10 +68,14 @@ fn select_key(raw: &str) -> Result { const LABEL: &str = "push_seal_pubkey_hex"; if raw.contains(LABEL) { + // Accept either separator. The producing side emits `=`, this example was + // written against `:`, and a paste that reaches the wrong parser fails with + // a message about hex rather than about the separator -- which points at + // the value when the mismatch is in the format. let value = raw .lines() .find(|line| line.contains(LABEL)) - .and_then(|line| line.split(':').nth(1)) + .and_then(|line| line.split([':', '=']).nth(1)) .map(str::trim) .ok_or_else(|| format!("found `{LABEL}` but no value after it"))?; return validate(value); From b812d88e8a6dc78d635ed3944387d19caa28ac10 Mon Sep 17 00:00:00 2001 From: ualtinok Date: Mon, 10 Aug 2026 23:05:34 +0200 Subject: [PATCH 23/37] Add prepaid balances and credit pools to ProviderUsage Providers that sell credit alongside a subscription report a balance with no period, which the existing shape cannot carry: a rate window is a percentage of a period, and a pool is an amount with neither. Today that data is fetched by producers and discarded, so an account with a depleted window and a live credit pool reads as unusable when it would have served the request. Kept apart from `usage` rather than folded in, because the two fail in opposite directions: over-consuming a window gets you throttled and recovers by waiting, over-consuming a balance gets you billed and recovers by paying. A balance therefore never becomes a window, never carries a reset, and never appears as a percentage. Three choices in here were forced by real payloads rather than picked. Amounts are integer minor units because DeepSeek and MiniMax both send decimal strings and Anthropic sends minor units with an exponent -- and because a balance is compared against zero on every routing decision that reads it, where binary floats are not safe. Pool ids carry the provider's own name, since wallets separate voucher from cash and credit without defining which is a gift, and renaming one `granted` would invent the label a spend policy keys on. And `basis` distinguishes a reported remainder from one derived against a shared total, because DeepSeek reports per-pool remainders while others report only grants -- which is the difference between an exact policy and a ceiling. Additive: an entry without pools serializes exactly as before, pinned by a test on the rendered text rather than the field. --- crates/cortexkit-provider-usage/Cargo.toml | 2 +- crates/cortexkit-provider-usage/src/lib.rs | 169 +++++++++++++++++++++ 2 files changed, 170 insertions(+), 1 deletion(-) diff --git a/crates/cortexkit-provider-usage/Cargo.toml b/crates/cortexkit-provider-usage/Cargo.toml index 6128ac2..5b5a6ac 100644 --- a/crates/cortexkit-provider-usage/Cargo.toml +++ b/crates/cortexkit-provider-usage/Cargo.toml @@ -11,7 +11,7 @@ # says; this crate makes no guarantee about how a producer derived the numbers. [package] name = "cortexkit-provider-usage" -version = "0.4.1" +version = "0.5.0" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/crates/cortexkit-provider-usage/src/lib.rs b/crates/cortexkit-provider-usage/src/lib.rs index abd0453..52f34dd 100644 --- a/crates/cortexkit-provider-usage/src/lib.rs +++ b/crates/cortexkit-provider-usage/src/lib.rs @@ -131,6 +131,104 @@ pub struct Usage { pub extra_rate_windows: Option>, } +/// An amount of money or credit, in integer minor units. +/// +/// Not a float, and the reason is not stylistic. A balance is compared against +/// zero on every routing decision that reads it, and binary floating point +/// cannot hold ordinary decimal amounts exactly — the nearest `f64` to `0.1` is +/// not `0.1`, so sums drift and a comparison near zero can fall either way. The +/// providers agree: DeepSeek and MiniMax both send decimal strings, and +/// Anthropic sends integer minor units with an exponent. +/// +/// Parse a provider's own representation once, where its precision is still +/// known, rather than passing a float along and re-rendering it. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Amount { + /// The amount in minor units: `1050` with `exponent: 2` is 10.50. + pub minor: i64, + /// Decimal places in `minor`. `2` for currencies with cents; `0` for whole + /// credits or points. + pub exponent: u8, + /// What the amount is denominated in: a currency code like `"USD"`, or a + /// provider's own label for its credits. + /// + /// A free string rather than a currency enum, because not every pool is + /// money — some are points that convert to no currency, and an enum would + /// force those into a currency slot or drop them. + pub unit: String, +} + +/// Where a pool's balance came from, which decides what a consumer may promise. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum PoolFunding { + /// Given by the provider: a promotion, a trial grant, a voucher. Spendable + /// without a bill. + Granted, + /// Bought. Spending it costs money. + Purchased, + /// Included in a subscription the account already pays for. + Subscription, + /// The provider separates this pool but does not say what funds it. + /// + /// A correct answer rather than a failure one: some providers name their + /// pools without defining them, and guessing the funding is how a consumer + /// ends up spending money it meant to protect. + Unknown, +} + +/// How a pool's `remaining` was obtained. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum PoolBasis { + /// The provider states this pool's remaining balance directly. + Reported, + /// Computed from a total and a consumption figure that covers several pools + /// at once, so the split between them is not known. + /// + /// The distinction is load-bearing for any "spend only granted credits" + /// policy: against a `Reported` pool it is exact, and against a `Derived` + /// one it can only be a ceiling. + Derived, +} + +/// A prepaid balance or credit pool on an account. +/// +/// Plural by necessity: one figure cannot express "9.50 granted and 40 +/// purchased", which is exactly the distinction a consumer needs to spend the +/// first without spending the second. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Pool { + /// The provider's own name for this pool, never one invented here. + /// + /// Providers separate pools without always defining them — a wallet may list + /// voucher, cash and credit balances and document none of them. Passing the + /// provider's name through lets a consumer decide; renaming one `granted` + /// would be inventing the label a spend policy keys on. + pub id: String, + /// Human-readable name for display. + pub label: String, + /// What funds this pool. + pub funding: PoolFunding, + /// What is left, when it can be established. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub remaining: Option, + /// The pool's size, when the provider reports one. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub total: Option, + /// How `remaining` was obtained. Read it before acting on `remaining`. + pub basis: PoolBasis, + /// Whether the provider says this pool may currently be drawn on. + /// + /// Read from the provider, never inferred from `remaining > 0`: a pool can + /// be non-empty and closed, which several providers publish directly through + /// their own enable flags. Absent means the provider does not say. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub spendable: Option, +} + /// Account labels and subscription information supplied by a provider or vault. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)] #[serde(rename_all = "camelCase")] @@ -242,6 +340,22 @@ pub struct ProviderUsage { /// `error_class` to tell those apart, rather than inferring from this field. #[serde(skip_serializing_if = "Option::is_none")] pub usage: Option, + /// Prepaid balances and credit pools on this account, when the provider + /// reports any. + /// + /// Deliberately apart from [`Self::usage`], because a pool and a rate window + /// are different facts that fail in opposite directions: over-consuming a + /// window gets you throttled and recovers by waiting, while over-consuming a + /// balance gets you billed and recovers by paying. Nothing in a routing loop + /// can undo the second, so a balance is never expressed as a window, never + /// carries a reset, and never appears as a percentage — a consumer that + /// found one where it expects headroom would pace into a bill. + /// + /// Absent means the producer has nothing to say, which is not the same as an + /// account having no credit. Empty means it looked and the provider reports + /// no pools. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub spend: Option>, /// Present only on a degraded entry. The consumer skips any entry with a /// truthy `error`. #[serde(skip_serializing_if = "Option::is_none")] @@ -292,6 +406,7 @@ impl ProviderUsage { fetched_at: None, saved_resets: None, usage: Some(usage), + spend: None, error: None, error_class: None, } @@ -309,6 +424,7 @@ impl ProviderUsage { fetched_at: None, saved_resets: None, usage: None, + spend: None, error: Some(error.to_string()), error_class: None, } @@ -536,6 +652,59 @@ mod tests { assert_eq!(entry.error.as_deref(), Some("something new")); } + /// An entry with no pools serializes exactly as it did before pools existed. + /// + /// Consumers pin these payloads, so an additive field that appears as `null` + /// on every existing entry is not additive in practice. The check is on the + /// rendered text rather than on the field, because that is what a consumer + /// parses. + #[test] + fn an_entry_without_pools_does_not_mention_them() { + let entry = ProviderUsage::healthy("codex", None, "oauth", Usage::default()); + let json = serde_json::to_string(&entry).unwrap(); + assert!(!json.contains("spend"), "unexpected spend key: {json}"); + } + + /// Pools survive a round trip, including the two fields a consumer must read + /// before acting on an amount. + /// + /// `basis` and `funding` are what separate "you have 10 granted credits + /// left" from "you were granted 10 credits and we cannot tell how many + /// remain". A consumer that loses either one is left with a number it cannot + /// safely spend against. + #[test] + fn pools_round_trip_with_their_basis_and_funding() { + let pool = Pool { + id: "granted_balance".to_string(), + label: "Granted".to_string(), + funding: PoolFunding::Granted, + remaining: Some(Amount { + minor: 1050, + exponent: 2, + unit: "CNY".to_string(), + }), + total: None, + basis: PoolBasis::Reported, + spendable: Some(true), + }; + let mut entry = ProviderUsage::healthy("deepseek", None, "api", Usage::default()); + entry.spend = Some(vec![pool.clone()]); + + let json = serde_json::to_string(&entry).unwrap(); + let back: ProviderUsage = serde_json::from_str(&json).unwrap(); + assert_eq!(back.spend, Some(vec![pool])); + + // Rendered as the wire spells them, since consumers key on these. + assert!(json.contains(r#""funding":"granted""#), "{json}"); + assert!(json.contains(r#""basis":"reported""#), "{json}"); + // 10.50 CNY is carried as minor units, never as a float. + assert!(json.contains(r#""minor":1050"#), "{json}"); + assert!( + !json.contains("10.5"), + "an amount was rendered as a decimal: {json}" + ); + } + /// A healthy entry must never carry a class: the field's presence is itself /// a signal, and a class on a working provider would be a contradiction a /// consumer has to resolve. From ec9a015b68b5303c3bc6b2d5dd1b29690b51b59a Mon Sep 17 00:00:00 2001 From: ualtinok Date: Mon, 10 Aug 2026 23:14:30 +0200 Subject: [PATCH 24/37] Stop an unknown pool kind from discarding the whole entry PoolFunding and PoolBasis were closed enums, and this payload crosses a repository boundary: one project produces it, others consume it, and their versions move independently. So the first funding kind added after a consumer is built fails deserialization of the ENTIRE ProviderUsage entry rather than one field. Measured rather than argued: an entry carrying a healthy 42% window and two pools, one with an unrecognised funding, loses everything -- "unknown variant `crypto_grant`". An account's rate windows would vanish because of a credit pool the consumer had never heard of, and a vanished entry reads as the provider being unavailable. For funding the fallback costs nothing, because the fallback and the semantics already agree: a kind this consumer cannot name is one it must not spend from, which is what Unknown already meant. Basis needed a decision rather than a default. Its two poles are not symmetrical -- treating an exact remainder as a ceiling under-spends and costs nothing, while treating a ceiling as exact spends money that may not be there -- so an unrecognised value must fold to the conservative side. It folds to a new Unstated variant rather than to Derived, because both are read the same way but Derived is a claim about how a number was obtained, and answering "I do not know" with it would assert a fact the producer does not hold. That is the failure this type exists to prevent, one level up. Both proven by removing the fallbacks: each test reddens by name. --- crates/cortexkit-provider-usage/src/lib.rs | 84 +++++++++++++++++++++- 1 file changed, 83 insertions(+), 1 deletion(-) diff --git a/crates/cortexkit-provider-usage/src/lib.rs b/crates/cortexkit-provider-usage/src/lib.rs index 52f34dd..3546666 100644 --- a/crates/cortexkit-provider-usage/src/lib.rs +++ b/crates/cortexkit-provider-usage/src/lib.rs @@ -170,11 +170,20 @@ pub enum PoolFunding { Purchased, /// Included in a subscription the account already pays for. Subscription, - /// The provider separates this pool but does not say what funds it. + /// The provider separates this pool but does not say what funds it, **or** + /// the producer named a funding kind this consumer does not recognise. /// /// A correct answer rather than a failure one: some providers name their /// pools without defining them, and guessing the funding is how a consumer /// ends up spending money it meant to protect. + /// + /// It is also the deserialization fallback, and the two meanings genuinely + /// agree — a funding kind added after this consumer was built is, to this + /// consumer, of unknown funding. Without the fallback an unrecognised value + /// fails the whole `ProviderUsage` entry rather than this one field, so a + /// new pool kind would take an account's *usage* down with it and read as + /// the provider being unavailable. + #[serde(other)] Unknown, } @@ -191,6 +200,21 @@ pub enum PoolBasis { /// policy: against a `Reported` pool it is exact, and against a `Derived` /// one it can only be a ceiling. Derived, + /// No basis was stated, or one was stated that this consumer does not + /// recognise. **Treat `remaining` as a ceiling, never as exact.** + /// + /// This is deliberately its own variant rather than folding an unrecognised + /// value into [`Self::Derived`]. Both are read conservatively, so the + /// spending behaviour is the same either way — but `Derived` is a statement + /// about how a number was obtained, and answering "I do not know" with it + /// would have the producer assert a fact it does not hold. That is the + /// failure this type exists to prevent, one level up. + /// + /// Reading it conservatively is safe in the direction that matters: an + /// exact remainder treated as a ceiling under-spends, while a ceiling + /// treated as exact spends money that may not be there. + #[serde(other)] + Unstated, } /// A prepaid balance or credit pool on an account. @@ -705,6 +729,64 @@ mod tests { ); } + /// An unrecognised funding kind must not take the entry down with it. + /// + /// This payload crosses a repository boundary: one project produces it, + /// others consume it, and their versions move independently. A closed enum + /// makes the first new funding kind fail deserialization of the WHOLE + /// `ProviderUsage` entry rather than one field, so an account's rate windows + /// would vanish because of a credit pool the consumer had never heard of -- + /// and a vanished entry reads as the provider being unavailable. + /// + /// Asserted on a mixed entry rather than on the enum alone, because the + /// blast radius is the point: the usage figure below is what a router acts + /// on, and it is downstream of the pool that failed. + #[test] + fn an_unknown_funding_kind_does_not_discard_the_entry() { + let json = r#"{ + "provider": "minimax", + "usage": { "primary": { "usedPercent": 42.0 } }, + "spend": [ + { "id": "a", "label": "A", "funding": "granted", "basis": "reported" }, + { "id": "b", "label": "B", "funding": "crypto_grant", "basis": "reported" } + ] + }"#; + + let entry: ProviderUsage = serde_json::from_str(json).expect("entry must survive"); + let pools = entry.spend.expect("pools present"); + assert_eq!(pools.len(), 2, "no pool may be dropped"); + assert_eq!(pools[0].funding, PoolFunding::Granted); + // The unrecognised kind lands on Unknown, which is the correct reading: + // a funding this consumer cannot name is one it must not spend from. + assert_eq!(pools[1].funding, PoolFunding::Unknown); + // And the part a router acts on survived. + assert_eq!( + entry.usage.and_then(|u| u.primary).map(|w| w.used_percent), + Some(42.0) + ); + } + + /// An unrecognised basis reads as unstated, never as exact. + /// + /// The two poles are not symmetrical. Treating an exact remainder as a + /// ceiling under-spends and costs nothing; treating a ceiling as exact + /// spends money that may not be there. So the fallback folds to the + /// conservative side, and does so under its own name rather than claiming + /// the number was derived -- which would assert a fact about a computation + /// the consumer knows nothing about. + #[test] + fn an_unknown_basis_is_unstated_rather_than_exact() { + let json = r#"{ "id": "a", "label": "A", "funding": "granted", + "basis": "sampled_hourly" }"#; + let pool: Pool = serde_json::from_str(json).expect("pool must survive"); + assert_eq!(pool.basis, PoolBasis::Unstated); + assert_ne!( + pool.basis, + PoolBasis::Reported, + "an unknown basis must never read as an exact remainder" + ); + } + /// A healthy entry must never carry a class: the field's presence is itself /// a signal, and a class on a working provider would be a contradiction a /// consumer has to resolve. From ffdd06adf743ad26fe171e048e021a8354f45a5a Mon Sep 17 00:00:00 2001 From: ualtinok Date: Tue, 11 Aug 2026 12:19:45 +0200 Subject: [PATCH 25/37] model-catalog: read tier thresholds from tier.tier.size, refuse when absent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parser read context-pricing tier thresholds from keys models.dev has never emitted (context_over / min_context), so every threshold silently parsed to 0 via unwrap_or — meaning the over-threshold rate claimed to apply from context 0 on all 335 live tier rows. The fixture was authored from the same misunderstanding as the parser, so its non-vacuous assertion certified the defect. The real threshold lives at tier.tier.size (335/335 rows on the live payload). A missing threshold is now a loud MissingTierThreshold error, never a silent 0: a tier whose floor defaults to 0 is a silent repricing. Two new tests pin both failure directions (absent threshold refuses; the invented legacy keys do not satisfy it), mutation-proven by restoring the unwrap_or(0) — both redden, the baseline passes. Found by FUSI executing the shipped parser against live bytes; writeup at fusiform/docs/findings/2026-08-11-commons-tier-threshold.md. --- crates/cortexkit-model-catalog/Cargo.toml | 2 +- crates/cortexkit-model-catalog/src/lib.rs | 60 ++++++++++++++++++++--- 2 files changed, 54 insertions(+), 8 deletions(-) diff --git a/crates/cortexkit-model-catalog/Cargo.toml b/crates/cortexkit-model-catalog/Cargo.toml index e800916..942fe2b 100644 --- a/crates/cortexkit-model-catalog/Cargo.toml +++ b/crates/cortexkit-model-catalog/Cargo.toml @@ -4,7 +4,7 @@ # each brings its own snapshot and owns its own derived stores. [package] name = "cortexkit-model-catalog" -version = "0.1.0" +version = "0.2.0" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/crates/cortexkit-model-catalog/src/lib.rs b/crates/cortexkit-model-catalog/src/lib.rs index a51328d..8bfb84d 100644 --- a/crates/cortexkit-model-catalog/src/lib.rs +++ b/crates/cortexkit-model-catalog/src/lib.rs @@ -43,6 +43,10 @@ pub enum CatalogParseError { field: &'static str, value: String, }, + /// A pricing tier without a `tier.size` context threshold. A tier whose + /// floor cannot be read must not default to 0: that would apply the + /// over-threshold rate to every request, which is a silent repricing. + MissingTierThreshold, } impl std::fmt::Display for CatalogParseError { @@ -56,6 +60,9 @@ impl std::fmt::Display for CatalogParseError { CatalogParseError::NegativeRate { provider, model, field, value } => { write!(f, "catalog rate {provider}/{model}.{field} = {value} is negative") } + CatalogParseError::MissingTierThreshold => { + write!(f, "catalog pricing tier lacks a tier.size context threshold") + } } } } @@ -273,12 +280,20 @@ fn parse_cost( Some(v) => convert(field, v).map(Some), } }; + // The threshold lives at tier.tier.size (with tier.tier.type == + // "context") in the models.dev shape — measured at 335/335 tier + // rows on the live payload. Earlier revisions read invented keys + // (context_over / min_context) that the upstream never emitted, so + // every threshold silently parsed to 0; a missing threshold is now + // a loud error, because a tier whose floor defaults to 0 applies + // its over-threshold rate to every request. + let min_context = tier + .get("tier") + .and_then(|t| t.get("size")) + .and_then(Value::as_u64) + .ok_or(CatalogParseError::MissingTierThreshold)?; tiers.push(CostTier { - min_context: tier - .get("context_over") - .or_else(|| tier.get("min_context")) - .and_then(Value::as_u64) - .unwrap_or(0), + min_context, input: trate("input")?, output: trate("output")?, cache_read: trate("cache_read")?, @@ -469,7 +484,7 @@ mod tests { "cost": { "input": 1.25, "output": 10, "tiers": [ - { "context_over": 200000, "input": 2.5, "output": 15 } + { "tier": { "type": "context", "size": 200000 }, "input": 2.5, "output": 15 } ] } } @@ -510,6 +525,37 @@ mod tests { assert_eq!(model.cost.tiers[0].input, Some(2_500_000_000)); } + /// The threshold must come from `tier.tier.size` — the shape models.dev + /// actually emits (335/335 tier rows on the 2026-08-11 live payload). + /// Earlier revisions read invented keys (`context_over`/`min_context`) + /// that no upstream snapshot ever carried, so every threshold silently + /// parsed to 0 and the fixture, authored from the same misunderstanding, + /// certified it. These two tests pin both failure directions. + #[test] + fn tier_threshold_missing_is_a_loud_error_never_zero() { + // A tier with rates but NO tier.size: must refuse, not default to 0. + let err = CatalogDoc::parse( + r#"{ "p": { "models": { "m": { "cost": { "tiers": [ { "input": 2.5 } ] } } } } }"#, + ) + .unwrap_err(); + assert!(matches!(err, CatalogParseError::MissingTierThreshold)); + } + + #[test] + fn tier_threshold_ignores_the_invented_legacy_keys() { + // The keys earlier revisions read. If someone "restores compatibility" + // with them, the threshold would come from a field no upstream emits — + // this must stay a loud refusal on the missing REAL key. + let err = CatalogDoc::parse( + r#"{ "p": { "models": { "m": { "cost": { "tiers": [ { "context_over": 200000, "input": 2.5 } ] } } } } }"#, + ) + .unwrap_err(); + assert!( + matches!(err, CatalogParseError::MissingTierThreshold), + "context_over must not satisfy the threshold: models.dev never emitted it" + ); + } + #[test] fn raw_passthrough_preserves_unmodeled_fields() { let doc = @@ -541,7 +587,7 @@ mod tests { } // Tier rates are guarded by the same gate. let err = CatalogDoc::parse( - r#"{ "p": { "models": { "m": { "cost": { "tiers": [ { "context_over": 1, "input": -1 } ] } } } } }"#, + r#"{ "p": { "models": { "m": { "cost": { "tiers": [ { "tier": { "type": "context", "size": 1 }, "input": -1 } ] } } } } }"#, ) .unwrap_err(); assert!(matches!(err, CatalogParseError::NegativeRate { .. })); From 528b680ae1a6f5498fbe70fed5a8daf46f1b4cd3 Mon Sep 17 00:00:00 2001 From: ualtinok Date: Tue, 11 Aug 2026 12:25:06 +0200 Subject: [PATCH 26/37] model-catalog: gate tier dimension on tier.type == "context"; name the row in the refusal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FUSI's residual findings on ffdd06a, both probe-verified: (1) the parser read tier.tier.size without checking tier.tier.type, so a hypothetical per-image tier ({type: images, size: 1000}) was silently reinterpreted as a 1000-token context floor — the same assumed-meaning class as the original defect, one field over, and plausible upstream given models.dev already lists image/audio/video models. min_context is a claim that the dimension IS context; the type gate is part of the threshold's meaning. (2) MissingTierThreshold carried no row identifier while being fatal to the whole parse — a refusal over 6,253 models without a pointer turns a five-second fix into a bisect; it now carries provider/model like its neighbouring variants. Type-gate mutation-proven (removing it reddens the non-context test by name); live payload still parses 335/335 nonzero. --- crates/cortexkit-model-catalog/src/lib.rs | 85 +++++++++++++++++++---- 1 file changed, 71 insertions(+), 14 deletions(-) diff --git a/crates/cortexkit-model-catalog/src/lib.rs b/crates/cortexkit-model-catalog/src/lib.rs index 8bfb84d..5f6fd17 100644 --- a/crates/cortexkit-model-catalog/src/lib.rs +++ b/crates/cortexkit-model-catalog/src/lib.rs @@ -43,10 +43,16 @@ pub enum CatalogParseError { field: &'static str, value: String, }, - /// A pricing tier without a `tier.size` context threshold. A tier whose - /// floor cannot be read must not default to 0: that would apply the + /// A pricing tier whose dimension cannot be verified as context-based, or + /// a context tier without a `tier.size` threshold. A tier whose floor + /// cannot be read must not default to 0: that would apply the /// over-threshold rate to every request, which is a silent repricing. - MissingTierThreshold, + /// Carries the row so an operator diagnoses a rejected snapshot without + /// bisecting the payload. + MissingTierThreshold { + provider: String, + model: String, + }, } impl std::fmt::Display for CatalogParseError { @@ -60,8 +66,11 @@ impl std::fmt::Display for CatalogParseError { CatalogParseError::NegativeRate { provider, model, field, value } => { write!(f, "catalog rate {provider}/{model}.{field} = {value} is negative") } - CatalogParseError::MissingTierThreshold => { - write!(f, "catalog pricing tier lacks a tier.size context threshold") + CatalogParseError::MissingTierThreshold { provider, model } => { + write!( + f, + "catalog pricing tier on {provider}/{model} lacks a verifiable context threshold (tier.type/tier.size)" + ) } } } @@ -280,18 +289,31 @@ fn parse_cost( Some(v) => convert(field, v).map(Some), } }; - // The threshold lives at tier.tier.size (with tier.tier.type == - // "context") in the models.dev shape — measured at 335/335 tier + // The threshold lives at tier.tier.size with tier.tier.type == + // "context" in the models.dev shape — measured at 335/335 tier // rows on the live payload. Earlier revisions read invented keys // (context_over / min_context) that the upstream never emitted, so // every threshold silently parsed to 0; a missing threshold is now // a loud error, because a tier whose floor defaults to 0 applies - // its over-threshold rate to every request. - let min_context = tier - .get("tier") - .and_then(|t| t.get("size")) + // its over-threshold rate to every request. The `type` gate is + // load-bearing too: `min_context` is a claim that the dimension IS + // context, so a non-context tier (per-image, per-second) must not + // have its size read as a token threshold — the upstream already + // lists image/audio/video models, so a second tier type is a + // plausible upstream addition, not a hypothetical. + let tier_err = || CatalogParseError::MissingTierThreshold { + provider: provider.to_string(), + model: model.to_string(), + }; + let dim = tier.get("tier").ok_or_else(tier_err)?; + if dim.get("type").and_then(Value::as_str) != Some("context") { + return Err(tier_err()); + } + + let min_context = dim + .get("size") .and_then(Value::as_u64) - .ok_or(CatalogParseError::MissingTierThreshold)?; + .ok_or_else(tier_err)?; tiers.push(CostTier { min_context, input: trate("input")?, @@ -538,7 +560,10 @@ mod tests { r#"{ "p": { "models": { "m": { "cost": { "tiers": [ { "input": 2.5 } ] } } } } }"#, ) .unwrap_err(); - assert!(matches!(err, CatalogParseError::MissingTierThreshold)); + assert!(matches!( + err, + CatalogParseError::MissingTierThreshold { .. } + )); } #[test] @@ -551,11 +576,43 @@ mod tests { ) .unwrap_err(); assert!( - matches!(err, CatalogParseError::MissingTierThreshold), + matches!(err, CatalogParseError::MissingTierThreshold { .. }), "context_over must not satisfy the threshold: models.dev never emitted it" ); } + #[test] + fn tier_of_a_non_context_dimension_is_refused_not_reinterpreted() { + // A per-image tier must not have its size read as a token threshold: + // min_context is a CLAIM that the dimension is context, so the type + // gate is part of the threshold's meaning, not decoration. + let err = CatalogDoc::parse( + r#"{ "p": { "models": { "m": { "cost": { "tiers": [ { "tier": { "type": "images", "size": 1000 }, "input": 2.5 } ] } } } } }"#, + ) + .unwrap_err(); + match err { + CatalogParseError::MissingTierThreshold { provider, model } => { + assert_eq!((provider.as_str(), model.as_str()), ("p", "m")); + } + other => panic!("expected MissingTierThreshold, got {other:?}"), + } + } + + #[test] + fn tier_error_names_the_offending_row() { + // The parse failure is fatal to the whole snapshot, so the error must + // point at the row: a refusal over 6,253 models without an identifier + // turns a five-second fix into a bisect. + let err = CatalogDoc::parse( + r#"{ "prov": { "models": { "mod": { "cost": { "tiers": [ { "input": 2.5 } ] } } } } }"#, + ) + .unwrap_err(); + assert_eq!( + err.to_string(), + "catalog pricing tier on prov/mod lacks a verifiable context threshold (tier.type/tier.size)" + ); + } + #[test] fn raw_passthrough_preserves_unmodeled_fields() { let doc = From 7fe008f8e9e7b2b03d5434c00753f1b2c3d17082 Mon Sep 17 00:00:00 2001 From: ualtinok Date: Wed, 12 Aug 2026 16:36:13 +0200 Subject: [PATCH 27/37] =?UTF-8?q?provider-usage:=20used=5Fcount/total=5Fco?= =?UTF-8?q?unt=20docs=20decoupled=20=E2=80=94=20the=20cap=20can=20be=20kno?= =?UTF-8?q?wn=20while=20consumption=20is=20only=20a=20percentage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit qwen-cloud now publishes totalCount with no usedCount: the console's derived count carried a disagreement between two provider endpoints (observed percentage matched no integer count over the reported cap), so the emitter dropped it rather than rounding to a number that never existed. The doc pairing 'present alongside used_count' no longer holds, and used_count's contract is now stated: integral, upstream's own figure only, never recovered from a percentage and a cap. Doc-only change; no wire or serde behavior moves, no version bump per the version-check's doc-only exemption. --- crates/cortexkit-provider-usage/src/lib.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/crates/cortexkit-provider-usage/src/lib.rs b/crates/cortexkit-provider-usage/src/lib.rs index 3546666..7b6e4bc 100644 --- a/crates/cortexkit-provider-usage/src/lib.rs +++ b/crates/cortexkit-provider-usage/src/lib.rs @@ -66,13 +66,18 @@ pub struct RateWindow { /// the consumer then paces on utilization alone rather than a burn rate. #[serde(skip_serializing_if = "Option::is_none")] pub window_minutes: Option, - /// Absolute consumed count in the window (e.g. tokens, requests). Present - /// only when the provider reports or derives it; human-facing UIs can show - /// "10,336 / 40,000" alongside the percentage for richer context. + /// Absolute consumed count in the window (e.g. tokens, requests). A count + /// of things, so integral by contract, and only ever the upstream's own + /// figure — never recovered from a percentage and a cap (a derived figure + /// can carry a disagreement between two provider endpoints while wearing + /// a type that claims exactness). Omitted when the upstream reports only + /// a percentage. Human-facing UIs can show "10,336 / 40,000" alongside + /// the percentage for richer context. #[serde(skip_serializing_if = "Option::is_none", default)] pub used_count: Option, - /// Absolute total cap for the window. Present alongside `used_count` when - /// the provider knows the ceiling; omitted otherwise. + /// Absolute total cap for the window, when the upstream states one. May + /// appear without `used_count`: the cap can be known while the consumed + /// figure is only a percentage. #[serde(skip_serializing_if = "Option::is_none", default)] pub total_count: Option, } From 7d8e4151a0755654cff1c982cde8a9f64be52e3c Mon Sep 17 00:00:00 2001 From: ualtinok Date: Thu, 13 Aug 2026 17:28:57 +0200 Subject: [PATCH 28/37] provider-usage: disclose a reading served through an ongoing failure (#12) A preserved last-known-good entry is currently byte-identical to a fresh one apart from fetchedAt, so a consumer cannot separate "this figure is old because the producer cannot reach the provider" from "this figure is old because nothing polled recently". Those have opposite remedies, and a consumer with only a timestamp has to guess with a wall-clock threshold -- which denies fresh-enough data in order to catch stale data. One consumer built exactly that and had a dispatch blocked on a 58-minute snapshot, which was genuinely stale but indistinguishable from a slow poll at the moment it mattered. `since` is deliberately not fetchedAt: the reading was taken when it was taken, and the failure began afterwards. The gap between them is how long the producer has been blind, which is what a staleness policy wants. Additive and absent on a fresh entry, so today's shape is byte-identical and a consumer predating the field decodes unchanged -- both pinned. --- crates/cortexkit-provider-usage/Cargo.toml | 2 +- crates/cortexkit-provider-usage/src/lib.rs | 114 +++++++++++++++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/crates/cortexkit-provider-usage/Cargo.toml b/crates/cortexkit-provider-usage/Cargo.toml index 5b5a6ac..a3a4a2f 100644 --- a/crates/cortexkit-provider-usage/Cargo.toml +++ b/crates/cortexkit-provider-usage/Cargo.toml @@ -11,7 +11,7 @@ # says; this crate makes no guarantee about how a producer derived the numbers. [package] name = "cortexkit-provider-usage" -version = "0.5.0" +version = "0.6.0" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/crates/cortexkit-provider-usage/src/lib.rs b/crates/cortexkit-provider-usage/src/lib.rs index 7b6e4bc..6f04fbd 100644 --- a/crates/cortexkit-provider-usage/src/lib.rs +++ b/crates/cortexkit-provider-usage/src/lib.rs @@ -421,6 +421,44 @@ pub struct ProviderUsage { /// this field. #[serde(skip_serializing_if = "Option::is_none", default)] pub error_class: Option, + /// Present when this entry is a last-known-good reading served through an + /// ongoing failure, absent when it is a fresh success. + /// + /// Without it a preserved reading is byte-identical to a fresh one apart + /// from `fetched_at`, so a consumer cannot separate "this figure is old + /// because the producer has been unable to reach the provider" from "this + /// figure is old because nothing polled recently". Those have opposite + /// remedies — the first is a reason to stop acting on the number, the + /// second is not — and a consumer with only a timestamp has to guess with a + /// wall-clock threshold, which denies fresh-enough data to catch stale data. + /// + /// A producer serving preserved readings is behaving correctly: a brief + /// upstream failure should not blank a window. This field discloses that it + /// is happening rather than reporting a fault. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub stale: Option, +} + +/// Why an entry is being served through a failure, and since when. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Stale { + /// When the producer first failed to refresh this entry, RFC3339. + /// + /// Distinct from `fetched_at`, which is when the served reading was + /// obtained. The gap between them is how long the producer has been unable + /// to look, which is the quantity a staleness policy actually wants: a + /// reading can be minutes old with the producer perfectly healthy, or + /// seconds old with the producer failing since just after it was taken. + pub since: String, + /// The failure class, using the same vocabulary as `error_class` on a + /// degraded entry. + /// + /// Carried so a consumer can tell a flapping upstream from a credential + /// that has started refusing, without branching on prose. Optional because + /// a producer may preserve a reading for a reason it cannot classify. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub class: Option, } impl ProviderUsage { @@ -438,6 +476,7 @@ impl ProviderUsage { spend: None, error: None, error_class: None, + stale: None, } } @@ -456,6 +495,7 @@ impl ProviderUsage { spend: None, error: Some(error.to_string()), error_class: None, + stale: None, } } @@ -480,6 +520,80 @@ impl ProviderUsage { #[cfg(test)] mod tests { + + /// An entry without the field serializes exactly as before. + /// + /// Every consumer decoding today's shape must keep working, so absence has + /// to be byte-identical rather than merely tolerated. + #[test] + fn a_fresh_entry_carries_no_stale_key() { + let entry = ProviderUsage::healthy("codex", None, "oauth", Usage::default()); + let json = serde_json::to_string(&entry).expect("entry serializes"); + + assert!( + !json.contains("stale"), + "a fresh entry must not emit the key at all: {json}" + ); + } + + /// A preserved reading states when the producer stopped being able to look. + /// + /// `since` is deliberately not `fetchedAt`: the reading was taken when it + /// was taken, and the failure began afterwards. The gap between the two is + /// how long the producer has been blind, which is the quantity a staleness + /// policy wants -- an entry can be minutes old with the producer healthy, + /// or seconds old with it failing since just after the read. + #[test] + fn a_preserved_reading_states_when_the_failure_began() { + let mut entry = ProviderUsage::healthy("codex", None, "oauth", Usage::default()); + entry.fetched_at = Some("2026-08-13T10:00:00Z".to_string()); + entry.stale = Some(Stale { + since: "2026-08-13T10:02:00Z".to_string(), + class: Some("upstream_failed".to_string()), + }); + + let json = serde_json::to_string(&entry).expect("entry serializes"); + let back: ProviderUsage = serde_json::from_str(&json).expect("entry round-trips"); + let stale = back.stale.expect("the disclosure survives the round trip"); + + assert_eq!(stale.since, "2026-08-13T10:02:00Z"); + assert_eq!(stale.class.as_deref(), Some("upstream_failed")); + assert_ne!( + Some(stale.since.as_str()), + back.fetched_at.as_deref(), + "the two timestamps answer different questions and must not be conflated" + ); + assert!( + json.contains("\"stale\""), + "the key is camelCase on the wire: {json}" + ); + } + + /// A producer that cannot classify the failure still discloses the state. + /// + /// Optional rather than required so a preserved reading is never suppressed + /// for want of a label -- disclosing "this is stale, cause unstated" beats + /// looking fresh. + #[test] + fn a_disclosure_without_a_class_still_decodes() { + let json = r#"{"provider":"codex","stale":{"since":"2026-08-13T10:02:00Z"}}"#; + let entry: ProviderUsage = serde_json::from_str(json).expect("decodes"); + let stale = entry.stale.expect("present"); + + assert_eq!(stale.class, None); + assert_eq!(stale.since, "2026-08-13T10:02:00Z"); + } + + /// An entry from a producer that predates the field decodes unchanged. + #[test] + fn an_entry_without_the_field_decodes() { + let json = r#"{"provider":"codex","source":"oauth"}"#; + let entry: ProviderUsage = serde_json::from_str(json).expect("decodes"); + + assert_eq!(entry.stale, None); + assert_eq!(entry.provider, "codex"); + } + use super::*; #[test] From 483cad334764ecb4853b3effd8bc386993a6f8a5 Mon Sep 17 00:00:00 2001 From: ualtinok Date: Fri, 14 Aug 2026 10:17:39 +0200 Subject: [PATCH 29/37] docs: lease store density finding parked by decision (BROCA measurement) --- docs/lease-store-density.md | 45 +++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 docs/lease-store-density.md diff --git a/docs/lease-store-density.md b/docs/lease-store-density.md new file mode 100644 index 0000000..b09165e --- /dev/null +++ b/docs/lease-store-density.md @@ -0,0 +1,45 @@ +# Lease store density: measured finding, decision recorded + +Status: PARKED BY DECISION (2026-08-14). Owner: SUBC (commons owner). + +## Measurement (BROCA, 2026-08-14, re-run independently before delivery) + +- 20,484 lease files; 20,933 logical bytes; 83,902,464 physical bytes (80.0 MiB) +- Amplification 4,008x on a 4 KiB-block APFS volume (portable number is the + logical ~20 KiB; amplification varies with block size / inline-data support) +- 99.7% of runs mint a new session identity (743 files/24h by st_birthtime, + agreeing with 741 runs / 739 distinct sessions from run_index) -> ~2.9 MiB/day +- A packed WITHOUT ROWID table holding the same 20,482 pairs measures + 466,944 bytes (22.8 bytes/key, 99.4% reduction) on the real corpus + +## Why this is parked rather than fixed + +The layout lives in `cortexkit-lease`; five repos depend on it (broca, +claustrum, synapse, broca-tagref, commons). A layout change is a cross-repo +migration with a window where an epoch must be durable in BOTH the old file +and the new table simultaneously. Getting that window wrong breaks +single-writer exclusion — the one invariant the lease exists to provide and +the reason the epoch file must outlive its actor (see lease crate docs: +advisory-lock + persisted epoch CAS; the file is never unlinked, by design, +to avoid the unlink-inode race). + +~1 GiB/year of block-amplified small files is real but not worth risking a +fencing invariant on this fleet's timeline. The measurement is done and +recorded so the future decision starts from evidence. + +## Re-open triggers (any one) + +- Lease directory physical size exceeds 1 GiB on any deployment +- A consumer appears with high-frequency ephemeral identities (orders of + magnitude above ~750 sessions/day) +- The lease crate takes a breaking rev for an unrelated reason (piggyback the + layout migration on an already-paid cross-repo window) + +## Migration sketch for whoever picks this up + +Dual-write epoch to old file + packed table during the window; readers prefer +the table and fall back to the file; cut reads over only after every consumer +repo is on the dual-write rev; retire files lazily. The dangerous step is any +reader that can see the table EMPTY while the file holds a newer epoch — +i.e. the fallback order must be newest-wins across both stores, never +table-wins. From d2208eda95d845193d84199dbfd0781a1f479291 Mon Sep 17 00:00:00 2001 From: ualtinok Date: Fri, 14 Aug 2026 10:21:22 +0200 Subject: [PATCH 30/37] docs: bind each lease-density re-open trigger to the seat that can observe it --- docs/lease-store-density.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/lease-store-density.md b/docs/lease-store-density.md index b09165e..698d67c 100644 --- a/docs/lease-store-density.md +++ b/docs/lease-store-density.md @@ -27,13 +27,19 @@ to avoid the unlink-inode race). fencing invariant on this fleet's timeline. The measurement is done and recorded so the future decision starts from evidence. -## Re-open triggers (any one) +## Re-open triggers (any one), each watched by the seat that can see it -- Lease directory physical size exceeds 1 GiB on any deployment +- Lease directory physical size exceeds 1 GiB — WATCHED BY BROCA (wake armed + 2026-08-14 on st_blocks*512 crossing 1 GiB; ~320 days at measured growth). + Broca holds the deployment; the crate cannot see its consumers' disks, so + this condition written only in this document would be one nobody is + positioned to check. - A consumer appears with high-frequency ephemeral identities (orders of - magnitude above ~750 sessions/day) + magnitude above ~750 sessions/day) — watched by SUBC (visible from commons + consumer reviews, invisible from broca's seat) - The lease crate takes a breaking rev for an unrelated reason (piggyback the - layout migration on an already-paid cross-repo window) + layout migration on an already-paid cross-repo window) — watched by SUBC + (crate owner) ## Migration sketch for whoever picks this up From 426d45308ca10271f1bdf5e5bdc0c0ab18ae9218 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Sun, 16 Aug 2026 11:25:55 +0200 Subject: [PATCH 31/37] feat(model-catalog): PAYG remap format, parser, and conformance vectors models.dev publishes cost:{input:0,output:0} for plan-billed lanes - 486 models across 60 providers in the 2026-08-13 snapshot. Those zeros are correct as marginal cost and useless for routing: a spend report that prices plan usage at $0 cannot answer what a call would have cost on that platform without the plan. This adds the document format and parser that overlays the catalog with sourced rates for those ids, plus the conformance vectors that define what a correct overlay does. What is here: - PaygRemapDoc and the entry kinds, with an exact provider-qualified key newtype that never falls back to a bare model name - that fallback silently compares a reseller id against the origin provider's price - a fallible parser with 12 error variants, all reachable and tested - is_all_zero, the normative ALL-ZERO predicate, exported so consumers do not each reimplement it - a conformance runner generic over the join, with zero implementations of that join in this crate - two vector corpora under tests/golden/, following the pattern in cortexkit-store-types and cortexkit-cache-core What is deliberately absent: the classifier, and the canonical data document. The failure taxonomy is still moving - it grew a third mode after one review round, four matrix cells after another, and had its priced column restructured after a third - so pinning it to this crate's semver surface is premature. A cfg(test) reference implementation would be worse: as the only executable join in the tree it becomes the de facto normative one. The crate header says types and parsing only, no bundled data, so payg-remap.json is not here either; both placement questions belong to the maintainer. Two gates, and only one runs here. The parse gate is executed and proven: all 14 guards were mutation-tested in two classes - deleted, and narrowed to check less - and each reddens a named vector. The classification suite is complete and cell-referenced but does not execute here, because there is nothing to execute it against; 17 of 31 mutation rows are shipped and unrun until a classifier exists. The narrowing class is why that distinction matters. A removal-only sweep reported 14/14 green while five guards survived narrowing, every one correct, load-bearing, and untested - including a provenance filter that had never executed at all, because every vector omitted the field and the lookup short-circuited before reaching it. Each classification vector carries a cell reference naming the matrix cell it derives from, and a constant CELL_CONTRACT table asserts every vector's outcome against the matrix. A vector that contradicts its cited cell is then catchable by reading rather than by execution. Additive: no existing type, function, or test changes. The only deletion is the version line, 0.2.0 to 0.3.0. Refs cortexkit/astrocyte#3 --- crates/cortexkit-model-catalog/Cargo.toml | 2 +- crates/cortexkit-model-catalog/src/lib.rs | 9 + .../src/payg_conformance.rs | 150 +++ .../cortexkit-model-catalog/src/payg_remap.rs | 555 +++++++++++ .../tests/golden/payg-class-vectors.json | 882 ++++++++++++++++++ .../tests/golden/payg-parse-vectors.json | 147 +++ .../tests/payg_class_vectors.rs | 296 ++++++ .../tests/payg_parse_vectors.rs | 224 +++++ .../tests/payg_remap_parse.rs | 264 ++++++ 9 files changed, 2528 insertions(+), 1 deletion(-) create mode 100644 crates/cortexkit-model-catalog/src/payg_conformance.rs create mode 100644 crates/cortexkit-model-catalog/src/payg_remap.rs create mode 100644 crates/cortexkit-model-catalog/tests/golden/payg-class-vectors.json create mode 100644 crates/cortexkit-model-catalog/tests/golden/payg-parse-vectors.json create mode 100644 crates/cortexkit-model-catalog/tests/payg_class_vectors.rs create mode 100644 crates/cortexkit-model-catalog/tests/payg_parse_vectors.rs create mode 100644 crates/cortexkit-model-catalog/tests/payg_remap_parse.rs diff --git a/crates/cortexkit-model-catalog/Cargo.toml b/crates/cortexkit-model-catalog/Cargo.toml index 942fe2b..8f160dd 100644 --- a/crates/cortexkit-model-catalog/Cargo.toml +++ b/crates/cortexkit-model-catalog/Cargo.toml @@ -4,7 +4,7 @@ # each brings its own snapshot and owns its own derived stores. [package] name = "cortexkit-model-catalog" -version = "0.2.0" +version = "0.3.0" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/crates/cortexkit-model-catalog/src/lib.rs b/crates/cortexkit-model-catalog/src/lib.rs index 5f6fd17..ea1d16e 100644 --- a/crates/cortexkit-model-catalog/src/lib.rs +++ b/crates/cortexkit-model-catalog/src/lib.rs @@ -21,6 +21,15 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; use serde_json::Value; +mod payg_conformance; +mod payg_remap; + +pub use payg_conformance::{run_vectors, PaygOutcome, PaygVector, PaygVectorSuite, VectorFailure}; +pub use payg_remap::{ + is_all_zero, NotSoldPerTokenEntry, OverridesUnpricedEntry, PaygModelId, PaygProviderRule, + PaygProviderRuleKind, PaygRemapDoc, PaygRemapEntry, PaygRemapParseError, ResolvesToEntry, +}; + /// Integer nanodollars per million tokens. $3/M tokens = 3_000_000_000. pub type RateNanosPerMtok = i64; diff --git a/crates/cortexkit-model-catalog/src/payg_conformance.rs b/crates/cortexkit-model-catalog/src/payg_conformance.rs new file mode 100644 index 0000000..fc9b378 --- /dev/null +++ b/crates/cortexkit-model-catalog/src/payg_conformance.rs @@ -0,0 +1,150 @@ +use serde::Deserialize; +use serde_json::Value; + +use crate::{CatalogDoc, PaygModelId, PaygRemapDoc}; + +/// One expected classification outcome from the PAYG conformance matrix. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PaygOutcome { + Priced, + NotSoldPerToken, + TargetNotInCatalog, + TargetNotPriceable, + DeclarationSuperseded, + NoEntry, +} + +/// One classification vector supplied by a conformance corpus. +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct PaygVector { + pub name: String, + pub cell: String, + #[serde(deserialize_with = "deserialize_remap_doc")] + pub remap: PaygRemapDoc, + #[serde(deserialize_with = "deserialize_catalog_doc")] + pub catalog: CatalogDoc, + #[serde(deserialize_with = "deserialize_model_id")] + pub model: PaygModelId, + pub expected: PaygOutcome, +} + +/// A complete, ordered classification-vector corpus. +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct PaygVectorSuite { + pub vectors: Vec, +} + +/// One vector whose classifier result differed from the declared expectation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VectorFailure { + pub vector: String, + pub expected: PaygOutcome, + pub actual: PaygOutcome, +} + +/// Execute every vector against the caller's classification implementation. +pub fn run_vectors(vectors: &PaygVectorSuite, classify: F) -> Vec +where + F: Fn(&PaygRemapDoc, &CatalogDoc, &PaygModelId) -> PaygOutcome, +{ + vectors + .vectors + .iter() + .filter_map(|vector| { + let actual = classify(&vector.remap, &vector.catalog, &vector.model); + (actual != vector.expected).then(|| VectorFailure { + vector: vector.name.clone(), + expected: vector.expected, + actual, + }) + }) + .collect() +} + +fn deserialize_remap_doc<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let value = Value::deserialize(deserializer)?; + PaygRemapDoc::parse(&value.to_string()).map_err(serde::de::Error::custom) +} + +fn deserialize_catalog_doc<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let value = Value::deserialize(deserializer)?; + CatalogDoc::parse(&value.to_string()).map_err(serde::de::Error::custom) +} + +fn deserialize_model_id<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let id = String::deserialize(deserializer)?; + PaygModelId::parse(&id).map_err(serde::de::Error::custom) +} + +#[cfg(test)] +mod tests { + use crate::{CatalogDoc, PaygModelId, PaygRemapDoc}; + + use super::{run_vectors, PaygOutcome, PaygVector, PaygVectorSuite}; + + #[test] + fn reports_a_mismatch_from_the_caller_supplied_classifier() { + let vectors = PaygVectorSuite { + vectors: vec![PaygVector { + name: "priced-vector".into(), + cell: "overrides_unpriced/absent".into(), + remap: PaygRemapDoc::parse( + r#"{ + "schema": 1, + "counterfactual": "same_platform_list", + "providers": {}, + "entries": {} + }"#, + ) + .unwrap(), + catalog: CatalogDoc::parse("{}").unwrap(), + model: PaygModelId::parse("provider/model").unwrap(), + expected: PaygOutcome::Priced, + }], + }; + + assert_eq!( + run_vectors(&vectors, |_, _, _| PaygOutcome::NoEntry), + vec![super::VectorFailure { + vector: "priced-vector".into(), + expected: PaygOutcome::Priced, + actual: PaygOutcome::NoEntry, + }] + ); + } + + #[test] + fn parses_vectors_with_their_catalog_and_remap_documents() { + let suite: PaygVectorSuite = serde_json::from_str( + r#"{ + "vectors": [{ + "name": "priced-vector", + "cell": "overrides_unpriced/absent", + "remap": { + "schema": 1, + "counterfactual": "same_platform_list", + "providers": {}, + "entries": {} + }, + "catalog": {}, + "model": "provider/model", + "expected": "priced" + }] + }"#, + ) + .unwrap(); + + assert_eq!(suite.vectors[0].model.as_str(), "provider/model"); + assert_eq!(suite.vectors[0].expected, PaygOutcome::Priced); + } +} diff --git a/crates/cortexkit-model-catalog/src/payg_remap.rs b/crates/cortexkit-model-catalog/src/payg_remap.rs new file mode 100644 index 0000000..1687cef --- /dev/null +++ b/crates/cortexkit-model-catalog/src/payg_remap.rs @@ -0,0 +1,555 @@ +use std::collections::BTreeMap; + +use serde_json::Value; + +use crate::{dollars_to_nanos, CostSchedule, CostTier, RateNanosPerMtok}; + +const COUNTERFACTUAL: &str = "same_platform_list"; + +/// An exact provider-qualified model identifier used by PAYG remap documents. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct PaygModelId(String); + +impl PaygModelId { + pub fn parse(id: &str) -> Result { + let Some((provider, model)) = id.split_once('/') else { + return Err(PaygRemapParseError::MalformedId { id: id.into() }); + }; + if provider.is_empty() || model.is_empty() { + return Err(PaygRemapParseError::MalformedId { id: id.into() }); + } + Ok(Self(id.into())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn provider(&self) -> &str { + self.0 + .split_once('/') + .map(|(provider, _)| provider) + .expect("PaygModelId is validated by parse") + } + + pub fn model(&self) -> &str { + self.0 + .split_once('/') + .map(|(_, model)| model) + .expect("PaygModelId is validated by parse") + } +} + +/// One complete, parsed PAYG remap document. +/// +/// ```rust,compile_fail +/// use cortexkit_model_catalog::PaygRemapDoc; +/// +/// let parsed = PaygRemapDoc::parse("{}"); +/// let _ = parsed.unwrap_or_default(); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PaygRemapDoc { + pub schema: u64, + pub providers: BTreeMap, + pub entries: BTreeMap, +} + +impl PaygRemapDoc { + pub fn parse(json: &str) -> Result { + let root: Value = serde_json::from_str(json) + .map_err(|error| PaygRemapParseError::Json(error.to_string()))?; + let root = root + .as_object() + .ok_or_else(|| PaygRemapParseError::Json("top level is not an object".into()))?; + + let schema = root + .get("schema") + .and_then(Value::as_u64) + .ok_or_else(|| PaygRemapParseError::Json("schema is not an unsigned integer".into()))?; + if schema != 1 { + return Err(PaygRemapParseError::UnknownSchema { schema }); + } + + let found = root + .get("counterfactual") + .and_then(Value::as_str) + .map_or_else(|| "".into(), Into::into); + if found != COUNTERFACTUAL { + return Err(PaygRemapParseError::CounterfactualMismatch { + expected: COUNTERFACTUAL, + found, + }); + } + + let providers = parse_provider_rules( + root.get("providers") + .ok_or_else(|| PaygRemapParseError::Json("providers is missing".into()))?, + )?; + let entries = parse_entries( + root.get("entries") + .ok_or_else(|| PaygRemapParseError::Json("entries is missing".into()))?, + )?; + + for (id, entry) in &entries { + if let PaygRemapEntry::ResolvesTo(resolve) = entry { + if resolve.target == *id { + return Err(PaygRemapParseError::SelfReferentialTarget { + id: id.as_str().into(), + }); + } + if entries.contains_key(&resolve.target) { + return Err(PaygRemapParseError::ChainedTarget { + id: id.as_str().into(), + target: resolve.target.as_str().into(), + }); + } + } + } + + Ok(Self { + schema, + providers, + entries, + }) + } +} + +/// The only provider-wide PAYG refusal rule. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PaygProviderRuleKind { + ZerosAreNotPrices, +} + +/// A provider-scoped PAYG refusal rule. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PaygProviderRule { + pub kind: PaygProviderRuleKind, + pub id_prefix: Option, + pub source: String, + pub observed: String, +} + +/// A specific PAYG remap declaration. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PaygRemapEntry { + ResolvesTo(ResolvesToEntry), + OverridesUnpriced(OverridesUnpricedEntry), + NotSoldPerToken(NotSoldPerTokenEntry), +} + +/// A declaration that points at one terminal catalog schedule. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvesToEntry { + pub target: PaygModelId, + pub because: String, + pub source: String, + pub observed: String, +} + +/// A declaration that supplies a sourced schedule absent from the catalog. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OverridesUnpricedEntry { + pub cost: CostSchedule, + pub source: String, + pub observed: String, +} + +/// A declaration that the platform has no per-token rate for this identifier. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NotSoldPerTokenEntry { + pub reason: String, + pub source: String, + pub observed: String, +} + +/// A PAYG remap-document parse failure. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PaygRemapParseError { + Json(String), + UnknownSchema { + schema: u64, + }, + CounterfactualMismatch { + expected: &'static str, + found: String, + }, + UnknownKind { + id: String, + kind: String, + }, + MalformedId { + id: String, + }, + MissingProvenance { + id: String, + field: &'static str, + }, + SelfReferentialTarget { + id: String, + }, + ChainedTarget { + id: String, + target: String, + }, + ZeroOverride { + id: String, + }, + InexactRate { + id: String, + field: &'static str, + value: String, + }, + NegativeRate { + id: String, + field: &'static str, + value: String, + }, + ContextBandNotRepresentable { + id: String, + }, + InvalidIdPrefix { + id: String, + }, +} + +impl std::fmt::Display for PaygRemapParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Json(error) => write!(f, "PAYG remap json: {error}"), + Self::UnknownSchema { schema } => write!(f, "unknown PAYG remap schema {schema}"), + Self::CounterfactualMismatch { expected, found } => { + write!( + f, + "PAYG remap counterfactual is {found:?}, expected {expected:?}" + ) + } + Self::UnknownKind { id, kind } => { + write!(f, "unknown PAYG remap kind {kind:?} for {id}") + } + Self::MalformedId { id } => write!(f, "malformed PAYG remap id {id:?}"), + Self::MissingProvenance { id, field } => { + write!(f, "PAYG remap declaration {id} is missing {field}") + } + Self::SelfReferentialTarget { id } => { + write!(f, "PAYG remap declaration {id} resolves to itself") + } + Self::ChainedTarget { id, target } => { + write!( + f, + "PAYG remap declaration {id} resolves through entry {target}" + ) + } + Self::ZeroOverride { id } => { + write!(f, "PAYG remap override {id} does not supply a positive rate") + } + Self::InexactRate { id, field, value } => write!( + f, + "PAYG remap rate {id}.{field} = {value} cannot scale exactly to nanodollars" + ), + Self::NegativeRate { id, field, value } => { + write!(f, "PAYG remap rate {id}.{field} = {value} is negative") + } + Self::ContextBandNotRepresentable { id } => write!( + f, + "PAYG remap override {id} uses context_over_200k; express that band through a tiers entry" + ), + Self::InvalidIdPrefix { id } => { + write!(f, "PAYG provider rule {id} has a non-string id_prefix") + } + } + } +} + +impl std::error::Error for PaygRemapParseError {} + +fn parse_provider_rules( + value: &Value, +) -> Result, PaygRemapParseError> { + let rules = value + .as_object() + .ok_or_else(|| PaygRemapParseError::Json("providers is not an object".into()))?; + let mut parsed = BTreeMap::new(); + for (id, value) in rules { + let rule = value.as_object().ok_or_else(|| { + PaygRemapParseError::Json(format!("provider rule {id} is not an object")) + })?; + let kind = required_string(rule, id, "kind")?; + let kind = match kind.as_str() { + "zeros_are_not_prices" => PaygProviderRuleKind::ZerosAreNotPrices, + _ => { + return Err(PaygRemapParseError::UnknownKind { + id: id.clone(), + kind, + }); + } + }; + let id_prefix = match rule.get("id_prefix") { + None | Some(Value::Null) => None, + Some(Value::String(prefix)) => Some(prefix.clone()), + Some(_) => return Err(PaygRemapParseError::InvalidIdPrefix { id: id.clone() }), + }; + parsed.insert( + id.clone(), + PaygProviderRule { + kind, + id_prefix, + source: required_provenance(rule, id, "source")?, + observed: required_provenance(rule, id, "observed")?, + }, + ); + } + Ok(parsed) +} + +fn parse_entries( + value: &Value, +) -> Result, PaygRemapParseError> { + let entries = value + .as_object() + .ok_or_else(|| PaygRemapParseError::Json("entries is not an object".into()))?; + let mut parsed = BTreeMap::new(); + for (raw_id, value) in entries { + let id = PaygModelId::parse(raw_id)?; + let entry = value.as_object().ok_or_else(|| { + PaygRemapParseError::Json(format!("PAYG remap entry {raw_id} is not an object")) + })?; + let source = required_provenance(entry, raw_id, "source")?; + let observed = required_provenance(entry, raw_id, "observed")?; + let kind = required_string(entry, raw_id, "kind")?; + let entry = match kind.as_str() { + "resolves_to" => PaygRemapEntry::ResolvesTo(ResolvesToEntry { + target: PaygModelId::parse(&required_string(entry, raw_id, "target")?)?, + because: required_string(entry, raw_id, "because")?, + source, + observed, + }), + "overrides_unpriced" => PaygRemapEntry::OverridesUnpriced(OverridesUnpricedEntry { + cost: parse_override_cost( + raw_id, + entry + .get("cost") + .ok_or_else(|| missing_required_field(raw_id, "cost"))?, + )?, + source, + observed, + }), + "not_sold_per_token" => PaygRemapEntry::NotSoldPerToken(NotSoldPerTokenEntry { + reason: required_string(entry, raw_id, "reason")?, + source, + observed, + }), + _ => { + return Err(PaygRemapParseError::UnknownKind { + id: raw_id.clone(), + kind, + }); + } + }; + parsed.insert(id, entry); + } + Ok(parsed) +} + +fn required_provenance( + entry: &serde_json::Map, + id: &str, + field: &'static str, +) -> Result { + entry + .get(field) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(Into::into) + .ok_or_else(|| PaygRemapParseError::MissingProvenance { + id: id.into(), + field, + }) +} + +fn required_string( + entry: &serde_json::Map, + id: &str, + field: &str, +) -> Result { + entry + .get(field) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(Into::into) + .ok_or_else(|| missing_required_field(id, field)) +} + +fn missing_required_field(id: &str, field: &str) -> PaygRemapParseError { + PaygRemapParseError::Json(format!("PAYG remap declaration {id} is missing {field}")) +} + +fn parse_override_cost(id: &str, value: &Value) -> Result { + let cost = value.as_object().ok_or_else(|| { + PaygRemapParseError::Json(format!( + "PAYG remap override cost for {id} is not an object" + )) + })?; + for field in cost.keys() { + if field == "context_over_200k" { + return Err(PaygRemapParseError::ContextBandNotRepresentable { id: id.into() }); + } + if !matches!( + field.as_str(), + "input" + | "output" + | "cache_read" + | "cache_write" + | "reasoning" + | "input_audio" + | "output_audio" + | "tiers" + ) { + return Err(PaygRemapParseError::Json(format!( + "PAYG remap override cost for {id} has unknown field {field}" + ))); + } + } + + let schedule = CostSchedule { + input: parse_rate(cost, id, "input")?, + output: parse_rate(cost, id, "output")?, + cache_read: parse_rate(cost, id, "cache_read")?, + cache_write: parse_rate(cost, id, "cache_write")?, + reasoning: parse_rate(cost, id, "reasoning")?, + input_audio: parse_rate(cost, id, "input_audio")?, + output_audio: parse_rate(cost, id, "output_audio")?, + tiers: parse_tiers(cost, id)?, + }; + if !has_positive_rate(&schedule) { + return Err(PaygRemapParseError::ZeroOverride { id: id.into() }); + } + Ok(schedule) +} + +fn parse_tiers( + cost: &serde_json::Map, + id: &str, +) -> Result, PaygRemapParseError> { + let Some(tiers) = cost.get("tiers") else { + return Ok(Vec::new()); + }; + let tiers = tiers.as_array().ok_or_else(|| { + PaygRemapParseError::Json(format!( + "PAYG remap override tiers for {id} is not an array" + )) + })?; + let mut parsed = Vec::with_capacity(tiers.len()); + for tier in tiers { + let tier = tier.as_object().ok_or_else(|| { + PaygRemapParseError::Json(format!( + "PAYG remap override tier for {id} is not an object" + )) + })?; + for field in tier.keys() { + if !matches!( + field.as_str(), + "input" | "output" | "cache_read" | "cache_write" | "tier" + ) { + return Err(PaygRemapParseError::Json(format!( + "PAYG remap override tier for {id} has unknown field {field}" + ))); + } + } + let dimension = tier.get("tier").and_then(Value::as_object).ok_or_else(|| { + PaygRemapParseError::Json(format!("PAYG remap override tier for {id} lacks tier")) + })?; + if dimension.get("type").and_then(Value::as_str) != Some("context") { + return Err(PaygRemapParseError::Json(format!( + "PAYG remap override tier for {id} is not a context tier" + ))); + } + let min_context = dimension + .get("size") + .and_then(Value::as_u64) + .ok_or_else(|| { + PaygRemapParseError::Json(format!( + "PAYG remap override tier for {id} lacks tier.size" + )) + })?; + parsed.push(CostTier { + min_context, + input: parse_rate(tier, id, "input")?, + output: parse_rate(tier, id, "output")?, + cache_read: parse_rate(tier, id, "cache_read")?, + cache_write: parse_rate(tier, id, "cache_write")?, + }); + } + parsed.sort_by_key(|tier| tier.min_context); + Ok(parsed) +} + +fn parse_rate( + fields: &serde_json::Map, + id: &str, + field: &'static str, +) -> Result, PaygRemapParseError> { + let Some(value) = fields.get(field) else { + return Ok(None); + }; + if value.is_null() { + return Ok(None); + } + let rate = dollars_to_nanos(value).map_err(|value| PaygRemapParseError::InexactRate { + id: id.into(), + field, + value, + })?; + if rate < 0 { + return Err(PaygRemapParseError::NegativeRate { + id: id.into(), + field, + value: value.to_string(), + }); + } + Ok(Some(rate)) +} + +/// §5.3's ALL-ZERO predicate for one parsed cost schedule. +/// +/// At least one of `input` or `output` must be `Some(0)`, every present rate must be +/// `Some(0)`, and every tier rate must be zero. An all-`None` schedule is unpriced, not +/// zero; the leading `input`/`output` condition preserves that distinction. +pub fn is_all_zero(cost: &CostSchedule) -> bool { + let direct = [ + cost.input, + cost.output, + cost.cache_read, + cost.cache_write, + cost.reasoning, + cost.input_audio, + cost.output_audio, + ]; + (cost.input == Some(0) || cost.output == Some(0)) + && direct.into_iter().flatten().all(|rate| rate == 0) + && cost.tiers.iter().all(|tier| { + [tier.input, tier.output, tier.cache_read, tier.cache_write] + .into_iter() + .flatten() + .all(|rate| rate == 0) + }) +} + +fn has_positive_rate(cost: &CostSchedule) -> bool { + let direct = [ + cost.input, + cost.output, + cost.cache_read, + cost.cache_write, + cost.reasoning, + cost.input_audio, + cost.output_audio, + ]; + direct.into_iter().flatten().any(|rate| rate > 0) + || cost.tiers.iter().any(|tier| { + [tier.input, tier.output, tier.cache_read, tier.cache_write] + .into_iter() + .flatten() + .any(|rate| rate > 0) + }) +} diff --git a/crates/cortexkit-model-catalog/tests/golden/payg-class-vectors.json b/crates/cortexkit-model-catalog/tests/golden/payg-class-vectors.json new file mode 100644 index 0000000..2a64321 --- /dev/null +++ b/crates/cortexkit-model-catalog/tests/golden/payg-class-vectors.json @@ -0,0 +1,882 @@ +{ + "vectors": [ + { + "name": "resolves-to-priced-source", + "cell": "resolves_to/priced", + "remap": { + "schema": 1, + "counterfactual": "same_platform_list", + "providers": {}, + "entries": { + "source/model": { + "kind": "resolves_to", + "target": "target/model", + "because": "same-platform schedule", + "source": "https://example.test/source", + "observed": "2026-08-15" + } + } + }, + "catalog": { + "source": { + "models": { + "model": { + "cost": { + "input": 1 + } + } + } + }, + "target": { + "models": { + "model": { + "cost": { + "input": 1 + } + } + } + } + }, + "model": "source/model", + "expected": "declaration_superseded" + }, + { + "name": "resolves-to-zero-source-priced-target", + "cell": "resolves_to/all-zero/target-priced", + "remap": { + "schema": 1, + "counterfactual": "same_platform_list", + "providers": {}, + "entries": { + "source/model": { + "kind": "resolves_to", + "target": "target/model", + "because": "same-platform schedule", + "source": "https://example.test/source", + "observed": "2026-08-15" + } + } + }, + "catalog": { + "source": { + "models": { + "model": { + "cost": { + "input": 0 + } + } + } + }, + "target": { + "models": { + "model": { + "cost": { + "input": 1 + } + } + } + } + }, + "model": "source/model", + "expected": "priced" + }, + { + "name": "resolves-to-zero-source-none-target", + "cell": "resolves_to/all-zero/target-all-none", + "remap": { + "schema": 1, + "counterfactual": "same_platform_list", + "providers": {}, + "entries": { + "source/model": { + "kind": "resolves_to", + "target": "target/model", + "because": "same-platform schedule", + "source": "https://example.test/source", + "observed": "2026-08-15" + } + } + }, + "catalog": { + "source": { + "models": { + "model": { + "cost": { + "input": 0 + } + } + } + }, + "target": { + "models": { + "model": {} + } + } + }, + "model": "source/model", + "expected": "target_not_priceable" + }, + { + "name": "resolves-to-zero-source-zero-target", + "cell": "resolves_to/all-zero/target-all-zero", + "remap": { + "schema": 1, + "counterfactual": "same_platform_list", + "providers": {}, + "entries": { + "source/model": { + "kind": "resolves_to", + "target": "target/model", + "because": "same-platform schedule", + "source": "https://example.test/source", + "observed": "2026-08-15" + } + } + }, + "catalog": { + "source": { + "models": { + "model": { + "cost": { + "input": 0 + } + } + } + }, + "target": { + "models": { + "model": { + "cost": { + "input": 0 + } + } + } + } + }, + "model": "source/model", + "expected": "target_not_priceable" + }, + { + "name": "resolves-to-zero-source-absent-target", + "cell": "resolves_to/all-zero/target-absent", + "remap": { + "schema": 1, + "counterfactual": "same_platform_list", + "providers": {}, + "entries": { + "source/model": { + "kind": "resolves_to", + "target": "target/model", + "because": "same-platform schedule", + "source": "https://example.test/source", + "observed": "2026-08-15" + } + } + }, + "catalog": { + "source": { + "models": { + "model": { + "cost": { + "input": 0 + } + } + } + } + }, + "model": "source/model", + "expected": "target_not_in_catalog" + }, + { + "name": "resolves-to-none-source-priced-target", + "cell": "resolves_to/all-none/target-priced", + "remap": { + "schema": 1, + "counterfactual": "same_platform_list", + "providers": {}, + "entries": { + "source/model": { + "kind": "resolves_to", + "target": "target/model", + "because": "same-platform schedule", + "source": "https://example.test/source", + "observed": "2026-08-15" + } + } + }, + "catalog": { + "source": { + "models": { + "model": {} + } + }, + "target": { + "models": { + "model": { + "cost": { + "input": 1 + } + } + } + } + }, + "model": "source/model", + "expected": "priced" + }, + { + "name": "resolves-to-none-source-none-target", + "cell": "resolves_to/all-none/target-all-none", + "remap": { + "schema": 1, + "counterfactual": "same_platform_list", + "providers": {}, + "entries": { + "source/model": { + "kind": "resolves_to", + "target": "target/model", + "because": "same-platform schedule", + "source": "https://example.test/source", + "observed": "2026-08-15" + } + } + }, + "catalog": { + "source": { + "models": { + "model": {} + } + }, + "target": { + "models": { + "model": {} + } + } + }, + "model": "source/model", + "expected": "target_not_priceable" + }, + { + "name": "resolves-to-none-source-zero-target", + "cell": "resolves_to/all-none/target-all-zero", + "remap": { + "schema": 1, + "counterfactual": "same_platform_list", + "providers": {}, + "entries": { + "source/model": { + "kind": "resolves_to", + "target": "target/model", + "because": "same-platform schedule", + "source": "https://example.test/source", + "observed": "2026-08-15" + } + } + }, + "catalog": { + "source": { + "models": { + "model": {} + } + }, + "target": { + "models": { + "model": { + "cost": { + "input": 0 + } + } + } + } + }, + "model": "source/model", + "expected": "target_not_priceable" + }, + { + "name": "resolves-to-none-source-absent-target", + "cell": "resolves_to/all-none/target-absent", + "remap": { + "schema": 1, + "counterfactual": "same_platform_list", + "providers": {}, + "entries": { + "source/model": { + "kind": "resolves_to", + "target": "target/model", + "because": "same-platform schedule", + "source": "https://example.test/source", + "observed": "2026-08-15" + } + } + }, + "catalog": { + "source": { + "models": { + "model": {} + } + } + }, + "model": "source/model", + "expected": "target_not_in_catalog" + }, + { + "name": "resolves-to-absent-source-priced-target", + "cell": "resolves_to/absent/target-priced", + "remap": { + "schema": 1, + "counterfactual": "same_platform_list", + "providers": {}, + "entries": { + "source/model": { + "kind": "resolves_to", + "target": "target/model", + "because": "same-platform schedule", + "source": "https://example.test/source", + "observed": "2026-08-15" + } + } + }, + "catalog": { + "target": { + "models": { + "model": { + "cost": { + "input": 1 + } + } + } + } + }, + "model": "source/model", + "expected": "priced" + }, + { + "name": "resolves-to-absent-source-none-target", + "cell": "resolves_to/absent/target-all-none", + "remap": { + "schema": 1, + "counterfactual": "same_platform_list", + "providers": {}, + "entries": { + "source/model": { + "kind": "resolves_to", + "target": "target/model", + "because": "same-platform schedule", + "source": "https://example.test/source", + "observed": "2026-08-15" + } + } + }, + "catalog": { + "target": { + "models": { + "model": {} + } + } + }, + "model": "source/model", + "expected": "target_not_priceable" + }, + { + "name": "resolves-to-absent-source-zero-target", + "cell": "resolves_to/absent/target-all-zero", + "remap": { + "schema": 1, + "counterfactual": "same_platform_list", + "providers": {}, + "entries": { + "source/model": { + "kind": "resolves_to", + "target": "target/model", + "because": "same-platform schedule", + "source": "https://example.test/source", + "observed": "2026-08-15" + } + } + }, + "catalog": { + "target": { + "models": { + "model": { + "cost": { + "input": 0 + } + } + } + } + }, + "model": "source/model", + "expected": "target_not_priceable" + }, + { + "name": "resolves-to-absent-source-absent-target", + "cell": "resolves_to/absent/target-absent", + "remap": { + "schema": 1, + "counterfactual": "same_platform_list", + "providers": {}, + "entries": { + "source/model": { + "kind": "resolves_to", + "target": "target/model", + "because": "same-platform schedule", + "source": "https://example.test/source", + "observed": "2026-08-15" + } + } + }, + "catalog": {}, + "model": "source/model", + "expected": "target_not_in_catalog" + }, + { + "name": "override-reasoning-priced-source", + "cell": "overrides_unpriced/priced", + "remap": { + "schema": 1, + "counterfactual": "same_platform_list", + "providers": {}, + "entries": { + "source/model": { + "kind": "overrides_unpriced", + "cost": { + "input": 0.5, + "output": 3 + }, + "source": "https://example.test/source", + "observed": "2026-08-15" + } + } + }, + "catalog": { + "source": { + "models": { + "model": { + "cost": { + "input": 0, + "output": 0, + "reasoning": 5 + } + } + } + } + }, + "model": "source/model", + "expected": "declaration_superseded" + }, + { + "name": "override-qwen-reseller-zero", + "cell": "overrides_unpriced/all-zero", + "remap": { + "schema": 1, + "counterfactual": "same_platform_list", + "providers": {}, + "entries": { + "alibaba-token-plan/qwen3.7-plus": { + "kind": "overrides_unpriced", + "cost": { + "input": 0.5, + "output": 3, + "cache_read": 0.05, + "cache_write": 0.625, + "tiers": [ + { + "input": 2, + "output": 6, + "cache_read": 0.2, + "cache_write": 2.5, + "tier": { + "type": "context", + "size": 256000 + } + } + ] + }, + "source": "https://www.alibabacloud.com/help/en/model-studio/models", + "observed": "2026-08-15" + } + } + }, + "catalog": { + "alibaba-token-plan": { + "models": { + "qwen3.7-plus": { + "cost": { + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 + } + } + } + }, + "opencode-go": { + "models": { + "qwen3.7-plus": { + "cost": { + "input": 0.4, + "output": 1.6, + "cache_read": 0.04, + "cache_write": 0.5 + } + } + } + }, + "alibaba": { + "models": { + "qwen3.7-plus": { + "cost": { + "input": 0.5, + "output": 3, + "cache_read": 0.05, + "cache_write": 0.625 + } + } + } + } + }, + "model": "alibaba-token-plan/qwen3.7-plus", + "expected": "priced" + }, + { + "name": "override-none-source", + "cell": "overrides_unpriced/all-none", + "remap": { + "schema": 1, + "counterfactual": "same_platform_list", + "providers": {}, + "entries": { + "source/model": { + "kind": "overrides_unpriced", + "cost": { + "input": 0.5, + "output": 3 + }, + "source": "https://example.test/source", + "observed": "2026-08-15" + } + } + }, + "catalog": { + "source": { + "models": { + "model": {} + } + } + }, + "model": "source/model", + "expected": "priced" + }, + { + "name": "override-absent-source", + "cell": "overrides_unpriced/absent", + "remap": { + "schema": 1, + "counterfactual": "same_platform_list", + "providers": {}, + "entries": { + "source/model": { + "kind": "overrides_unpriced", + "cost": { + "input": 0.5, + "output": 3 + }, + "source": "https://example.test/source", + "observed": "2026-08-15" + } + } + }, + "catalog": {}, + "model": "source/model", + "expected": "priced" + }, + { + "name": "not-sold-priced-source", + "cell": "not_sold_per_token/priced", + "remap": { + "schema": 1, + "counterfactual": "same_platform_list", + "providers": {}, + "entries": { + "source/model": { + "kind": "not_sold_per_token", + "reason": "plan-only", + "source": "https://example.test/source", + "observed": "2026-08-15" + } + } + }, + "catalog": { + "source": { + "models": { + "model": { + "cost": { + "input": 1 + } + } + } + } + }, + "model": "source/model", + "expected": "declaration_superseded" + }, + { + "name": "not-sold-zero-source", + "cell": "not_sold_per_token/all-zero", + "remap": { + "schema": 1, + "counterfactual": "same_platform_list", + "providers": {}, + "entries": { + "source/model": { + "kind": "not_sold_per_token", + "reason": "plan-only", + "source": "https://example.test/source", + "observed": "2026-08-15" + } + } + }, + "catalog": { + "source": { + "models": { + "model": { + "cost": { + "input": 0 + } + } + } + } + }, + "model": "source/model", + "expected": "not_sold_per_token" + }, + { + "name": "not-sold-none-source", + "cell": "not_sold_per_token/all-none", + "remap": { + "schema": 1, + "counterfactual": "same_platform_list", + "providers": {}, + "entries": { + "source/model": { + "kind": "not_sold_per_token", + "reason": "plan-only", + "source": "https://example.test/source", + "observed": "2026-08-15" + } + } + }, + "catalog": { + "source": { + "models": { + "model": {} + } + } + }, + "model": "source/model", + "expected": "not_sold_per_token" + }, + { + "name": "not-sold-absent-source", + "cell": "not_sold_per_token/absent", + "remap": { + "schema": 1, + "counterfactual": "same_platform_list", + "providers": {}, + "entries": { + "source/model": { + "kind": "not_sold_per_token", + "reason": "plan-only", + "source": "https://example.test/source", + "observed": "2026-08-15" + } + } + }, + "catalog": {}, + "model": "source/model", + "expected": "not_sold_per_token" + }, + { + "name": "google-priced-provider-rule", + "cell": "zeros_are_not_prices/priced", + "remap": { + "schema": 1, + "counterfactual": "same_platform_list", + "providers": { + "google": { + "kind": "zeros_are_not_prices", + "source": "https://example.test/google", + "observed": "2026-08-15" + } + }, + "entries": {} + }, + "catalog": { + "google": { + "models": { + "gemini-3.5-flash": { + "cost": { + "input": 1.5, + "output": 9, + "cache_read": 0.15, + "input_audio": 1.5 + } + } + } + } + }, + "model": "google/gemini-3.5-flash", + "expected": "no_entry" + }, + { + "name": "provider-rule-zero-source", + "cell": "zeros_are_not_prices/all-zero", + "remap": { + "schema": 1, + "counterfactual": "same_platform_list", + "providers": { + "source": { + "kind": "zeros_are_not_prices", + "source": "https://example.test/source", + "observed": "2026-08-15" + } + }, + "entries": {} + }, + "catalog": { + "source": { + "models": { + "model": { + "cost": { + "input": 0 + } + } + } + } + }, + "model": "source/model", + "expected": "not_sold_per_token" + }, + { + "name": "provider-rule-none-source", + "cell": "zeros_are_not_prices/all-none", + "remap": { + "schema": 1, + "counterfactual": "same_platform_list", + "providers": { + "source": { + "kind": "zeros_are_not_prices", + "source": "https://example.test/source", + "observed": "2026-08-15" + } + }, + "entries": {} + }, + "catalog": { + "source": { + "models": { + "model": {} + } + } + }, + "model": "source/model", + "expected": "not_sold_per_token" + }, + { + "name": "provider-rule-absent-source", + "cell": "zeros_are_not_prices/absent", + "remap": { + "schema": 1, + "counterfactual": "same_platform_list", + "providers": { + "source": { + "kind": "zeros_are_not_prices", + "source": "https://example.test/source", + "observed": "2026-08-15" + } + }, + "entries": {} + }, + "catalog": {}, + "model": "source/model", + "expected": "not_sold_per_token" + }, + { + "name": "no-declaration-priced-source", + "cell": "no_declaration/priced", + "remap": { + "schema": 1, + "counterfactual": "same_platform_list", + "providers": {}, + "entries": {} + }, + "catalog": { + "source": { + "models": { + "model": { + "cost": { + "input": 1 + } + } + } + } + }, + "model": "source/model", + "expected": "no_entry" + }, + { + "name": "no-declaration-zero-source", + "cell": "no_declaration/all-zero", + "remap": { + "schema": 1, + "counterfactual": "same_platform_list", + "providers": {}, + "entries": {} + }, + "catalog": { + "source": { + "models": { + "model": { + "cost": { + "input": 0 + } + } + } + } + }, + "model": "source/model", + "expected": "no_entry" + }, + { + "name": "no-declaration-none-source", + "cell": "no_declaration/all-none", + "remap": { + "schema": 1, + "counterfactual": "same_platform_list", + "providers": {}, + "entries": {} + }, + "catalog": { + "source": { + "models": { + "model": {} + } + } + }, + "model": "source/model", + "expected": "no_entry" + }, + { + "name": "no-declaration-absent-source", + "cell": "no_declaration/absent", + "remap": { + "schema": 1, + "counterfactual": "same_platform_list", + "providers": {}, + "entries": {} + }, + "catalog": {}, + "model": "source/model", + "expected": "no_entry" + } + ] +} diff --git a/crates/cortexkit-model-catalog/tests/golden/payg-parse-vectors.json b/crates/cortexkit-model-catalog/tests/golden/payg-parse-vectors.json new file mode 100644 index 0000000..fd3f4cc --- /dev/null +++ b/crates/cortexkit-model-catalog/tests/golden/payg-parse-vectors.json @@ -0,0 +1,147 @@ +{ + "vectors": [ + { + "name": "version-skew", + "input_json": "{\"schema\":2,\"counterfactual\":\"same_platform_list\",\"providers\":{},\"entries\":{}}", + "expect_error": { "variant": "UnknownSchema", "schema": 2 } + }, + { + "name": "schema-zero", + "input_json": "{\"schema\":0,\"counterfactual\":\"same_platform_list\",\"providers\":{},\"entries\":{}}", + "expect_error": { "variant": "UnknownSchema", "schema": 0 } + }, + { + "name": "counterfactual-mismatch", + "input_json": "{\"schema\":1,\"counterfactual\":\"different\",\"providers\":{},\"entries\":{}}", + "expect_error": { "variant": "CounterfactualMismatch", "expected": "same_platform_list", "found": "different" } + }, + { + "name": "unknown-kind-refuses-whole-document", + "input_json": "{\"schema\":1,\"counterfactual\":\"same_platform_list\",\"providers\":{},\"entries\":{\"p/m\":{\"kind\":\"unknown\",\"source\":\"https://vendor.example/pricing\",\"observed\":\"2026-08-13\"}}}", + "expect_error": { "variant": "UnknownKind", "id": "p/m", "kind": "unknown" } + }, + { + "name": "id-without-provider", + "input_json": "{\"schema\":1,\"counterfactual\":\"same_platform_list\",\"providers\":{},\"entries\":{\"/model\":{\"kind\":\"not_sold_per_token\",\"reason\":\"plan_only\",\"source\":\"https://vendor.example/pricing\",\"observed\":\"2026-08-13\"}}}", + "expect_error": { "variant": "MalformedId", "id": "/model" } + }, + { + "name": "id-without-model", + "input_json": "{\"schema\":1,\"counterfactual\":\"same_platform_list\",\"providers\":{},\"entries\":{\"provider/\":{\"kind\":\"not_sold_per_token\",\"reason\":\"plan_only\",\"source\":\"https://vendor.example/pricing\",\"observed\":\"2026-08-13\"}}}", + "expect_error": { "variant": "MalformedId", "id": "provider/" } + }, + { + "name": "entry-without-source", + "input_json": "{\"schema\":1,\"counterfactual\":\"same_platform_list\",\"providers\":{},\"entries\":{\"p/m\":{\"kind\":\"not_sold_per_token\",\"reason\":\"plan_only\",\"observed\":\"2026-08-13\"}}}", + "expect_error": { "variant": "MissingProvenance", "id": "p/m", "field": "source" } + }, + { + "name": "provider-rule-without-source", + "input_json": "{\"schema\":1,\"counterfactual\":\"same_platform_list\",\"providers\":{\"p\":{\"kind\":\"zeros_are_not_prices\",\"observed\":\"2026-08-13\"}},\"entries\":{}}", + "expect_error": { "variant": "MissingProvenance", "id": "p", "field": "source" } + }, + { + "name": "entry-without-observed", + "input_json": "{\"schema\":1,\"counterfactual\":\"same_platform_list\",\"providers\":{},\"entries\":{\"p/m\":{\"kind\":\"not_sold_per_token\",\"reason\":\"plan_only\",\"source\":\"https://vendor.example/pricing\"}}}", + "expect_error": { "variant": "MissingProvenance", "id": "p/m", "field": "observed" } + }, + { + "name": "provider-rule-without-observed", + "input_json": "{\"schema\":1,\"counterfactual\":\"same_platform_list\",\"providers\":{\"p\":{\"kind\":\"zeros_are_not_prices\",\"source\":\"https://vendor.example/pricing\"}},\"entries\":{}}", + "expect_error": { "variant": "MissingProvenance", "id": "p", "field": "observed" } + }, + { + "name": "entry-with-empty-source", + "input_json": "{\"schema\":1,\"counterfactual\":\"same_platform_list\",\"providers\":{},\"entries\":{\"p/m\":{\"kind\":\"not_sold_per_token\",\"reason\":\"plan_only\",\"source\":\"\",\"observed\":\"2026-08-13\"}}}", + "expect_error": { "variant": "MissingProvenance", "id": "p/m", "field": "source" } + }, + { + "name": "provider-rule-with-empty-source", + "input_json": "{\"schema\":1,\"counterfactual\":\"same_platform_list\",\"providers\":{\"p\":{\"kind\":\"zeros_are_not_prices\",\"source\":\"\",\"observed\":\"2026-08-13\"}},\"entries\":{}}", + "expect_error": { "variant": "MissingProvenance", "id": "p", "field": "source" } + }, + { + "name": "entry-with-empty-observed", + "input_json": "{\"schema\":1,\"counterfactual\":\"same_platform_list\",\"providers\":{},\"entries\":{\"p/m\":{\"kind\":\"not_sold_per_token\",\"reason\":\"plan_only\",\"source\":\"https://vendor.example/pricing\",\"observed\":\"\"}}}", + "expect_error": { "variant": "MissingProvenance", "id": "p/m", "field": "observed" } + }, + { + "name": "provider-rule-with-empty-observed", + "input_json": "{\"schema\":1,\"counterfactual\":\"same_platform_list\",\"providers\":{\"p\":{\"kind\":\"zeros_are_not_prices\",\"source\":\"https://vendor.example/pricing\",\"observed\":\"\"}},\"entries\":{}}", + "expect_error": { "variant": "MissingProvenance", "id": "p", "field": "observed" } + }, + { + "name": "self-target", + "input_json": "{\"schema\":1,\"counterfactual\":\"same_platform_list\",\"providers\":{},\"entries\":{\"p/m\":{\"kind\":\"resolves_to\",\"target\":\"p/m\",\"because\":\"same schedule\",\"source\":\"https://vendor.example/pricing\",\"observed\":\"2026-08-13\"}}}", + "expect_error": { "variant": "SelfReferentialTarget", "id": "p/m" } + }, + { + "name": "chained-target", + "input_json": "{\"schema\":1,\"counterfactual\":\"same_platform_list\",\"providers\":{},\"entries\":{\"p/a\":{\"kind\":\"resolves_to\",\"target\":\"p/b\",\"because\":\"origin schedule\",\"source\":\"https://vendor.example/pricing\",\"observed\":\"2026-08-13\"},\"p/b\":{\"kind\":\"not_sold_per_token\",\"reason\":\"plan_only\",\"source\":\"https://vendor.example/pricing\",\"observed\":\"2026-08-13\"}}}", + "expect_error": { "variant": "ChainedTarget", "id": "p/a", "target": "p/b" } + }, + { + "name": "zero-valued-overrides-unpriced", + "input_json": "{\"schema\":1,\"counterfactual\":\"same_platform_list\",\"providers\":{},\"entries\":{\"p/m\":{\"kind\":\"overrides_unpriced\",\"cost\":{\"input\":0,\"output\":0,\"cache_read\":0,\"cache_write\":0,\"reasoning\":0,\"input_audio\":0,\"output_audio\":0,\"tiers\":[{\"tier\":{\"type\":\"context\",\"size\":128000},\"input\":0,\"output\":0,\"cache_read\":0,\"cache_write\":0}]},\"source\":\"https://vendor.example/pricing\",\"observed\":\"2026-08-13\"}}}", + "expect_error": { "variant": "ZeroOverride", "id": "p/m" } + }, + { + "name": "rate-that-rounds-to-zero", + "input_json": "{\"schema\":1,\"counterfactual\":\"same_platform_list\",\"providers\":{},\"entries\":{\"p/m\":{\"kind\":\"overrides_unpriced\",\"cost\":{\"input\":1e-10},\"source\":\"https://vendor.example/pricing\",\"observed\":\"2026-08-13\"}}}", + "expect_error": { "variant": "InexactRate", "id": "p/m", "field": "input", "value": "1e-10" } + }, + { + "name": "negative-rate", + "input_json": "{\"schema\":1,\"counterfactual\":\"same_platform_list\",\"providers\":{},\"entries\":{\"p/m\":{\"kind\":\"overrides_unpriced\",\"cost\":{\"output\":-1},\"source\":\"https://vendor.example/pricing\",\"observed\":\"2026-08-13\"}}}", + "expect_error": { + "variant": "NegativeRate", + "id": "p/m", + "field": "output", + "value": "-1" + } + }, + { + "name": "context-band-requires-tier", + "input_json": "{\"schema\":1,\"counterfactual\":\"same_platform_list\",\"providers\":{},\"entries\":{\"p/m\":{\"kind\":\"overrides_unpriced\",\"cost\":{\"context_over_200k\":{\"input\":2,\"output\":6}},\"source\":\"https://vendor.example/pricing\",\"observed\":\"2026-08-13\"}}}", + "expect_error": { + "variant": "ContextBandNotRepresentable", + "id": "p/m", + "message": "PAYG remap override p/m uses context_over_200k; express that band through a tiers entry" + } + } + , + { + "name": "zero-only-reasoning-override", + "input_json": "{\"schema\":1,\"counterfactual\":\"same_platform_list\",\"providers\":{},\"entries\":{\"p/m\":{\"kind\":\"overrides_unpriced\",\"cost\":{\"reasoning\":0},\"source\":\"https://vendor.example/pricing\",\"observed\":\"2026-08-13\"}}}", + "expect_error": { + "variant": "ZeroOverride", + "id": "p/m" + } + }, + { + "name": "all-none-override", + "input_json": "{\"schema\":1,\"counterfactual\":\"same_platform_list\",\"providers\":{},\"entries\":{\"p/m\":{\"kind\":\"overrides_unpriced\",\"cost\":{},\"source\":\"https://vendor.example/pricing\",\"observed\":\"2026-08-13\"}}}", + "expect_error": { + "variant": "ZeroOverride", + "id": "p/m" + } + }, + { + "name": "all-zero-tier-only-override", + "input_json": "{\"schema\":1,\"counterfactual\":\"same_platform_list\",\"providers\":{},\"entries\":{\"p/m\":{\"kind\":\"overrides_unpriced\",\"cost\":{\"tiers\":[{\"tier\":{\"type\":\"context\",\"size\":128000},\"input\":0,\"output\":0}]},\"source\":\"https://vendor.example/pricing\",\"observed\":\"2026-08-13\"}}}", + "expect_error": { + "variant": "ZeroOverride", + "id": "p/m" + } + } + , + { + "name": "non-string-provider-id-prefix", + "input_json": "{\"schema\":1,\"counterfactual\":\"same_platform_list\",\"providers\":{\"p\":{\"kind\":\"zeros_are_not_prices\",\"id_prefix\":123,\"source\":\"https://vendor.example/rules\",\"observed\":\"2026-08-13\"}},\"entries\":{}}", + "expect_error": { + "variant": "InvalidIdPrefix", + "id": "p" + } + } + ] +} diff --git a/crates/cortexkit-model-catalog/tests/payg_class_vectors.rs b/crates/cortexkit-model-catalog/tests/payg_class_vectors.rs new file mode 100644 index 0000000..43c3d07 --- /dev/null +++ b/crates/cortexkit-model-catalog/tests/payg_class_vectors.rs @@ -0,0 +1,296 @@ +//! Validate the frozen PAYG classification-vector corpus without classifying it. +//! +//! Any classifier implementation MUST execute this suite through `run_vectors`; a +//! classifier that does not is nonconforming. This crate owns the corpus shape, while +//! the classifier's home owns execution of the matrix outcomes. +//! The qwen schedules retain only the live rates needed by their cells, not a complete +//! models.dev record; context bands are represented by `tiers` in this crate. + +use std::collections::BTreeSet; + +use cortexkit_model_catalog::PaygVectorSuite; +use serde::Deserialize; + +const VECTORS: &str = include_str!("golden/payg-class-vectors.json"); + +const MATRIX_CELLS: &[&str] = &[ + "resolves_to/priced", + "resolves_to/all-zero/target-priced", + "resolves_to/all-zero/target-all-none", + "resolves_to/all-zero/target-all-zero", + "resolves_to/all-zero/target-absent", + "resolves_to/all-none/target-priced", + "resolves_to/all-none/target-all-none", + "resolves_to/all-none/target-all-zero", + "resolves_to/all-none/target-absent", + "resolves_to/absent/target-priced", + "resolves_to/absent/target-all-none", + "resolves_to/absent/target-all-zero", + "resolves_to/absent/target-absent", + "overrides_unpriced/priced", + "overrides_unpriced/all-zero", + "overrides_unpriced/all-none", + "overrides_unpriced/absent", + "not_sold_per_token/priced", + "not_sold_per_token/all-zero", + "not_sold_per_token/all-none", + "not_sold_per_token/absent", + "zeros_are_not_prices/priced", + "zeros_are_not_prices/all-zero", + "zeros_are_not_prices/all-none", + "zeros_are_not_prices/absent", + "no_declaration/priced", + "no_declaration/all-zero", + "no_declaration/all-none", + "no_declaration/absent", +]; + +const LEGAL_OUTCOMES: &[&str] = &[ + "priced", + "not_sold_per_token", + "target_not_in_catalog", + "target_not_priceable", + "declaration_superseded", + "no_entry", +]; + +const CELL_CONTRACT: &[(&str, &str)] = &[ + ("resolves_to/priced", "declaration_superseded"), + ("resolves_to/all-zero/target-priced", "priced"), + ( + "resolves_to/all-zero/target-all-none", + "target_not_priceable", + ), + ( + "resolves_to/all-zero/target-all-zero", + "target_not_priceable", + ), + ( + "resolves_to/all-zero/target-absent", + "target_not_in_catalog", + ), + ("resolves_to/all-none/target-priced", "priced"), + ( + "resolves_to/all-none/target-all-none", + "target_not_priceable", + ), + ( + "resolves_to/all-none/target-all-zero", + "target_not_priceable", + ), + ( + "resolves_to/all-none/target-absent", + "target_not_in_catalog", + ), + ("resolves_to/absent/target-priced", "priced"), + ("resolves_to/absent/target-all-none", "target_not_priceable"), + ("resolves_to/absent/target-all-zero", "target_not_priceable"), + ("resolves_to/absent/target-absent", "target_not_in_catalog"), + ("overrides_unpriced/priced", "declaration_superseded"), + ("overrides_unpriced/all-zero", "priced"), + ("overrides_unpriced/all-none", "priced"), + ("overrides_unpriced/absent", "priced"), + ("not_sold_per_token/priced", "declaration_superseded"), + ("not_sold_per_token/all-zero", "not_sold_per_token"), + ("not_sold_per_token/all-none", "not_sold_per_token"), + ("not_sold_per_token/absent", "not_sold_per_token"), + ("zeros_are_not_prices/priced", "no_entry"), + ("zeros_are_not_prices/all-zero", "not_sold_per_token"), + ("zeros_are_not_prices/all-none", "not_sold_per_token"), + ("zeros_are_not_prices/absent", "not_sold_per_token"), + ("no_declaration/priced", "no_entry"), + ("no_declaration/all-zero", "no_entry"), + ("no_declaration/all-none", "no_entry"), + ("no_declaration/absent", "no_entry"), +]; + +#[derive(Debug, Deserialize)] +struct RawVectorSuite { + vectors: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawVector { + cell: String, + expected: String, +} + +#[test] +fn classification_vectors_are_a_complete_well_formed_matrix_corpus() { + let vectors: PaygVectorSuite = serde_json::from_str(VECTORS) + .expect("every classification vector parses through the public conformance type"); + let raw: RawVectorSuite = serde_json::from_str(VECTORS) + .expect("read classification vector cell references and outcome names"); + + let failures = validation_failures(&raw.vectors, vectors.vectors.len()); + assert!( + failures.is_empty(), + "PAYG classification vector corpus is malformed:\n{}", + failures.join("\n") + ); +} + +#[test] +fn cell_reference_guard_rejects_an_unknown_target_state() { + let vectors = vec![raw_vector( + "overrides_unpriced/all-zero/target-priced", + "priced", + )]; + + assert!(validate_cell_references(&vectors).is_err()); +} + +#[test] +fn legal_outcome_guard_rejects_a_prefix_of_a_real_outcome() { + let vectors = vec![raw_vector("no_declaration/absent", "priced-but-not-legal")]; + + assert!(validate_expected_outcomes(&vectors).is_err()); +} + +#[test] +fn cell_contract_guard_rejects_a_legal_but_wrong_target_state_outcome() { + let vectors = vec![raw_vector( + "resolves_to/all-zero/target-all-zero", + "declaration_superseded", + )]; + + assert!(validate_cell_contract(&vectors).is_err()); +} + +#[test] +fn coverage_guard_rejects_a_duplicate_cell() { + let mut vectors = complete_matrix_vectors(); + vectors.push(raw_vector("no_declaration/absent", "no_entry")); + + assert!(validate_exact_once_coverage(&vectors).is_err()); +} + +#[test] +fn coverage_guard_rejects_a_missing_cell() { + let mut vectors = complete_matrix_vectors(); + vectors.pop(); + + assert!(validate_exact_once_coverage(&vectors).is_err()); +} + +#[test] +fn coverage_guard_rejects_an_extra_cell() { + let mut vectors = complete_matrix_vectors(); + vectors.push(raw_vector("outside-the-matrix", "no_entry")); + + assert!(validate_exact_once_coverage(&vectors).is_err()); +} + +#[test] +fn validation_diagnostics_collect_independent_failures() { + let vectors = vec![raw_vector("outside-the-matrix", "not-an-outcome")]; + + let failures = validation_failures(&vectors, 0); + + assert_eq!(failures.len(), 5, "{failures:#?}"); + assert!(failures + .iter() + .any(|failure| failure.contains("expected 29 vectors"))); + assert!(failures + .iter() + .any(|failure| failure.contains("unknown matrix cell"))); + assert!(failures + .iter() + .any(|failure| failure.contains("illegal PAYG outcome"))); +} + +fn validate_cell_references(vectors: &[RawVector]) -> Result<(), String> { + for vector in vectors { + if !MATRIX_CELLS.contains(&vector.cell.as_str()) { + return Err(format!("unknown matrix cell: {}", vector.cell)); + } + } + Ok(()) +} + +fn validation_failures(vectors: &[RawVector], parsed_vector_count: usize) -> Vec { + let mut failures = Vec::new(); + let matrix_count_matches = vectors.len() == MATRIX_CELLS.len(); + if !matrix_count_matches { + failures.push(format!( + "expected {} vectors, found {}", + MATRIX_CELLS.len(), + vectors.len() + )); + } + if vectors.len() != parsed_vector_count { + failures.push(format!( + "raw fixture has {} vectors but public parsing produced {parsed_vector_count}", + vectors.len() + )); + } + for validation in [ + validate_cell_references(vectors), + validate_expected_outcomes(vectors), + validate_cell_contract(vectors), + ] { + if let Err(error) = validation { + failures.push(error); + } + } + if matrix_count_matches { + if let Err(error) = validate_exact_once_coverage(vectors) { + failures.push(error); + } + } + failures +} + +fn validate_expected_outcomes(vectors: &[RawVector]) -> Result<(), String> { + for vector in vectors { + if !LEGAL_OUTCOMES.contains(&vector.expected.as_str()) { + return Err(format!("illegal PAYG outcome: {}", vector.expected)); + } + } + Ok(()) +} + +fn validate_cell_contract(vectors: &[RawVector]) -> Result<(), String> { + for vector in vectors { + let expected = CELL_CONTRACT + .iter() + .find_map(|(cell, expected)| (*cell == vector.cell).then_some(*expected)) + .ok_or_else(|| format!("matrix cell has no expected outcome: {}", vector.cell))?; + if vector.expected != expected { + return Err(format!( + "matrix cell {} requires {expected}, found {}", + vector.cell, vector.expected + )); + } + } + Ok(()) +} + +fn validate_exact_once_coverage(vectors: &[RawVector]) -> Result<(), String> { + let seen = vectors + .iter() + .map(|vector| vector.cell.as_str()) + .collect::>(); + + if vectors.len() != MATRIX_CELLS.len() || seen.len() != vectors.len() { + return Err("matrix cells are missing or duplicated".into()); + } + if MATRIX_CELLS.iter().any(|cell| !seen.contains(cell)) { + return Err("matrix cells are missing".into()); + } + Ok(()) +} + +fn complete_matrix_vectors() -> Vec { + MATRIX_CELLS + .iter() + .map(|cell| raw_vector(cell, "no_entry")) + .collect() +} + +fn raw_vector(cell: &str, expected: &str) -> RawVector { + RawVector { + cell: cell.into(), + expected: expected.into(), + } +} diff --git a/crates/cortexkit-model-catalog/tests/payg_parse_vectors.rs b/crates/cortexkit-model-catalog/tests/payg_parse_vectors.rs new file mode 100644 index 0000000..eadf765 --- /dev/null +++ b/crates/cortexkit-model-catalog/tests/payg_parse_vectors.rs @@ -0,0 +1,224 @@ +//! Execute the frozen PAYG remap parse-gate fixture against the public parser. +//! +//! `tests/golden/payg-parse-vectors.json` names the invalid documents each guard must +//! refuse. Change it only with a format-contract change and fresh mutation evidence: +//! a passing suite alone does not prove a parser guard remains load-bearing. + +use cortexkit_model_catalog::{PaygRemapDoc, PaygRemapParseError}; +use serde::Deserialize; + +const VECTORS: &str = include_str!("golden/payg-parse-vectors.json"); + +#[derive(Debug, Deserialize)] +struct VectorFile { + vectors: Vec, +} + +#[derive(Debug, Deserialize)] +struct Vector { + name: String, + input_json: String, + expect_error: ExpectedError, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "variant")] +enum ExpectedError { + UnknownSchema { + schema: u64, + }, + CounterfactualMismatch { + expected: String, + found: String, + }, + UnknownKind { + id: String, + kind: String, + }, + MalformedId { + id: String, + }, + MissingProvenance { + id: String, + field: String, + }, + SelfReferentialTarget { + id: String, + }, + ChainedTarget { + id: String, + target: String, + }, + ZeroOverride { + id: String, + }, + InexactRate { + id: String, + field: String, + value: String, + }, + NegativeRate { + id: String, + field: String, + value: String, + }, + ContextBandNotRepresentable { + id: String, + message: String, + }, + InvalidIdPrefix { + id: String, + }, +} + +#[test] +fn parse_gate_rejects_every_golden_vector_with_its_exact_error() { + let file: VectorFile = serde_json::from_str(VECTORS).expect("parse PAYG parse vectors"); + assert_eq!( + file.vectors.len(), + 24, + "one vector for each non-structural parse guard" + ); + + for vector in file.vectors { + let error = match PaygRemapDoc::parse(&vector.input_json) { + Ok(doc) => panic!("{} unexpectedly parsed: {doc:?}", vector.name), + Err(error) => error, + }; + assert_expected_error(&vector.name, error, vector.expect_error); + } +} + +#[test] +fn override_with_a_real_rate_beside_zero_is_not_all_zero() { + let doc = PaygRemapDoc::parse( + r#"{ + "schema": 1, + "counterfactual": "same_platform_list", + "providers": {}, + "entries": { + "p/m": { + "kind": "overrides_unpriced", + "cost": { "input": 0, "output": 0, "reasoning": 1 }, + "source": "https://vendor.example/pricing", + "observed": "2026-08-13" + } + } + }"#, + ) + .expect("a mixed override has a real rate and must parse"); + + assert_eq!(doc.entries.len(), 1); +} + +// `payg_remap_parse::parses_provider_rule_and_resolve_target_without_lookup_fallback` +// owns the target-operand mutation: its terminal target is absent from entries, so +// `entries.contains_key(id)` must not turn a valid declaration into ChainedTarget. + +fn assert_expected_error(name: &str, error: PaygRemapParseError, expected: ExpectedError) { + match (error, expected) { + ( + PaygRemapParseError::UnknownSchema { schema: actual }, + ExpectedError::UnknownSchema { schema: expected }, + ) => assert_eq!(actual, expected, "{name}: UnknownSchema.schema"), + ( + PaygRemapParseError::CounterfactualMismatch { + expected: actual_expected, + found: actual_found, + }, + ExpectedError::CounterfactualMismatch { expected, found }, + ) => { + assert_eq!( + actual_expected, expected, + "{name}: CounterfactualMismatch.expected" + ); + assert_eq!(actual_found, found, "{name}: CounterfactualMismatch.found"); + } + ( + PaygRemapParseError::UnknownKind { + id: actual_id, + kind: actual_kind, + }, + ExpectedError::UnknownKind { id, kind }, + ) => { + assert_eq!(actual_id, id, "{name}: UnknownKind.id"); + assert_eq!(actual_kind, kind, "{name}: UnknownKind.kind"); + } + ( + PaygRemapParseError::MalformedId { id: actual }, + ExpectedError::MalformedId { id: expected }, + ) => { + assert_eq!(actual, expected, "{name}: MalformedId.id"); + } + ( + PaygRemapParseError::MissingProvenance { + id: actual_id, + field: actual_field, + }, + ExpectedError::MissingProvenance { id, field }, + ) => { + assert_eq!(actual_id, id, "{name}: MissingProvenance.id"); + assert_eq!(actual_field, field, "{name}: MissingProvenance.field"); + } + ( + PaygRemapParseError::SelfReferentialTarget { id: actual }, + ExpectedError::SelfReferentialTarget { id: expected }, + ) => assert_eq!(actual, expected, "{name}: SelfReferentialTarget.id"), + ( + PaygRemapParseError::ChainedTarget { + id: actual_id, + target: actual_target, + }, + ExpectedError::ChainedTarget { id, target }, + ) => { + assert_eq!(actual_id, id, "{name}: ChainedTarget.id"); + assert_eq!(actual_target, target, "{name}: ChainedTarget.target"); + } + ( + PaygRemapParseError::ZeroOverride { id: actual }, + ExpectedError::ZeroOverride { id: expected }, + ) => { + assert_eq!(actual, expected, "{name}: ZeroOverride.id"); + } + ( + PaygRemapParseError::InexactRate { + id: actual_id, + field: actual_field, + value: actual_value, + }, + ExpectedError::InexactRate { id, field, value }, + ) => { + assert_eq!(actual_id, id, "{name}: InexactRate.id"); + assert_eq!(actual_field, field, "{name}: InexactRate.field"); + assert_eq!(actual_value, value, "{name}: InexactRate.value"); + } + ( + PaygRemapParseError::NegativeRate { + id: actual_id, + field: actual_field, + value: actual_value, + }, + ExpectedError::NegativeRate { id, field, value }, + ) => { + assert_eq!(actual_id, id, "{name}: NegativeRate.id"); + assert_eq!(actual_field, field, "{name}: NegativeRate.field"); + assert_eq!(actual_value, value, "{name}: NegativeRate.value"); + } + ( + ref actual @ PaygRemapParseError::ContextBandNotRepresentable { id: ref actual_id }, + ExpectedError::ContextBandNotRepresentable { id, message }, + ) => { + assert_eq!(actual_id, &id, "{name}: ContextBandNotRepresentable.id"); + assert_eq!( + actual.to_string(), + message, + "{name}: ContextBandNotRepresentable" + ); + } + ( + PaygRemapParseError::InvalidIdPrefix { id: actual }, + ExpectedError::InvalidIdPrefix { id: expected }, + ) => assert_eq!(actual, expected, "{name}: InvalidIdPrefix.id"), + (actual, expected) => panic!("{name}: expected {expected:?}, got {actual:?}"), + } +} diff --git a/crates/cortexkit-model-catalog/tests/payg_remap_parse.rs b/crates/cortexkit-model-catalog/tests/payg_remap_parse.rs new file mode 100644 index 0000000..cfd1b02 --- /dev/null +++ b/crates/cortexkit-model-catalog/tests/payg_remap_parse.rs @@ -0,0 +1,264 @@ +use cortexkit_model_catalog::{ + is_all_zero, CostSchedule, PaygModelId, PaygProviderRuleKind, PaygRemapDoc, PaygRemapEntry, + PaygRemapParseError, +}; + +#[test] +fn all_none_schedule_is_unpriced_not_zero() { + assert!(!is_all_zero(&CostSchedule::default())); +} + +#[test] +fn all_zero_schedule_is_zero() { + let schedule = CostSchedule { + input: Some(0), + output: Some(0), + ..CostSchedule::default() + }; + + assert!(is_all_zero(&schedule)); +} + +#[test] +fn parses_minimal_schema_one_document() { + let doc = PaygRemapDoc::parse( + r#"{ + "schema": 1, + "counterfactual": "same_platform_list", + "providers": {}, + "entries": { + "plan/model": { + "kind": "not_sold_per_token", + "reason": "plan_only_no_published_rate", + "source": "https://vendor.example/pricing", + "observed": "2026-08-13" + } + } + }"#, + ) + .unwrap(); + + let id = PaygModelId::parse("plan/model").unwrap(); + assert_eq!(id.provider(), "plan"); + assert_eq!(id.model(), "model"); + assert!(matches!( + doc.entries.get(&id), + Some(PaygRemapEntry::NotSoldPerToken(_)) + )); + assert!(doc.providers.is_empty()); +} + +#[test] +fn rejects_document_level_parse_guards() { + let cases = [ + ( + r#"{ "schema": 2, "counterfactual": "same_platform_list", "providers": {}, "entries": {} }"#, + "unknown schema", + ), + ( + r#"{ "schema": 1, "counterfactual": "different", "providers": {}, "entries": {} }"#, + "counterfactual mismatch", + ), + ( + r#"{ "schema": 1, "counterfactual": "same_platform_list", "providers": {}, "entries": { "model": { "kind": "not_sold_per_token", "reason": "plan", "source": "https://vendor.example", "observed": "2026-08-13" } } }"#, + "malformed id", + ), + ]; + + for (json, name) in cases { + let error = PaygRemapDoc::parse(json).unwrap_err(); + match name { + "unknown schema" => assert!(matches!( + error, + PaygRemapParseError::UnknownSchema { schema: 2 } + )), + "counterfactual mismatch" => assert!(matches!( + error, + PaygRemapParseError::CounterfactualMismatch { .. } + )), + "malformed id" => assert!(matches!(error, PaygRemapParseError::MalformedId { .. })), + _ => unreachable!(), + } + } +} + +#[test] +fn parses_provider_rule_and_resolve_target_without_lookup_fallback() { + let doc = PaygRemapDoc::parse( + r#"{ + "schema": 1, + "counterfactual": "same_platform_list", + "providers": { + "google": { + "kind": "zeros_are_not_prices", + "id_prefix": "antigravity-", + "source": "https://vendor.example/rules", + "observed": "2026-08-13" + } + }, + "entries": { + "reseller/model": { + "kind": "resolves_to", + "target": "origin/model", + "because": "origin api rate", + "source": "https://vendor.example/pricing", + "observed": "2026-08-13" + } + } + }"#, + ) + .unwrap(); + + assert!(matches!( + doc.providers.get("google").map(|rule| &rule.kind), + Some(PaygProviderRuleKind::ZerosAreNotPrices) + )); + let reseller = PaygModelId::parse("reseller/model").unwrap(); + let origin = PaygModelId::parse("origin/model").unwrap(); + assert_ne!(reseller, origin); + let nested = PaygModelId::parse("provider/path/with/slashes").unwrap(); + assert_eq!(nested.provider(), "provider"); + assert_eq!(nested.model(), "path/with/slashes"); + match doc.entries.get(&reseller).unwrap() { + PaygRemapEntry::ResolvesTo(entry) => assert_eq!(entry.target, origin), + other => panic!("expected resolves_to, got {other:?}"), + } +} + +#[test] +fn rejects_malformed_provider_qualified_ids() { + for id in ["model", "/model", "provider/"] { + assert!(matches!( + PaygModelId::parse(id), + Err(PaygRemapParseError::MalformedId { .. }) + )); + } +} + +#[test] +fn rejects_invalid_entries_and_zero_overrides() { + let cases = [ + ( + r#"{ "schema": 1, "counterfactual": "same_platform_list", "providers": {}, "entries": { "p/m": { "kind": "unknown", "source": "https://vendor.example", "observed": "2026-08-13" } } }"#, + "unknown kind", + ), + ( + r#"{ "schema": 1, "counterfactual": "same_platform_list", "providers": {}, "entries": { "p/m": { "kind": "not_sold_per_token", "reason": "plan", "observed": "2026-08-13" } } }"#, + "missing provenance", + ), + ( + r#"{ "schema": 1, "counterfactual": "same_platform_list", "providers": {}, "entries": { "p/m": { "kind": "resolves_to", "target": "p/m", "because": "self", "source": "https://vendor.example", "observed": "2026-08-13" } } }"#, + "self target", + ), + ( + r#"{ "schema": 1, "counterfactual": "same_platform_list", "providers": {}, "entries": { "p/m": { "kind": "overrides_unpriced", "cost": { "input": 0 }, "source": "https://vendor.example", "observed": "2026-08-13" } } }"#, + "zero override", + ), + ]; + + for (json, name) in cases { + let error = PaygRemapDoc::parse(json).unwrap_err(); + match name { + "unknown kind" => assert!(matches!(error, PaygRemapParseError::UnknownKind { .. })), + "missing provenance" => assert!(matches!( + error, + PaygRemapParseError::MissingProvenance { .. } + )), + "self target" => assert!(matches!( + error, + PaygRemapParseError::SelfReferentialTarget { .. } + )), + "zero override" => assert!(matches!(error, PaygRemapParseError::ZeroOverride { .. })), + _ => unreachable!(), + } + } +} + +#[test] +fn rejects_all_remaining_parse_guards() { + let cases = [ + ( + r#"{ "schema": 1, "counterfactual": "same_platform_list", "providers": { "p": { "kind": "zeros_are_not_prices", "observed": "2026-08-13" } }, "entries": {} }"#, + "provider provenance", + ), + ( + r#"{ "schema": 1, "counterfactual": "same_platform_list", "providers": { "p": { "kind": "unknown", "source": "https://vendor.example", "observed": "2026-08-13" } }, "entries": {} }"#, + "provider kind", + ), + ( + r#"{ "schema": 1, "counterfactual": "same_platform_list", "providers": {}, "entries": { "p/a": { "kind": "resolves_to", "target": "p/b", "because": "a", "source": "https://vendor.example", "observed": "2026-08-13" }, "p/b": { "kind": "not_sold_per_token", "reason": "plan", "source": "https://vendor.example", "observed": "2026-08-13" } } }"#, + "chained target", + ), + ( + r#"{ "schema": 1, "counterfactual": "same_platform_list", "providers": {}, "entries": { "p/m": { "kind": "overrides_unpriced", "cost": { "input": 1e-10 }, "source": "https://vendor.example", "observed": "2026-08-13" } } }"#, + "inexact rate", + ), + ( + r#"{ "schema": 1, "counterfactual": "same_platform_list", "providers": {}, "entries": { "p/m": { "kind": "overrides_unpriced", "cost": { "output": -1 }, "source": "https://vendor.example", "observed": "2026-08-13" } } }"#, + "negative rate", + ), + ]; + + for (json, name) in cases { + let error = PaygRemapDoc::parse(json).unwrap_err(); + match name { + "provider provenance" => assert!(matches!( + error, + PaygRemapParseError::MissingProvenance { ref id, field: "source" } if id == "p" + )), + "provider kind" => assert!(matches!(error, PaygRemapParseError::UnknownKind { .. })), + "chained target" => assert!(matches!(error, PaygRemapParseError::ChainedTarget { .. })), + "inexact rate" => assert!(matches!(error, PaygRemapParseError::InexactRate { .. })), + "negative rate" => assert!(matches!(error, PaygRemapParseError::NegativeRate { .. })), + _ => unreachable!(), + } + } +} + +#[test] +fn parses_nonzero_override_schedule_and_rejects_unrepresentable_rate_blocks() { + let doc = PaygRemapDoc::parse( + r#"{ + "schema": 1, + "counterfactual": "same_platform_list", + "providers": {}, + "entries": { + "plan/model": { + "kind": "overrides_unpriced", + "cost": { + "input": 0.5, + "output": 3, + "reasoning": 1, + "tiers": [ + { "tier": { "type": "context", "size": 256000 }, "input": 2, "output": 6 }, + { "tier": { "type": "context", "size": 128000 }, "input": 1, "output": 4 } + ] + }, + "source": "https://vendor.example/pricing", + "observed": "2026-08-13" + } + } + }"#, + ) + .unwrap(); + let id = PaygModelId::parse("plan/model").unwrap(); + match doc.entries.get(&id).unwrap() { + PaygRemapEntry::OverridesUnpriced(entry) => { + assert_eq!(entry.cost.input, Some(500_000_000)); + assert_eq!(entry.cost.output, Some(3_000_000_000)); + assert_eq!(entry.cost.reasoning, Some(1_000_000_000)); + assert_eq!(entry.cost.tiers[0].min_context, 128_000); + assert_eq!(entry.cost.tiers[1].min_context, 256_000); + } + other => panic!("expected overrides_unpriced, got {other:?}"), + } + + let error = PaygRemapDoc::parse( + r#"{ "schema": 1, "counterfactual": "same_platform_list", "providers": {}, "entries": { "p/m": { "kind": "overrides_unpriced", "cost": { "input": 1, "context_over_200k": { "input": 2 } }, "source": "https://vendor.example", "observed": "2026-08-13" } } }"#, + ) + .unwrap_err(); + assert!(matches!( + error, + PaygRemapParseError::ContextBandNotRepresentable { ref id } if id == "p/m" + )); +} From c82a72cc25a1d6bc658b3e4ee62081e0e072ed93 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:31:54 +0200 Subject: [PATCH 32/37] feat(model-catalog): optional effective_from, distinct from observed --- .../cortexkit-model-catalog/src/payg_remap.rs | 52 ++++++++ .../tests/golden/payg-parse-vectors.json | 9 ++ .../tests/payg_parse_vectors.rs | 112 +++++++++++++++++- 3 files changed, 171 insertions(+), 2 deletions(-) diff --git a/crates/cortexkit-model-catalog/src/payg_remap.rs b/crates/cortexkit-model-catalog/src/payg_remap.rs index 1687cef..4a8326b 100644 --- a/crates/cortexkit-model-catalog/src/payg_remap.rs +++ b/crates/cortexkit-model-catalog/src/payg_remap.rs @@ -128,6 +128,7 @@ pub struct PaygProviderRule { pub id_prefix: Option, pub source: String, pub observed: String, + pub effective_from: Option, } /// A specific PAYG remap declaration. @@ -145,6 +146,7 @@ pub struct ResolvesToEntry { pub because: String, pub source: String, pub observed: String, + pub effective_from: Option, } /// A declaration that supplies a sourced schedule absent from the catalog. @@ -153,6 +155,7 @@ pub struct OverridesUnpricedEntry { pub cost: CostSchedule, pub source: String, pub observed: String, + pub effective_from: Option, } /// A declaration that the platform has no per-token rate for this identifier. @@ -161,6 +164,7 @@ pub struct NotSoldPerTokenEntry { pub reason: String, pub source: String, pub observed: String, + pub effective_from: Option, } /// A PAYG remap-document parse failure. @@ -211,6 +215,10 @@ pub enum PaygRemapParseError { InvalidIdPrefix { id: String, }, + InvalidEffectiveFrom { + id: String, + value: String, + }, } impl std::fmt::Display for PaygRemapParseError { @@ -257,6 +265,9 @@ impl std::fmt::Display for PaygRemapParseError { Self::InvalidIdPrefix { id } => { write!(f, "PAYG provider rule {id} has a non-string id_prefix") } + Self::InvalidEffectiveFrom { id, value } => { + write!(f, "PAYG remap declaration {id} has invalid effective_from {value:?}") + } } } } @@ -296,6 +307,7 @@ fn parse_provider_rules( id_prefix, source: required_provenance(rule, id, "source")?, observed: required_provenance(rule, id, "observed")?, + effective_from: optional_effective_from(rule, id)?, }, ); } @@ -316,6 +328,7 @@ fn parse_entries( })?; let source = required_provenance(entry, raw_id, "source")?; let observed = required_provenance(entry, raw_id, "observed")?; + let effective_from = optional_effective_from(entry, raw_id)?; let kind = required_string(entry, raw_id, "kind")?; let entry = match kind.as_str() { "resolves_to" => PaygRemapEntry::ResolvesTo(ResolvesToEntry { @@ -323,6 +336,7 @@ fn parse_entries( because: required_string(entry, raw_id, "because")?, source, observed, + effective_from, }), "overrides_unpriced" => PaygRemapEntry::OverridesUnpriced(OverridesUnpricedEntry { cost: parse_override_cost( @@ -333,11 +347,13 @@ fn parse_entries( )?, source, observed, + effective_from, }), "not_sold_per_token" => PaygRemapEntry::NotSoldPerToken(NotSoldPerTokenEntry { reason: required_string(entry, raw_id, "reason")?, source, observed, + effective_from, }), _ => { return Err(PaygRemapParseError::UnknownKind { @@ -367,6 +383,42 @@ fn required_provenance( }) } +/// `observed` is audit provenance, while `effective_from` selects a pricing era for downstream +/// arithmetic. Keep this stricter check separate: malformed effective dates can choose a wrong +/// rate, whereas malformed observations merely confuse a human auditor. This validates only the +/// `YYYY-MM-DD` shape, not calendar validity. +fn optional_effective_from( + entry: &serde_json::Map, + id: &str, +) -> Result, PaygRemapParseError> { + let Some(value) = entry.get("effective_from") else { + return Ok(None); + }; + let Some(date) = value.as_str() else { + return Err(PaygRemapParseError::InvalidEffectiveFrom { + id: id.into(), + value: value.to_string(), + }); + }; + if !is_yyyy_mm_dd(date) { + return Err(PaygRemapParseError::InvalidEffectiveFrom { + id: id.into(), + value: date.into(), + }); + } + Ok(Some(date.into())) +} + +fn is_yyyy_mm_dd(value: &str) -> bool { + value.len() == 10 + && value.as_bytes()[4] == b'-' + && value.as_bytes()[7] == b'-' + && value + .bytes() + .enumerate() + .all(|(index, byte)| matches!(index, 4 | 7) || byte.is_ascii_digit()) +} + fn required_string( entry: &serde_json::Map, id: &str, diff --git a/crates/cortexkit-model-catalog/tests/golden/payg-parse-vectors.json b/crates/cortexkit-model-catalog/tests/golden/payg-parse-vectors.json index fd3f4cc..9d5cf8d 100644 --- a/crates/cortexkit-model-catalog/tests/golden/payg-parse-vectors.json +++ b/crates/cortexkit-model-catalog/tests/golden/payg-parse-vectors.json @@ -142,6 +142,15 @@ "variant": "InvalidIdPrefix", "id": "p" } + }, + { + "name": "malformed-entry-effective-from", + "input_json": "{\"schema\":1,\"counterfactual\":\"same_platform_list\",\"providers\":{},\"entries\":{\"p/m\":{\"kind\":\"not_sold_per_token\",\"reason\":\"plan_only\",\"source\":\"https://vendor.example/pricing\",\"observed\":\"2026-08-13\",\"effective_from\":\"2026-8-13\"}}}", + "expect_error": { + "variant": "InvalidEffectiveFrom", + "id": "p/m", + "value": "2026-8-13" + } } ] } diff --git a/crates/cortexkit-model-catalog/tests/payg_parse_vectors.rs b/crates/cortexkit-model-catalog/tests/payg_parse_vectors.rs index eadf765..266de09 100644 --- a/crates/cortexkit-model-catalog/tests/payg_parse_vectors.rs +++ b/crates/cortexkit-model-catalog/tests/payg_parse_vectors.rs @@ -4,7 +4,7 @@ //! refuse. Change it only with a format-contract change and fresh mutation evidence: //! a passing suite alone does not prove a parser guard remains load-bearing. -use cortexkit_model_catalog::{PaygRemapDoc, PaygRemapParseError}; +use cortexkit_model_catalog::{PaygModelId, PaygRemapDoc, PaygRemapEntry, PaygRemapParseError}; use serde::Deserialize; const VECTORS: &str = include_str!("golden/payg-parse-vectors.json"); @@ -69,6 +69,10 @@ enum ExpectedError { InvalidIdPrefix { id: String, }, + InvalidEffectiveFrom { + id: String, + value: String, + }, } #[test] @@ -76,7 +80,7 @@ fn parse_gate_rejects_every_golden_vector_with_its_exact_error() { let file: VectorFile = serde_json::from_str(VECTORS).expect("parse PAYG parse vectors"); assert_eq!( file.vectors.len(), - 24, + 25, "one vector for each non-structural parse guard" ); @@ -89,6 +93,100 @@ fn parse_gate_rejects_every_golden_vector_with_its_exact_error() { } } +#[test] +fn malformed_effective_from_is_refused() { + let error = PaygRemapDoc::parse( + r#"{ + "schema": 1, + "counterfactual": "same_platform_list", + "providers": {}, + "entries": { + "p/m": { + "kind": "not_sold_per_token", + "reason": "plan_only", + "source": "https://vendor.example/pricing", + "observed": "2026-08-13", + "effective_from": "2026-8-13" + } + } + }"#, + ) + .expect_err("an effective date outside YYYY-MM-DD must be refused"); + + assert_eq!( + error.to_string(), + "PAYG remap declaration p/m has invalid effective_from \"2026-8-13\"" + ); +} + +#[test] +fn entry_effective_from_round_trips() { + let doc = PaygRemapDoc::parse( + r#"{ + "schema": 1, + "counterfactual": "same_platform_list", + "providers": { + "p": { + "kind": "zeros_are_not_prices", + "source": "https://vendor.example/pricing", + "observed": "2026-08-13", + "effective_from": "2026-09-01" + } + }, + "entries": { + "p/with-effective-date": { + "kind": "not_sold_per_token", + "reason": "plan_only", + "source": "https://vendor.example/pricing", + "observed": "2026-08-13", + "effective_from": "2026-09-01" + } + } + }"#, + ) + .expect("a shaped effective date must parse"); + + assert_eq!( + doc.providers["p"].effective_from.as_deref(), + Some("2026-09-01") + ); + let id = PaygModelId::parse("p/with-effective-date").expect("valid test id"); + let PaygRemapEntry::NotSoldPerToken(entry) = &doc.entries[&id] else { + panic!("expected not_sold_per_token entry"); + }; + assert_eq!(entry.effective_from.as_deref(), Some("2026-09-01")); +} + +#[test] +fn entry_without_effective_from_stays_absent() { + let doc = PaygRemapDoc::parse( + r#"{ + "schema": 1, + "counterfactual": "same_platform_list", + "providers": {}, + "entries": { + "p/without-effective-date": { + "kind": "not_sold_per_token", + "reason": "plan_only", + "source": "https://vendor.example/pricing", + "observed": "2026-08-13" + } + } + }"#, + ) + .expect("an entry without effective_from must remain valid"); + + let id = PaygModelId::parse("p/without-effective-date").expect("valid test id"); + let PaygRemapEntry::NotSoldPerToken(entry) = &doc.entries[&id] else { + panic!("expected not_sold_per_token entry"); + }; + assert_eq!(entry.effective_from, None); + assert_ne!( + entry.effective_from.as_deref(), + Some(entry.observed.as_str()) + ); +} + #[test] fn override_with_a_real_rate_beside_zero_is_not_all_zero() { let doc = PaygRemapDoc::parse( @@ -219,6 +317,16 @@ fn assert_expected_error(name: &str, error: PaygRemapParseError, expected: Expec PaygRemapParseError::InvalidIdPrefix { id: actual }, ExpectedError::InvalidIdPrefix { id: expected }, ) => assert_eq!(actual, expected, "{name}: InvalidIdPrefix.id"), + ( + PaygRemapParseError::InvalidEffectiveFrom { + id: actual_id, + value: actual_value, + }, + ExpectedError::InvalidEffectiveFrom { id, value }, + ) => { + assert_eq!(actual_id, id, "{name}: InvalidEffectiveFrom.id"); + assert_eq!(actual_value, value, "{name}: InvalidEffectiveFrom.value"); + } (actual, expected) => panic!("{name}: expected {expected:?}, got {actual:?}"), } } From 8be18d61ea1b26d28b5ffc499ad9e857fabc7563 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:58:33 +0200 Subject: [PATCH 33/37] fix(model-catalog): accept null effective_from, replace a vacuous assertion --- .../cortexkit-model-catalog/src/payg_remap.rs | 5 ++- .../tests/golden/payg-parse-vectors.json | 17 ++++++++ .../tests/payg_parse_vectors.rs | 41 ++++++++++++++++--- 3 files changed, 56 insertions(+), 7 deletions(-) diff --git a/crates/cortexkit-model-catalog/src/payg_remap.rs b/crates/cortexkit-model-catalog/src/payg_remap.rs index 4a8326b..c5ae425 100644 --- a/crates/cortexkit-model-catalog/src/payg_remap.rs +++ b/crates/cortexkit-model-catalog/src/payg_remap.rs @@ -391,8 +391,9 @@ fn optional_effective_from( entry: &serde_json::Map, id: &str, ) -> Result, PaygRemapParseError> { - let Some(value) = entry.get("effective_from") else { - return Ok(None); + let value = match entry.get("effective_from") { + None | Some(Value::Null) => return Ok(None), + Some(value) => value, }; let Some(date) = value.as_str() else { return Err(PaygRemapParseError::InvalidEffectiveFrom { diff --git a/crates/cortexkit-model-catalog/tests/golden/payg-parse-vectors.json b/crates/cortexkit-model-catalog/tests/golden/payg-parse-vectors.json index 9d5cf8d..a0eaff7 100644 --- a/crates/cortexkit-model-catalog/tests/golden/payg-parse-vectors.json +++ b/crates/cortexkit-model-catalog/tests/golden/payg-parse-vectors.json @@ -151,6 +151,23 @@ "id": "p/m", "value": "2026-8-13" } + }, + { + "name": "non-string-entry-effective-from", + "input_json": "{\"schema\":1,\"counterfactual\":\"same_platform_list\",\"providers\":{},\"entries\":{\"p/m\":{\"kind\":\"not_sold_per_token\",\"reason\":\"plan_only\",\"source\":\"https://vendor.example/pricing\",\"observed\":\"2026-08-13\",\"effective_from\":123}}}", + "expect_error": { + "variant": "InvalidEffectiveFrom", + "id": "p/m", + "value": "123" + } + } + ], + "positive_vectors": [ + { + "name": "null-entry-effective-from-stays-absent", + "input_json": "{\"schema\":1,\"counterfactual\":\"same_platform_list\",\"providers\":{},\"entries\":{\"p/m\":{\"kind\":\"not_sold_per_token\",\"reason\":\"plan_only\",\"source\":\"https://vendor.example/pricing\",\"observed\":\"2026-08-13\",\"effective_from\":null}}}", + "id": "p/m", + "effective_from": null } ] } diff --git a/crates/cortexkit-model-catalog/tests/payg_parse_vectors.rs b/crates/cortexkit-model-catalog/tests/payg_parse_vectors.rs index 266de09..98c3d1f 100644 --- a/crates/cortexkit-model-catalog/tests/payg_parse_vectors.rs +++ b/crates/cortexkit-model-catalog/tests/payg_parse_vectors.rs @@ -12,6 +12,8 @@ const VECTORS: &str = include_str!("golden/payg-parse-vectors.json"); #[derive(Debug, Deserialize)] struct VectorFile { vectors: Vec, + #[serde(default)] + positive_vectors: Vec, } #[derive(Debug, Deserialize)] @@ -21,6 +23,14 @@ struct Vector { expect_error: ExpectedError, } +#[derive(Debug, Deserialize)] +struct PositiveVector { + name: String, + input_json: String, + id: String, + effective_from: Option, +} + #[derive(Debug, Deserialize)] #[serde(tag = "variant")] enum ExpectedError { @@ -80,7 +90,7 @@ fn parse_gate_rejects_every_golden_vector_with_its_exact_error() { let file: VectorFile = serde_json::from_str(VECTORS).expect("parse PAYG parse vectors"); assert_eq!( file.vectors.len(), - 25, + 26, "one vector for each non-structural parse guard" ); @@ -93,6 +103,30 @@ fn parse_gate_rejects_every_golden_vector_with_its_exact_error() { } } +#[test] +fn parse_gate_accepts_every_positive_golden_vector() { + let file: VectorFile = serde_json::from_str(VECTORS).expect("parse PAYG parse vectors"); + + assert_eq!( + file.positive_vectors.len(), + 1, + "one vector for an explicitly unset optional field" + ); + for vector in file.positive_vectors { + let doc = PaygRemapDoc::parse(&vector.input_json) + .unwrap_or_else(|error| panic!("{} unexpectedly refused: {error}", vector.name)); + let id = PaygModelId::parse(&vector.id).expect("golden vector must use a valid id"); + let PaygRemapEntry::NotSoldPerToken(entry) = &doc.entries[&id] else { + panic!("{} must parse a not_sold_per_token entry", vector.name); + }; + assert_eq!( + entry.effective_from, vector.effective_from, + "{}: effective_from", + vector.name + ); + } +} + #[test] fn malformed_effective_from_is_refused() { let error = PaygRemapDoc::parse( @@ -180,11 +214,8 @@ fn entry_without_effective_from_stays_absent() { let PaygRemapEntry::NotSoldPerToken(entry) = &doc.entries[&id] else { panic!("expected not_sold_per_token entry"); }; + assert_eq!(entry.observed, "2026-08-13"); assert_eq!(entry.effective_from, None); - assert_ne!( - entry.effective_from.as_deref(), - Some(entry.observed.as_str()) - ); } #[test] From 4c8287e99fca5cf43fbba458b200f04d25dc0d1e Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:00:56 +0200 Subject: [PATCH 34/37] feat(model-catalog): rate_time_banded, an explicit refusal for time-varying list rates --- crates/cortexkit-model-catalog/src/lib.rs | 3 +- .../src/payg_conformance.rs | 55 ++++++++++++ .../cortexkit-model-catalog/src/payg_remap.rs | 46 +++++++++- .../tests/golden/payg-class-vectors.json | 31 +++++++ .../tests/golden/payg-parse-vectors.json | 37 ++++++++ .../tests/payg_class_vectors.rs | 5 +- .../tests/payg_parse_vectors.rs | 88 ++++++++++++++++--- 7 files changed, 251 insertions(+), 14 deletions(-) diff --git a/crates/cortexkit-model-catalog/src/lib.rs b/crates/cortexkit-model-catalog/src/lib.rs index ea1d16e..b4eea17 100644 --- a/crates/cortexkit-model-catalog/src/lib.rs +++ b/crates/cortexkit-model-catalog/src/lib.rs @@ -27,7 +27,8 @@ mod payg_remap; pub use payg_conformance::{run_vectors, PaygOutcome, PaygVector, PaygVectorSuite, VectorFailure}; pub use payg_remap::{ is_all_zero, NotSoldPerTokenEntry, OverridesUnpricedEntry, PaygModelId, PaygProviderRule, - PaygProviderRuleKind, PaygRemapDoc, PaygRemapEntry, PaygRemapParseError, ResolvesToEntry, + PaygProviderRuleKind, PaygRemapDoc, PaygRemapEntry, PaygRemapParseError, RateTimeBandedEntry, + ResolvesToEntry, }; /// Integer nanodollars per million tokens. $3/M tokens = 3_000_000_000. diff --git a/crates/cortexkit-model-catalog/src/payg_conformance.rs b/crates/cortexkit-model-catalog/src/payg_conformance.rs index fc9b378..6df8a30 100644 --- a/crates/cortexkit-model-catalog/src/payg_conformance.rs +++ b/crates/cortexkit-model-catalog/src/payg_conformance.rs @@ -9,6 +9,9 @@ use crate::{CatalogDoc, PaygModelId, PaygRemapDoc}; pub enum PaygOutcome { Priced, NotSoldPerToken, + /// Refuses a catalog scalar like other refusal outcomes because it cannot be correct for every + /// time band. + RateTimeBanded, TargetNotInCatalog, TargetNotPriceable, DeclarationSuperseded, @@ -147,4 +150,56 @@ mod tests { assert_eq!(suite.vectors[0].model.as_str(), "provider/model"); assert_eq!(suite.vectors[0].expected, PaygOutcome::Priced); } + + #[test] + fn reports_the_named_time_banded_vector_when_collapsed_to_not_sold() { + let vectors: PaygVectorSuite = + serde_json::from_str(include_str!("../tests/golden/payg-class-vectors.json")) + .expect("time-banded classification vector parses"); + + let time_banded = vectors + .vectors + .into_iter() + .find(|vector| vector.name == "time-banded-priced-source") + .expect("time-banded vector is present"); + let failures = run_vectors( + &PaygVectorSuite { + vectors: vec![time_banded], + }, + |_, _, _| PaygOutcome::NotSoldPerToken, + ); + + assert_eq!( + failures, + vec![super::VectorFailure { + vector: "time-banded-priced-source".into(), + expected: PaygOutcome::RateTimeBanded, + actual: PaygOutcome::NotSoldPerToken, + }] + ); + } + + #[test] + fn time_banded_vector_requires_its_distinct_outcome() { + let vectors: PaygVectorSuite = + serde_json::from_str(include_str!("../tests/golden/payg-class-vectors.json")) + .expect("time-banded classification vector parses"); + let time_banded = vectors + .vectors + .into_iter() + .find(|vector| vector.name == "time-banded-priced-source") + .expect("time-banded vector is present"); + + let failures = run_vectors( + &PaygVectorSuite { + vectors: vec![time_banded], + }, + |remap, _, model| match remap.entries.get(model) { + Some(crate::PaygRemapEntry::RateTimeBanded(_)) => PaygOutcome::RateTimeBanded, + _ => PaygOutcome::NoEntry, + }, + ); + + assert!(failures.is_empty(), "{failures:#?}"); + } } diff --git a/crates/cortexkit-model-catalog/src/payg_remap.rs b/crates/cortexkit-model-catalog/src/payg_remap.rs index c5ae425..4190cfd 100644 --- a/crates/cortexkit-model-catalog/src/payg_remap.rs +++ b/crates/cortexkit-model-catalog/src/payg_remap.rs @@ -115,10 +115,11 @@ impl PaygRemapDoc { } } -/// The only provider-wide PAYG refusal rule. +/// Provider-wide PAYG refusal rules. #[derive(Debug, Clone, PartialEq, Eq)] pub enum PaygProviderRuleKind { ZerosAreNotPrices, + RateTimeBanded, } /// A provider-scoped PAYG refusal rule. @@ -137,6 +138,7 @@ pub enum PaygRemapEntry { ResolvesTo(ResolvesToEntry), OverridesUnpriced(OverridesUnpricedEntry), NotSoldPerToken(NotSoldPerTokenEntry), + RateTimeBanded(RateTimeBandedEntry), } /// A declaration that points at one terminal catalog schedule. @@ -167,6 +169,14 @@ pub struct NotSoldPerTokenEntry { pub effective_from: Option, } +/// A declaration that per-token list pricing varies by time and cannot be represented safely. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RateTimeBandedEntry { + pub source: String, + pub observed: String, + pub effective_from: Option, +} + /// A PAYG remap-document parse failure. #[derive(Debug, Clone, PartialEq, Eq)] pub enum PaygRemapParseError { @@ -219,6 +229,10 @@ pub enum PaygRemapParseError { id: String, value: String, }, + UnexpectedField { + id: String, + field: String, + }, } impl std::fmt::Display for PaygRemapParseError { @@ -268,6 +282,9 @@ impl std::fmt::Display for PaygRemapParseError { Self::InvalidEffectiveFrom { id, value } => { write!(f, "PAYG remap declaration {id} has invalid effective_from {value:?}") } + Self::UnexpectedField { id, field } => { + write!(f, "PAYG remap declaration {id} has unexpected field {field}") + } } } } @@ -288,6 +305,7 @@ fn parse_provider_rules( let kind = required_string(rule, id, "kind")?; let kind = match kind.as_str() { "zeros_are_not_prices" => PaygProviderRuleKind::ZerosAreNotPrices, + "rate_time_banded" => PaygProviderRuleKind::RateTimeBanded, _ => { return Err(PaygRemapParseError::UnknownKind { id: id.clone(), @@ -355,6 +373,14 @@ fn parse_entries( observed, effective_from, }), + "rate_time_banded" => { + reject_unexpected_time_banded_fields(entry, raw_id)?; + PaygRemapEntry::RateTimeBanded(RateTimeBandedEntry { + source, + observed, + effective_from, + }) + } _ => { return Err(PaygRemapParseError::UnknownKind { id: raw_id.clone(), @@ -367,6 +393,24 @@ fn parse_entries( Ok(parsed) } +fn reject_unexpected_time_banded_fields( + entry: &serde_json::Map, + id: &str, +) -> Result<(), PaygRemapParseError> { + for field in entry.keys() { + if !matches!( + field.as_str(), + "kind" | "source" | "observed" | "effective_from" + ) { + return Err(PaygRemapParseError::UnexpectedField { + id: id.into(), + field: field.clone(), + }); + } + } + Ok(()) +} + fn required_provenance( entry: &serde_json::Map, id: &str, diff --git a/crates/cortexkit-model-catalog/tests/golden/payg-class-vectors.json b/crates/cortexkit-model-catalog/tests/golden/payg-class-vectors.json index 2a64321..0b26d63 100644 --- a/crates/cortexkit-model-catalog/tests/golden/payg-class-vectors.json +++ b/crates/cortexkit-model-catalog/tests/golden/payg-class-vectors.json @@ -877,6 +877,37 @@ "catalog": {}, "model": "source/model", "expected": "no_entry" + }, + { + "name": "time-banded-priced-source", + "cell": "rate_time_banded/priced", + "remap": { + "schema": 1, + "counterfactual": "same_platform_list", + "providers": {}, + "entries": { + "deepseek/deepseek-v4-pro": { + "kind": "rate_time_banded", + "source": "https://vendor.example/pricing", + "observed": "2026-08-16" + } + } + }, + "catalog": { + "deepseek": { + "models": { + "deepseek-v4-pro": { + "cost": { + "input": 0.66, + "output": 1.98, + "cache_read": 0.022 + } + } + } + } + }, + "model": "deepseek/deepseek-v4-pro", + "expected": "rate_time_banded" } ] } diff --git a/crates/cortexkit-model-catalog/tests/golden/payg-parse-vectors.json b/crates/cortexkit-model-catalog/tests/golden/payg-parse-vectors.json index a0eaff7..bf88bb1 100644 --- a/crates/cortexkit-model-catalog/tests/golden/payg-parse-vectors.json +++ b/crates/cortexkit-model-catalog/tests/golden/payg-parse-vectors.json @@ -160,6 +160,24 @@ "id": "p/m", "value": "123" } + }, + { + "name": "time-banded-entry-rejects-rates", + "input_json": "{\"schema\":1,\"counterfactual\":\"same_platform_list\",\"providers\":{},\"entries\":{\"p/m\":{\"kind\":\"rate_time_banded\",\"rates\":{\"input\":0.66},\"source\":\"https://vendor.example/pricing\",\"observed\":\"2026-08-16\"}}}", + "expect_error": { + "variant": "UnexpectedField", + "id": "p/m", + "field": "rates" + } + }, + { + "name": "time-banded-entry-rejects-target", + "input_json": "{\"schema\":1,\"counterfactual\":\"same_platform_list\",\"providers\":{},\"entries\":{\"p/m\":{\"kind\":\"rate_time_banded\",\"target\":\"p/other\",\"source\":\"https://vendor.example/pricing\",\"observed\":\"2026-08-16\"}}}", + "expect_error": { + "variant": "UnexpectedField", + "id": "p/m", + "field": "target" + } } ], "positive_vectors": [ @@ -168,6 +186,25 @@ "input_json": "{\"schema\":1,\"counterfactual\":\"same_platform_list\",\"providers\":{},\"entries\":{\"p/m\":{\"kind\":\"not_sold_per_token\",\"reason\":\"plan_only\",\"source\":\"https://vendor.example/pricing\",\"observed\":\"2026-08-13\",\"effective_from\":null}}}", "id": "p/m", "effective_from": null + }, + { + "name": "time-banded-entry-provenance-round-trips", + "input_json": "{\"schema\":1,\"counterfactual\":\"same_platform_list\",\"providers\":{},\"entries\":{\"deepseek/deepseek-v4-pro\":{\"kind\":\"rate_time_banded\",\"source\":\"https://vendor.example/pricing\",\"observed\":\"2026-08-16\",\"effective_from\":\"2026-08-16\"}}}", + "id": "deepseek/deepseek-v4-pro", + "entry_kind": "rate_time_banded", + "source": "https://vendor.example/pricing", + "observed": "2026-08-16", + "effective_from": "2026-08-16" + }, + { + "name": "time-banded-provider-rule-provenance-round-trips", + "input_json": "{\"schema\":1,\"counterfactual\":\"same_platform_list\",\"providers\":{\"deepseek\":{\"kind\":\"rate_time_banded\",\"source\":\"https://vendor.example/pricing\",\"observed\":\"2026-08-16\",\"effective_from\":\"2026-08-16\"}},\"entries\":{}}", + "id": "deepseek/placeholder", + "provider": "deepseek", + "provider_kind": "rate_time_banded", + "source": "https://vendor.example/pricing", + "observed": "2026-08-16", + "effective_from": "2026-08-16" } ] } diff --git a/crates/cortexkit-model-catalog/tests/payg_class_vectors.rs b/crates/cortexkit-model-catalog/tests/payg_class_vectors.rs index 43c3d07..679d2ad 100644 --- a/crates/cortexkit-model-catalog/tests/payg_class_vectors.rs +++ b/crates/cortexkit-model-catalog/tests/payg_class_vectors.rs @@ -35,6 +35,7 @@ const MATRIX_CELLS: &[&str] = &[ "not_sold_per_token/all-zero", "not_sold_per_token/all-none", "not_sold_per_token/absent", + "rate_time_banded/priced", "zeros_are_not_prices/priced", "zeros_are_not_prices/all-zero", "zeros_are_not_prices/all-none", @@ -48,6 +49,7 @@ const MATRIX_CELLS: &[&str] = &[ const LEGAL_OUTCOMES: &[&str] = &[ "priced", "not_sold_per_token", + "rate_time_banded", "target_not_in_catalog", "target_not_priceable", "declaration_superseded", @@ -94,6 +96,7 @@ const CELL_CONTRACT: &[(&str, &str)] = &[ ("not_sold_per_token/all-zero", "not_sold_per_token"), ("not_sold_per_token/all-none", "not_sold_per_token"), ("not_sold_per_token/absent", "not_sold_per_token"), + ("rate_time_banded/priced", "rate_time_banded"), ("zeros_are_not_prices/priced", "no_entry"), ("zeros_are_not_prices/all-zero", "not_sold_per_token"), ("zeros_are_not_prices/all-none", "not_sold_per_token"), @@ -190,7 +193,7 @@ fn validation_diagnostics_collect_independent_failures() { assert_eq!(failures.len(), 5, "{failures:#?}"); assert!(failures .iter() - .any(|failure| failure.contains("expected 29 vectors"))); + .any(|failure| failure.contains("expected 30 vectors"))); assert!(failures .iter() .any(|failure| failure.contains("unknown matrix cell"))); diff --git a/crates/cortexkit-model-catalog/tests/payg_parse_vectors.rs b/crates/cortexkit-model-catalog/tests/payg_parse_vectors.rs index 98c3d1f..e1c7a43 100644 --- a/crates/cortexkit-model-catalog/tests/payg_parse_vectors.rs +++ b/crates/cortexkit-model-catalog/tests/payg_parse_vectors.rs @@ -28,6 +28,11 @@ struct PositiveVector { name: String, input_json: String, id: String, + entry_kind: Option, + provider: Option, + provider_kind: Option, + source: Option, + observed: Option, effective_from: Option, } @@ -83,6 +88,10 @@ enum ExpectedError { id: String, value: String, }, + UnexpectedField { + id: String, + field: String, + }, } #[test] @@ -90,7 +99,7 @@ fn parse_gate_rejects_every_golden_vector_with_its_exact_error() { let file: VectorFile = serde_json::from_str(VECTORS).expect("parse PAYG parse vectors"); assert_eq!( file.vectors.len(), - 26, + 28, "one vector for each non-structural parse guard" ); @@ -109,21 +118,68 @@ fn parse_gate_accepts_every_positive_golden_vector() { assert_eq!( file.positive_vectors.len(), - 1, - "one vector for an explicitly unset optional field" + 3, + "one unset optional field plus entry and provider time-banded declarations" ); for vector in file.positive_vectors { let doc = PaygRemapDoc::parse(&vector.input_json) .unwrap_or_else(|error| panic!("{} unexpectedly refused: {error}", vector.name)); let id = PaygModelId::parse(&vector.id).expect("golden vector must use a valid id"); - let PaygRemapEntry::NotSoldPerToken(entry) = &doc.entries[&id] else { - panic!("{} must parse a not_sold_per_token entry", vector.name); - }; - assert_eq!( - entry.effective_from, vector.effective_from, - "{}: effective_from", - vector.name - ); + if let Some(expected_kind) = vector.entry_kind.as_deref() { + match (expected_kind, &doc.entries[&id]) { + ("not_sold_per_token", PaygRemapEntry::NotSoldPerToken(entry)) => { + if let Some(source) = vector.source.as_deref() { + assert_eq!(entry.source, source, "{}: source", vector.name); + } + if let Some(observed) = vector.observed.as_deref() { + assert_eq!(entry.observed, observed, "{}: observed", vector.name); + } + assert_eq!( + entry.effective_from, vector.effective_from, + "{}: effective_from", + vector.name + ); + } + ("rate_time_banded", PaygRemapEntry::RateTimeBanded(entry)) => { + if let Some(source) = vector.source.as_deref() { + assert_eq!(entry.source, source, "{}: source", vector.name); + } + if let Some(observed) = vector.observed.as_deref() { + assert_eq!(entry.observed, observed, "{}: observed", vector.name); + } + assert_eq!( + entry.effective_from, vector.effective_from, + "{}: effective_from", + vector.name + ); + } + (kind, entry) => panic!("{} must parse a {kind} entry, got {entry:?}", vector.name), + } + } + + if let (Some(provider), Some(expected_kind)) = + (vector.provider.as_deref(), vector.provider_kind.as_deref()) + { + let rule = &doc.providers[provider]; + match expected_kind { + "rate_time_banded" => assert!(matches!( + rule.kind, + cortexkit_model_catalog::PaygProviderRuleKind::RateTimeBanded + )), + kind => panic!("{} has unknown provider kind {kind}", vector.name), + } + if let Some(source) = vector.source.as_deref() { + assert_eq!(rule.source, source, "{}: source", vector.name); + } + if let Some(observed) = vector.observed.as_deref() { + assert_eq!(rule.observed, observed, "{}: observed", vector.name); + } + assert_eq!( + rule.effective_from, vector.effective_from, + "{}: effective_from", + vector.name + ); + } } } @@ -358,6 +414,16 @@ fn assert_expected_error(name: &str, error: PaygRemapParseError, expected: Expec assert_eq!(actual_id, id, "{name}: InvalidEffectiveFrom.id"); assert_eq!(actual_value, value, "{name}: InvalidEffectiveFrom.value"); } + ( + PaygRemapParseError::UnexpectedField { + id: actual_id, + field: actual_field, + }, + ExpectedError::UnexpectedField { id, field }, + ) => { + assert_eq!(actual_id, id, "{name}: UnexpectedField.id"); + assert_eq!(actual_field, field, "{name}: UnexpectedField.field"); + } (actual, expected) => panic!("{name}: expected {expected:?}, got {actual:?}"), } } From ae8d7f40c45c4ba981c44372c72c241c7a9fdfcf Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:07:40 +0200 Subject: [PATCH 35/37] test(model-catalog): every positive vector declares and asserts its parsed kind --- .../tests/golden/payg-parse-vectors.json | 4 +- .../tests/payg_parse_vectors.rs | 131 +++++++++++------- 2 files changed, 85 insertions(+), 50 deletions(-) diff --git a/crates/cortexkit-model-catalog/tests/golden/payg-parse-vectors.json b/crates/cortexkit-model-catalog/tests/golden/payg-parse-vectors.json index bf88bb1..78b821a 100644 --- a/crates/cortexkit-model-catalog/tests/golden/payg-parse-vectors.json +++ b/crates/cortexkit-model-catalog/tests/golden/payg-parse-vectors.json @@ -185,6 +185,7 @@ "name": "null-entry-effective-from-stays-absent", "input_json": "{\"schema\":1,\"counterfactual\":\"same_platform_list\",\"providers\":{},\"entries\":{\"p/m\":{\"kind\":\"not_sold_per_token\",\"reason\":\"plan_only\",\"source\":\"https://vendor.example/pricing\",\"observed\":\"2026-08-13\",\"effective_from\":null}}}", "id": "p/m", + "entry_kind": "not_sold_per_token", "effective_from": null }, { @@ -198,8 +199,9 @@ }, { "name": "time-banded-provider-rule-provenance-round-trips", - "input_json": "{\"schema\":1,\"counterfactual\":\"same_platform_list\",\"providers\":{\"deepseek\":{\"kind\":\"rate_time_banded\",\"source\":\"https://vendor.example/pricing\",\"observed\":\"2026-08-16\",\"effective_from\":\"2026-08-16\"}},\"entries\":{}}", + "input_json": "{\"schema\":1,\"counterfactual\":\"same_platform_list\",\"providers\":{\"deepseek\":{\"kind\":\"rate_time_banded\",\"source\":\"https://vendor.example/pricing\",\"observed\":\"2026-08-16\",\"effective_from\":\"2026-08-16\"}},\"entries\":{\"deepseek/placeholder\":{\"kind\":\"rate_time_banded\",\"source\":\"https://vendor.example/pricing\",\"observed\":\"2026-08-16\",\"effective_from\":\"2026-08-16\"}}}", "id": "deepseek/placeholder", + "entry_kind": "rate_time_banded", "provider": "deepseek", "provider_kind": "rate_time_banded", "source": "https://vendor.example/pricing", diff --git a/crates/cortexkit-model-catalog/tests/payg_parse_vectors.rs b/crates/cortexkit-model-catalog/tests/payg_parse_vectors.rs index e1c7a43..c9892d6 100644 --- a/crates/cortexkit-model-catalog/tests/payg_parse_vectors.rs +++ b/crates/cortexkit-model-catalog/tests/payg_parse_vectors.rs @@ -12,6 +12,10 @@ const VECTORS: &str = include_str!("golden/payg-parse-vectors.json"); #[derive(Debug, Deserialize)] struct VectorFile { vectors: Vec, +} + +#[derive(Debug, Deserialize)] +struct PositiveVectorFile { #[serde(default)] positive_vectors: Vec, } @@ -28,7 +32,7 @@ struct PositiveVector { name: String, input_json: String, id: String, - entry_kind: Option, + entry_kind: String, provider: Option, provider_kind: Option, source: Option, @@ -36,6 +40,18 @@ struct PositiveVector { effective_from: Option, } +#[derive(Debug, Deserialize)] +struct RawPositiveVectorFile { + #[serde(default)] + positive_vectors: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawPositiveVector { + name: String, + entry_kind: Option, +} + #[derive(Debug, Deserialize)] #[serde(tag = "variant")] enum ExpectedError { @@ -114,7 +130,7 @@ fn parse_gate_rejects_every_golden_vector_with_its_exact_error() { #[test] fn parse_gate_accepts_every_positive_golden_vector() { - let file: VectorFile = serde_json::from_str(VECTORS).expect("parse PAYG parse vectors"); + let file: PositiveVectorFile = serde_json::from_str(VECTORS).expect("parse PAYG parse vectors"); assert_eq!( file.positive_vectors.len(), @@ -125,64 +141,81 @@ fn parse_gate_accepts_every_positive_golden_vector() { let doc = PaygRemapDoc::parse(&vector.input_json) .unwrap_or_else(|error| panic!("{} unexpectedly refused: {error}", vector.name)); let id = PaygModelId::parse(&vector.id).expect("golden vector must use a valid id"); - if let Some(expected_kind) = vector.entry_kind.as_deref() { - match (expected_kind, &doc.entries[&id]) { - ("not_sold_per_token", PaygRemapEntry::NotSoldPerToken(entry)) => { - if let Some(source) = vector.source.as_deref() { - assert_eq!(entry.source, source, "{}: source", vector.name); - } - if let Some(observed) = vector.observed.as_deref() { - assert_eq!(entry.observed, observed, "{}: observed", vector.name); - } - assert_eq!( - entry.effective_from, vector.effective_from, - "{}: effective_from", - vector.name - ); + match (vector.entry_kind.as_str(), &doc.entries[&id]) { + ("not_sold_per_token", PaygRemapEntry::NotSoldPerToken(entry)) => { + if let Some(source) = vector.source.as_deref() { + assert_eq!(entry.source, source, "{}: source", vector.name); + } + if let Some(observed) = vector.observed.as_deref() { + assert_eq!(entry.observed, observed, "{}: observed", vector.name); } - ("rate_time_banded", PaygRemapEntry::RateTimeBanded(entry)) => { - if let Some(source) = vector.source.as_deref() { - assert_eq!(entry.source, source, "{}: source", vector.name); - } - if let Some(observed) = vector.observed.as_deref() { - assert_eq!(entry.observed, observed, "{}: observed", vector.name); - } - assert_eq!( - entry.effective_from, vector.effective_from, - "{}: effective_from", - vector.name - ); + assert_eq!( + entry.effective_from, vector.effective_from, + "{}: effective_from", + vector.name + ); + } + ("rate_time_banded", PaygRemapEntry::RateTimeBanded(entry)) => { + if let Some(source) = vector.source.as_deref() { + assert_eq!(entry.source, source, "{}: source", vector.name); + } + if let Some(observed) = vector.observed.as_deref() { + assert_eq!(entry.observed, observed, "{}: observed", vector.name); } - (kind, entry) => panic!("{} must parse a {kind} entry, got {entry:?}", vector.name), + assert_eq!( + entry.effective_from, vector.effective_from, + "{}: effective_from", + vector.name + ); } + (kind, entry) => panic!("{} must parse a {kind} entry, got {entry:?}", vector.name), } - if let (Some(provider), Some(expected_kind)) = - (vector.provider.as_deref(), vector.provider_kind.as_deref()) - { - let rule = &doc.providers[provider]; - match expected_kind { - "rate_time_banded" => assert!(matches!( - rule.kind, - cortexkit_model_catalog::PaygProviderRuleKind::RateTimeBanded - )), - kind => panic!("{} has unknown provider kind {kind}", vector.name), - } - if let Some(source) = vector.source.as_deref() { - assert_eq!(rule.source, source, "{}: source", vector.name); - } - if let Some(observed) = vector.observed.as_deref() { - assert_eq!(rule.observed, observed, "{}: observed", vector.name); + match (vector.provider.as_deref(), vector.provider_kind.as_deref()) { + (Some(provider), Some(expected_kind)) => { + let rule = &doc.providers[provider]; + match expected_kind { + "rate_time_banded" => assert!(matches!( + rule.kind, + cortexkit_model_catalog::PaygProviderRuleKind::RateTimeBanded + )), + kind => panic!("{} has unknown provider kind {kind}", vector.name), + } + if let Some(source) = vector.source.as_deref() { + assert_eq!(rule.source, source, "{}: source", vector.name); + } + if let Some(observed) = vector.observed.as_deref() { + assert_eq!(rule.observed, observed, "{}: observed", vector.name); + } + assert_eq!( + rule.effective_from, vector.effective_from, + "{}: effective_from", + vector.name + ); } - assert_eq!( - rule.effective_from, vector.effective_from, - "{}: effective_from", + (None, None) => {} + _ => panic!( + "{} must declare both provider and provider_kind", vector.name - ); + ), } } } +#[test] +fn positive_vectors_must_declare_entry_kind() { + let file: RawPositiveVectorFile = + serde_json::from_str(VECTORS).expect("parse raw PAYG parse vectors"); + + for vector in file.positive_vectors { + assert!( + vector.entry_kind.is_some(), + "{} must declare entry_kind", + vector.name + ); + } +} + #[test] fn malformed_effective_from_is_refused() { let error = PaygRemapDoc::parse( From ec38420fc0bb52d688f700350004e319056dc4aa Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Mon, 24 Aug 2026 07:46:47 +0200 Subject: [PATCH 36/37] fix(model-catalog): required provenance assertions, refuse duplicate ids and unknown fields --- crates/cortexkit-model-catalog/src/lib.rs | 2 + .../cortexkit-model-catalog/src/payg_remap.rs | 218 +++++++++++++++--- .../tests/golden/payg-parse-vectors.json | 54 +++++ .../tests/payg_parse_vectors.rs | 60 +++-- 4 files changed, 282 insertions(+), 52 deletions(-) diff --git a/crates/cortexkit-model-catalog/src/lib.rs b/crates/cortexkit-model-catalog/src/lib.rs index b4eea17..f00de03 100644 --- a/crates/cortexkit-model-catalog/src/lib.rs +++ b/crates/cortexkit-model-catalog/src/lib.rs @@ -6,6 +6,8 @@ //! fleet rule is that they must parse the SAME shape so a catalog schema //! drift cannot make them disagree silently. This crate is that shape. //! Consumers bring their own snapshot bytes and own their derived stores. +//! This crate ships no reference classifier: each consumer owns the join from a PAYG remap to +//! its catalog snapshot, while the conformance runner supplies their shared executable contract. //! //! Money discipline: models.dev publishes dollar-per-million-token rates as //! JSON decimal numbers. This crate converts them ONCE, at the parse diff --git a/crates/cortexkit-model-catalog/src/payg_remap.rs b/crates/cortexkit-model-catalog/src/payg_remap.rs index 4190cfd..5826e7f 100644 --- a/crates/cortexkit-model-catalog/src/payg_remap.rs +++ b/crates/cortexkit-model-catalog/src/payg_remap.rs @@ -1,5 +1,8 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use serde::de::{MapAccess, SeqAccess, Visitor}; +use serde::{Deserialize, Deserializer}; use serde_json::Value; use crate::{dollars_to_nanos, CostSchedule, CostTier, RateNanosPerMtok}; @@ -82,6 +85,8 @@ impl PaygRemapDoc { }); } + reject_duplicate_entry_ids(json)?; + let providers = parse_provider_rules( root.get("providers") .ok_or_else(|| PaygRemapParseError::Json("providers is missing".into()))?, @@ -233,6 +238,12 @@ pub enum PaygRemapParseError { id: String, field: String, }, + DuplicateEntry { + id: String, + }, + DuplicateIdPrefix { + id_prefix: String, + }, } impl std::fmt::Display for PaygRemapParseError { @@ -285,6 +296,10 @@ impl std::fmt::Display for PaygRemapParseError { Self::UnexpectedField { id, field } => { write!(f, "PAYG remap declaration {id} has unexpected field {field}") } + Self::DuplicateEntry { id } => write!(f, "PAYG remap has duplicate entry {id}"), + Self::DuplicateIdPrefix { id_prefix } => { + write!(f, "PAYG remap has duplicate provider id_prefix {id_prefix}") + } } } } @@ -298,10 +313,16 @@ fn parse_provider_rules( .as_object() .ok_or_else(|| PaygRemapParseError::Json("providers is not an object".into()))?; let mut parsed = BTreeMap::new(); + let mut id_prefixes = BTreeSet::new(); for (id, value) in rules { let rule = value.as_object().ok_or_else(|| { PaygRemapParseError::Json(format!("provider rule {id} is not an object")) })?; + reject_unexpected_fields( + rule, + id, + &["kind", "id_prefix", "source", "observed", "effective_from"], + )?; let kind = required_string(rule, id, "kind")?; let kind = match kind.as_str() { "zeros_are_not_prices" => PaygProviderRuleKind::ZerosAreNotPrices, @@ -318,6 +339,13 @@ fn parse_provider_rules( Some(Value::String(prefix)) => Some(prefix.clone()), Some(_) => return Err(PaygRemapParseError::InvalidIdPrefix { id: id.clone() }), }; + if let Some(prefix) = &id_prefix { + if !id_prefixes.insert(prefix.clone()) { + return Err(PaygRemapParseError::DuplicateIdPrefix { + id_prefix: prefix.clone(), + }); + } + } parsed.insert( id.clone(), PaygProviderRule { @@ -349,32 +377,64 @@ fn parse_entries( let effective_from = optional_effective_from(entry, raw_id)?; let kind = required_string(entry, raw_id, "kind")?; let entry = match kind.as_str() { - "resolves_to" => PaygRemapEntry::ResolvesTo(ResolvesToEntry { - target: PaygModelId::parse(&required_string(entry, raw_id, "target")?)?, - because: required_string(entry, raw_id, "because")?, - source, - observed, - effective_from, - }), - "overrides_unpriced" => PaygRemapEntry::OverridesUnpriced(OverridesUnpricedEntry { - cost: parse_override_cost( + "resolves_to" => { + reject_unexpected_fields( + entry, + raw_id, + &[ + "kind", + "target", + "because", + "source", + "observed", + "effective_from", + ], + )?; + PaygRemapEntry::ResolvesTo(ResolvesToEntry { + target: PaygModelId::parse(&required_string(entry, raw_id, "target")?)?, + because: required_string(entry, raw_id, "because")?, + source, + observed, + effective_from, + }) + } + "overrides_unpriced" => { + reject_unexpected_fields( + entry, + raw_id, + &["kind", "cost", "source", "observed", "effective_from"], + )?; + PaygRemapEntry::OverridesUnpriced(OverridesUnpricedEntry { + cost: parse_override_cost( + raw_id, + entry + .get("cost") + .ok_or_else(|| missing_required_field(raw_id, "cost"))?, + )?, + source, + observed, + effective_from, + }) + } + "not_sold_per_token" => { + reject_unexpected_fields( + entry, raw_id, - entry - .get("cost") - .ok_or_else(|| missing_required_field(raw_id, "cost"))?, - )?, - source, - observed, - effective_from, - }), - "not_sold_per_token" => PaygRemapEntry::NotSoldPerToken(NotSoldPerTokenEntry { - reason: required_string(entry, raw_id, "reason")?, - source, - observed, - effective_from, - }), + &["kind", "reason", "source", "observed", "effective_from"], + )?; + PaygRemapEntry::NotSoldPerToken(NotSoldPerTokenEntry { + reason: required_string(entry, raw_id, "reason")?, + source, + observed, + effective_from, + }) + } "rate_time_banded" => { - reject_unexpected_time_banded_fields(entry, raw_id)?; + reject_unexpected_fields( + entry, + raw_id, + &["kind", "source", "observed", "effective_from"], + )?; PaygRemapEntry::RateTimeBanded(RateTimeBandedEntry { source, observed, @@ -393,15 +453,13 @@ fn parse_entries( Ok(parsed) } -fn reject_unexpected_time_banded_fields( +fn reject_unexpected_fields( entry: &serde_json::Map, id: &str, + allowed: &[&str], ) -> Result<(), PaygRemapParseError> { for field in entry.keys() { - if !matches!( - field.as_str(), - "kind" | "source" | "observed" | "effective_from" - ) { + if !allowed.contains(&field.as_str()) { return Err(PaygRemapParseError::UnexpectedField { id: id.into(), field: field.clone(), @@ -411,6 +469,106 @@ fn reject_unexpected_time_banded_fields( Ok(()) } +#[derive(Debug)] +enum DuplicatePreservingValue { + Null, + Bool, + Number, + String, + Array, + Object(Vec<(String, Self)>), +} + +impl<'de> Deserialize<'de> for DuplicatePreservingValue { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct DuplicatePreservingVisitor; + + impl<'de> Visitor<'de> for DuplicatePreservingVisitor { + type Value = DuplicatePreservingValue; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a JSON value") + } + + fn visit_unit(self) -> Result { + Ok(DuplicatePreservingValue::Null) + } + + fn visit_bool(self, _: bool) -> Result { + Ok(DuplicatePreservingValue::Bool) + } + + fn visit_i64(self, _: i64) -> Result { + Ok(DuplicatePreservingValue::Number) + } + + fn visit_u64(self, _: u64) -> Result { + Ok(DuplicatePreservingValue::Number) + } + + fn visit_f64(self, _: f64) -> Result { + Ok(DuplicatePreservingValue::Number) + } + + fn visit_str(self, _: &str) -> Result { + Ok(DuplicatePreservingValue::String) + } + + fn visit_string(self, _: String) -> Result { + Ok(DuplicatePreservingValue::String) + } + + fn visit_seq(self, mut sequence: A) -> Result + where + A: SeqAccess<'de>, + { + while sequence + .next_element::()? + .is_some() + {} + Ok(DuplicatePreservingValue::Array) + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let mut fields = Vec::new(); + while let Some(key) = map.next_key()? { + fields.push((key, map.next_value()?)); + } + Ok(DuplicatePreservingValue::Object(fields)) + } + } + + deserializer.deserialize_any(DuplicatePreservingVisitor) + } +} + +fn reject_duplicate_entry_ids(json: &str) -> Result<(), PaygRemapParseError> { + let root: DuplicatePreservingValue = + serde_json::from_str(json).map_err(|error| PaygRemapParseError::Json(error.to_string()))?; + let DuplicatePreservingValue::Object(root) = root else { + return Ok(()); + }; + let Some((_, DuplicatePreservingValue::Object(entries))) = + root.into_iter().find(|(field, _)| field == "entries") + else { + return Ok(()); + }; + + let mut ids = BTreeSet::new(); + for (id, _) in entries { + if !ids.insert(id.clone()) { + return Err(PaygRemapParseError::DuplicateEntry { id }); + } + } + Ok(()) +} + fn required_provenance( entry: &serde_json::Map, id: &str, diff --git a/crates/cortexkit-model-catalog/tests/golden/payg-parse-vectors.json b/crates/cortexkit-model-catalog/tests/golden/payg-parse-vectors.json index 78b821a..9df9411 100644 --- a/crates/cortexkit-model-catalog/tests/golden/payg-parse-vectors.json +++ b/crates/cortexkit-model-catalog/tests/golden/payg-parse-vectors.json @@ -178,6 +178,58 @@ "id": "p/m", "field": "target" } + }, + { + "name": "duplicate-entry-id", + "input_json": "{\"schema\":1,\"counterfactual\":\"same_platform_list\",\"providers\":{},\"entries\":{\"p/m\":{\"kind\":\"not_sold_per_token\",\"reason\":\"first\",\"source\":\"https://vendor.example/pricing\",\"observed\":\"2026-08-16\"},\"p/m\":{\"kind\":\"not_sold_per_token\",\"reason\":\"second\",\"source\":\"https://vendor.example/pricing\",\"observed\":\"2026-08-16\"}}}", + "expect_error": { + "variant": "DuplicateEntry", + "id": "p/m" + } + }, + { + "name": "duplicate-provider-id-prefix", + "input_json": "{\"schema\":1,\"counterfactual\":\"same_platform_list\",\"providers\":{\"p1\":{\"kind\":\"zeros_are_not_prices\",\"id_prefix\":\"shared-\",\"source\":\"https://vendor.example/pricing\",\"observed\":\"2026-08-16\"},\"p2\":{\"kind\":\"zeros_are_not_prices\",\"id_prefix\":\"shared-\",\"source\":\"https://vendor.example/pricing\",\"observed\":\"2026-08-16\"}},\"entries\":{}}", + "expect_error": { + "variant": "DuplicateIdPrefix", + "id_prefix": "shared-" + } + }, + { + "name": "resolves-to-unknown-field", + "input_json": "{\"schema\":1,\"counterfactual\":\"same_platform_list\",\"providers\":{},\"entries\":{\"p/m\":{\"kind\":\"resolves_to\",\"target\":\"q/m\",\"because\":\"same schedule\",\"sources\":\"typo\",\"source\":\"https://vendor.example/pricing\",\"observed\":\"2026-08-16\"}}}", + "expect_error": { + "variant": "UnexpectedField", + "id": "p/m", + "field": "sources" + } + }, + { + "name": "overrides-unpriced-unknown-field", + "input_json": "{\"schema\":1,\"counterfactual\":\"same_platform_list\",\"providers\":{},\"entries\":{\"p/m\":{\"kind\":\"overrides_unpriced\",\"cost\":{\"input\":1},\"effective_form\":\"typo\",\"source\":\"https://vendor.example/pricing\",\"observed\":\"2026-08-16\"}}}", + "expect_error": { + "variant": "UnexpectedField", + "id": "p/m", + "field": "effective_form" + } + }, + { + "name": "not-sold-per-token-unknown-field", + "input_json": "{\"schema\":1,\"counterfactual\":\"same_platform_list\",\"providers\":{},\"entries\":{\"p/m\":{\"kind\":\"not_sold_per_token\",\"reason\":\"plan-only\",\"effective_form\":\"typo\",\"source\":\"https://vendor.example/pricing\",\"observed\":\"2026-08-16\"}}}", + "expect_error": { + "variant": "UnexpectedField", + "id": "p/m", + "field": "effective_form" + } + }, + { + "name": "provider-rule-unknown-field", + "input_json": "{\"schema\":1,\"counterfactual\":\"same_platform_list\",\"providers\":{\"p\":{\"kind\":\"zeros_are_not_prices\",\"sources\":\"typo\",\"source\":\"https://vendor.example/pricing\",\"observed\":\"2026-08-16\"}},\"entries\":{}}", + "expect_error": { + "variant": "UnexpectedField", + "id": "p", + "field": "sources" + } } ], "positive_vectors": [ @@ -186,6 +238,8 @@ "input_json": "{\"schema\":1,\"counterfactual\":\"same_platform_list\",\"providers\":{},\"entries\":{\"p/m\":{\"kind\":\"not_sold_per_token\",\"reason\":\"plan_only\",\"source\":\"https://vendor.example/pricing\",\"observed\":\"2026-08-13\",\"effective_from\":null}}}", "id": "p/m", "entry_kind": "not_sold_per_token", + "source": "https://vendor.example/pricing", + "observed": "2026-08-13", "effective_from": null }, { diff --git a/crates/cortexkit-model-catalog/tests/payg_parse_vectors.rs b/crates/cortexkit-model-catalog/tests/payg_parse_vectors.rs index c9892d6..9f719f6 100644 --- a/crates/cortexkit-model-catalog/tests/payg_parse_vectors.rs +++ b/crates/cortexkit-model-catalog/tests/payg_parse_vectors.rs @@ -35,8 +35,8 @@ struct PositiveVector { entry_kind: String, provider: Option, provider_kind: Option, - source: Option, - observed: Option, + source: String, + observed: String, effective_from: Option, } @@ -50,6 +50,8 @@ struct RawPositiveVectorFile { struct RawPositiveVector { name: String, entry_kind: Option, + source: Option, + observed: Option, } #[derive(Debug, Deserialize)] @@ -108,6 +110,12 @@ enum ExpectedError { id: String, field: String, }, + DuplicateEntry { + id: String, + }, + DuplicateIdPrefix { + id_prefix: String, + }, } #[test] @@ -115,7 +123,7 @@ fn parse_gate_rejects_every_golden_vector_with_its_exact_error() { let file: VectorFile = serde_json::from_str(VECTORS).expect("parse PAYG parse vectors"); assert_eq!( file.vectors.len(), - 28, + 34, "one vector for each non-structural parse guard" ); @@ -143,12 +151,8 @@ fn parse_gate_accepts_every_positive_golden_vector() { let id = PaygModelId::parse(&vector.id).expect("golden vector must use a valid id"); match (vector.entry_kind.as_str(), &doc.entries[&id]) { ("not_sold_per_token", PaygRemapEntry::NotSoldPerToken(entry)) => { - if let Some(source) = vector.source.as_deref() { - assert_eq!(entry.source, source, "{}: source", vector.name); - } - if let Some(observed) = vector.observed.as_deref() { - assert_eq!(entry.observed, observed, "{}: observed", vector.name); - } + assert_eq!(entry.source, vector.source, "{}: source", vector.name); + assert_eq!(entry.observed, vector.observed, "{}: observed", vector.name); assert_eq!( entry.effective_from, vector.effective_from, "{}: effective_from", @@ -156,12 +160,8 @@ fn parse_gate_accepts_every_positive_golden_vector() { ); } ("rate_time_banded", PaygRemapEntry::RateTimeBanded(entry)) => { - if let Some(source) = vector.source.as_deref() { - assert_eq!(entry.source, source, "{}: source", vector.name); - } - if let Some(observed) = vector.observed.as_deref() { - assert_eq!(entry.observed, observed, "{}: observed", vector.name); - } + assert_eq!(entry.source, vector.source, "{}: source", vector.name); + assert_eq!(entry.observed, vector.observed, "{}: observed", vector.name); assert_eq!( entry.effective_from, vector.effective_from, "{}: effective_from", @@ -181,12 +181,8 @@ fn parse_gate_accepts_every_positive_golden_vector() { )), kind => panic!("{} has unknown provider kind {kind}", vector.name), } - if let Some(source) = vector.source.as_deref() { - assert_eq!(rule.source, source, "{}: source", vector.name); - } - if let Some(observed) = vector.observed.as_deref() { - assert_eq!(rule.observed, observed, "{}: observed", vector.name); - } + assert_eq!(rule.source, vector.source, "{}: source", vector.name); + assert_eq!(rule.observed, vector.observed, "{}: observed", vector.name); assert_eq!( rule.effective_from, vector.effective_from, "{}: effective_from", @@ -203,7 +199,7 @@ fn parse_gate_accepts_every_positive_golden_vector() { } #[test] -fn positive_vectors_must_declare_entry_kind() { +fn positive_vectors_must_declare_expected_fields() { let file: RawPositiveVectorFile = serde_json::from_str(VECTORS).expect("parse raw PAYG parse vectors"); @@ -213,6 +209,16 @@ fn positive_vectors_must_declare_entry_kind() { "{} must declare entry_kind", vector.name ); + assert!( + vector.source.is_some(), + "{} must declare source", + vector.name + ); + assert!( + vector.observed.is_some(), + "{} must declare observed", + vector.name + ); } } @@ -457,6 +463,16 @@ fn assert_expected_error(name: &str, error: PaygRemapParseError, expected: Expec assert_eq!(actual_id, id, "{name}: UnexpectedField.id"); assert_eq!(actual_field, field, "{name}: UnexpectedField.field"); } + ( + PaygRemapParseError::DuplicateEntry { id: actual }, + ExpectedError::DuplicateEntry { id: expected }, + ) => assert_eq!(actual, expected, "{name}: DuplicateEntry.id"), + ( + PaygRemapParseError::DuplicateIdPrefix { id_prefix: actual }, + ExpectedError::DuplicateIdPrefix { + id_prefix: expected, + }, + ) => assert_eq!(actual, expected, "{name}: DuplicateIdPrefix.id_prefix"), (actual, expected) => panic!("{name}: expected {expected:?}, got {actual:?}"), } } From 4e5bbe3c0c9780eeb388b9f95744ececb6705315 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Mon, 24 Aug 2026 08:10:21 +0200 Subject: [PATCH 37/37] fix(model-catalog): refuse duplicate keys at every level, not just entry ids --- .../cortexkit-model-catalog/src/payg_remap.rs | 65 +++++++++++++------ .../tests/golden/payg-parse-vectors.json | 16 +++++ .../tests/payg_parse_vectors.rs | 36 +++++++++- 3 files changed, 95 insertions(+), 22 deletions(-) diff --git a/crates/cortexkit-model-catalog/src/payg_remap.rs b/crates/cortexkit-model-catalog/src/payg_remap.rs index 5826e7f..d79e75f 100644 --- a/crates/cortexkit-model-catalog/src/payg_remap.rs +++ b/crates/cortexkit-model-catalog/src/payg_remap.rs @@ -85,7 +85,7 @@ impl PaygRemapDoc { }); } - reject_duplicate_entry_ids(json)?; + reject_duplicate_keys(json)?; let providers = parse_provider_rules( root.get("providers") @@ -244,6 +244,9 @@ pub enum PaygRemapParseError { DuplicateIdPrefix { id_prefix: String, }, + DuplicateKey { + key: String, + }, } impl std::fmt::Display for PaygRemapParseError { @@ -300,6 +303,7 @@ impl std::fmt::Display for PaygRemapParseError { Self::DuplicateIdPrefix { id_prefix } => { write!(f, "PAYG remap has duplicate provider id_prefix {id_prefix}") } + Self::DuplicateKey { key } => write!(f, "PAYG remap has duplicate key {key}"), } } } @@ -475,7 +479,7 @@ enum DuplicatePreservingValue { Bool, Number, String, - Array, + Array(Vec), Object(Vec<(String, Self)>), } @@ -525,11 +529,11 @@ impl<'de> Deserialize<'de> for DuplicatePreservingValue { where A: SeqAccess<'de>, { - while sequence - .next_element::()? - .is_some() - {} - Ok(DuplicatePreservingValue::Array) + let mut values = Vec::new(); + while let Some(value) = sequence.next_element()? { + values.push(value); + } + Ok(DuplicatePreservingValue::Array(values)) } fn visit_map(self, mut map: A) -> Result @@ -548,25 +552,44 @@ impl<'de> Deserialize<'de> for DuplicatePreservingValue { } } -fn reject_duplicate_entry_ids(json: &str) -> Result<(), PaygRemapParseError> { +fn reject_duplicate_keys(json: &str) -> Result<(), PaygRemapParseError> { let root: DuplicatePreservingValue = serde_json::from_str(json).map_err(|error| PaygRemapParseError::Json(error.to_string()))?; - let DuplicatePreservingValue::Object(root) = root else { - return Ok(()); - }; - let Some((_, DuplicatePreservingValue::Object(entries))) = - root.into_iter().find(|(field, _)| field == "entries") - else { - return Ok(()); - }; - let mut ids = BTreeSet::new(); - for (id, _) in entries { - if !ids.insert(id.clone()) { - return Err(PaygRemapParseError::DuplicateEntry { id }); + fn visit(value: DuplicatePreservingValue, path: &str) -> Result<(), PaygRemapParseError> { + match value { + DuplicatePreservingValue::Array(values) => { + for (index, value) in values.into_iter().enumerate() { + visit(value, &format!("{path}[{index}]"))?; + } + } + DuplicatePreservingValue::Object(fields) => { + let mut keys = BTreeSet::new(); + for (key, value) in fields { + if !keys.insert(key.clone()) { + return Err(if path == "entries" { + PaygRemapParseError::DuplicateEntry { id: key } + } else { + PaygRemapParseError::DuplicateKey { key } + }); + } + let child_path = if path.is_empty() { + key.clone() + } else { + format!("{path}.{key}") + }; + visit(value, &child_path)?; + } + } + DuplicatePreservingValue::Null + | DuplicatePreservingValue::Bool + | DuplicatePreservingValue::Number + | DuplicatePreservingValue::String => {} } + Ok(()) } - Ok(()) + + visit(root, "") } fn required_provenance( diff --git a/crates/cortexkit-model-catalog/tests/golden/payg-parse-vectors.json b/crates/cortexkit-model-catalog/tests/golden/payg-parse-vectors.json index 9df9411..3f7c9a8 100644 --- a/crates/cortexkit-model-catalog/tests/golden/payg-parse-vectors.json +++ b/crates/cortexkit-model-catalog/tests/golden/payg-parse-vectors.json @@ -230,6 +230,22 @@ "id": "p", "field": "sources" } + }, + { + "name": "duplicate-top-level-entries-key", + "input_json": "{\"schema\":1,\"counterfactual\":\"same_platform_list\",\"providers\":{},\"entries\":{},\"entries\":{\"p/m\":{\"kind\":\"not_sold_per_token\",\"reason\":\"plan-only\",\"source\":\"https://vendor.example/pricing\",\"observed\":\"2026-08-16\"}}}", + "expect_error": { + "variant": "DuplicateKey", + "key": "entries" + } + }, + { + "name": "duplicate-nested-entry-source-key", + "input_json": "{\"schema\":1,\"counterfactual\":\"same_platform_list\",\"providers\":{},\"entries\":{\"p/m\":{\"kind\":\"not_sold_per_token\",\"reason\":\"plan-only\",\"source\":\"https://first.example/pricing\",\"source\":\"https://second.example/pricing\",\"observed\":\"2026-08-16\"}}}", + "expect_error": { + "variant": "DuplicateKey", + "key": "source" + } } ], "positive_vectors": [ diff --git a/crates/cortexkit-model-catalog/tests/payg_parse_vectors.rs b/crates/cortexkit-model-catalog/tests/payg_parse_vectors.rs index 9f719f6..6715c44 100644 --- a/crates/cortexkit-model-catalog/tests/payg_parse_vectors.rs +++ b/crates/cortexkit-model-catalog/tests/payg_parse_vectors.rs @@ -116,6 +116,9 @@ enum ExpectedError { DuplicateIdPrefix { id_prefix: String, }, + DuplicateKey { + key: String, + }, } #[test] @@ -123,7 +126,7 @@ fn parse_gate_rejects_every_golden_vector_with_its_exact_error() { let file: VectorFile = serde_json::from_str(VECTORS).expect("parse PAYG parse vectors"); assert_eq!( file.vectors.len(), - 34, + 36, "one vector for each non-structural parse guard" ); @@ -136,6 +139,31 @@ fn parse_gate_rejects_every_golden_vector_with_its_exact_error() { } } +#[test] +fn duplicate_key_vectors_refuse_at_each_object_depth() { + let file: VectorFile = serde_json::from_str(VECTORS).expect("parse PAYG parse vectors"); + + let accepted = [ + "duplicate-top-level-entries-key", + "duplicate-nested-entry-source-key", + ] + .into_iter() + .filter(|name| { + let vector = file + .vectors + .iter() + .find(|vector| vector.name == *name) + .unwrap_or_else(|| panic!("missing {name} vector")); + PaygRemapDoc::parse(&vector.input_json).is_ok() + }) + .collect::>(); + + assert!( + accepted.is_empty(), + "duplicate vectors parsed: {accepted:?}" + ); +} + #[test] fn parse_gate_accepts_every_positive_golden_vector() { let file: PositiveVectorFile = serde_json::from_str(VECTORS).expect("parse PAYG parse vectors"); @@ -200,6 +228,8 @@ fn parse_gate_accepts_every_positive_golden_vector() { #[test] fn positive_vectors_must_declare_expected_fields() { + // Serde's required-field error reports only a generated-fixture line number; this companion + // guard identifies the malformed vector by name when the positive corpus grows. let file: RawPositiveVectorFile = serde_json::from_str(VECTORS).expect("parse raw PAYG parse vectors"); @@ -473,6 +503,10 @@ fn assert_expected_error(name: &str, error: PaygRemapParseError, expected: Expec id_prefix: expected, }, ) => assert_eq!(actual, expected, "{name}: DuplicateIdPrefix.id_prefix"), + ( + PaygRemapParseError::DuplicateKey { key: actual }, + ExpectedError::DuplicateKey { key: expected }, + ) => assert_eq!(actual, expected, "{name}: DuplicateKey.key"), (actual, expected) => panic!("{name}: expected {expected:?}, got {actual:?}"), } }