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/Cargo.toml b/Cargo.toml index e607185..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"] +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/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 diff --git a/crates/cortexkit-lease/src/lib.rs b/crates/cortexkit-lease/src/lib.rs index 237ac80..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. /// @@ -118,6 +167,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 +208,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, @@ -179,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. @@ -202,6 +273,46 @@ 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)?; + 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, + // 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 +326,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 { @@ -257,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(); @@ -314,6 +534,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(); diff --git a/crates/cortexkit-model-catalog/Cargo.toml b/crates/cortexkit-model-catalog/Cargo.toml new file mode 100644 index 0000000..8f160dd --- /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.3.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..f00de03 --- /dev/null +++ b/crates/cortexkit-model-catalog/src/lib.rs @@ -0,0 +1,685 @@ +//! 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. +//! 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 +//! 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; + +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, RateTimeBandedEntry, + ResolvesToEntry, +}; + +/// 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, + }, + /// 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, + }, + /// 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. + /// 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 { + 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" + ), + CatalogParseError::NegativeRate { provider, model, field, value } => { + write!(f, "catalog rate {provider}/{model}.{field} = {value} is negative") + } + CatalogParseError::MissingTierThreshold { provider, model } => { + write!( + f, + "catalog pricing tier on {provider}/{model} lacks a verifiable context threshold (tier.type/tier.size)" + ) + } + } + } +} + +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 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) => convert(field, v).map(Some), + } + }; + 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) => 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. 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_else(tier_err)?; + tiers.push(CostTier { + min_context, + 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. 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()) +} + +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; + 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() +} + +#[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)); + // 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": { + "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": [ + { "tier": { "type": "context", "size": 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)); + } + + /// 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 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 = + 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 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": [ { "tier": { "type": "context", "size": 1 }, "input": -1 } ] } } } } }"#, + ) + .unwrap_err(); + assert!(matches!(err, CatalogParseError::NegativeRate { .. })); + } + + #[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:?}"), + } + } +} 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..6df8a30 --- /dev/null +++ b/crates/cortexkit-model-catalog/src/payg_conformance.rs @@ -0,0 +1,205 @@ +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, + /// Refuses a catalog scalar like other refusal outcomes because it cannot be correct for every + /// time band. + RateTimeBanded, + 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); + } + + #[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 new file mode 100644 index 0000000..d79e75f --- /dev/null +++ b/crates/cortexkit-model-catalog/src/payg_remap.rs @@ -0,0 +1,833 @@ +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}; + +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, + }); + } + + reject_duplicate_keys(json)?; + + 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, + }) + } +} + +/// Provider-wide PAYG refusal rules. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PaygProviderRuleKind { + ZerosAreNotPrices, + RateTimeBanded, +} + +/// 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, + pub effective_from: Option, +} + +/// A specific PAYG remap declaration. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PaygRemapEntry { + ResolvesTo(ResolvesToEntry), + OverridesUnpriced(OverridesUnpricedEntry), + NotSoldPerToken(NotSoldPerTokenEntry), + RateTimeBanded(RateTimeBandedEntry), +} + +/// 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, + pub effective_from: Option, +} + +/// 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, + pub effective_from: Option, +} + +/// 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, + 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 { + 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, + }, + InvalidEffectiveFrom { + id: String, + value: String, + }, + UnexpectedField { + id: String, + field: String, + }, + DuplicateEntry { + id: String, + }, + DuplicateIdPrefix { + id_prefix: String, + }, + DuplicateKey { + key: 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") + } + 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}") + } + 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}") + } + Self::DuplicateKey { key } => write!(f, "PAYG remap has duplicate key {key}"), + } + } +} + +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(); + 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, + "rate_time_banded" => PaygProviderRuleKind::RateTimeBanded, + _ => { + 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() }), + }; + 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 { + kind, + id_prefix, + source: required_provenance(rule, id, "source")?, + observed: required_provenance(rule, id, "observed")?, + effective_from: optional_effective_from(rule, id)?, + }, + ); + } + 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 effective_from = optional_effective_from(entry, raw_id)?; + let kind = required_string(entry, raw_id, "kind")?; + let entry = match kind.as_str() { + "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, + &["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_fields( + entry, + raw_id, + &["kind", "source", "observed", "effective_from"], + )?; + PaygRemapEntry::RateTimeBanded(RateTimeBandedEntry { + source, + observed, + effective_from, + }) + } + _ => { + return Err(PaygRemapParseError::UnknownKind { + id: raw_id.clone(), + kind, + }); + } + }; + parsed.insert(id, entry); + } + Ok(parsed) +} + +fn reject_unexpected_fields( + entry: &serde_json::Map, + id: &str, + allowed: &[&str], +) -> Result<(), PaygRemapParseError> { + for field in entry.keys() { + if !allowed.contains(&field.as_str()) { + return Err(PaygRemapParseError::UnexpectedField { + id: id.into(), + field: field.clone(), + }); + } + } + Ok(()) +} + +#[derive(Debug)] +enum DuplicatePreservingValue { + Null, + Bool, + Number, + String, + Array(Vec), + 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>, + { + 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 + 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_keys(json: &str) -> Result<(), PaygRemapParseError> { + let root: DuplicatePreservingValue = + serde_json::from_str(json).map_err(|error| PaygRemapParseError::Json(error.to_string()))?; + + 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(()) + } + + visit(root, "") +} + +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, + }) +} + +/// `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 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 { + 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, + 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..0b26d63 --- /dev/null +++ b/crates/cortexkit-model-catalog/tests/golden/payg-class-vectors.json @@ -0,0 +1,913 @@ +{ + "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" + }, + { + "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 new file mode 100644 index 0000000..3f7c9a8 --- /dev/null +++ b/crates/cortexkit-model-catalog/tests/golden/payg-parse-vectors.json @@ -0,0 +1,282 @@ +{ + "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" + } + }, + { + "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" + } + }, + { + "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" + } + }, + { + "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" + } + }, + { + "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" + } + }, + { + "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": [ + { + "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", + "source": "https://vendor.example/pricing", + "observed": "2026-08-13", + "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\":{\"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", + "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 new file mode 100644 index 0000000..679d2ad --- /dev/null +++ b/crates/cortexkit-model-catalog/tests/payg_class_vectors.rs @@ -0,0 +1,299 @@ +//! 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", + "rate_time_banded/priced", + "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", + "rate_time_banded", + "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"), + ("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"), + ("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 30 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..6715c44 --- /dev/null +++ b/crates/cortexkit-model-catalog/tests/payg_parse_vectors.rs @@ -0,0 +1,512 @@ +//! 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::{PaygModelId, PaygRemapDoc, PaygRemapEntry, 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 PositiveVectorFile { + #[serde(default)] + positive_vectors: Vec, +} + +#[derive(Debug, Deserialize)] +struct Vector { + name: String, + input_json: String, + expect_error: ExpectedError, +} + +#[derive(Debug, Deserialize)] +struct PositiveVector { + name: String, + input_json: String, + id: String, + entry_kind: String, + provider: Option, + provider_kind: Option, + source: String, + observed: String, + effective_from: Option, +} + +#[derive(Debug, Deserialize)] +struct RawPositiveVectorFile { + #[serde(default)] + positive_vectors: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawPositiveVector { + name: String, + entry_kind: Option, + source: Option, + observed: Option, +} + +#[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, + }, + InvalidEffectiveFrom { + id: String, + value: String, + }, + UnexpectedField { + id: String, + field: String, + }, + DuplicateEntry { + id: String, + }, + DuplicateIdPrefix { + id_prefix: String, + }, + DuplicateKey { + key: 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(), + 36, + "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 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"); + + assert_eq!( + file.positive_vectors.len(), + 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"); + match (vector.entry_kind.as_str(), &doc.entries[&id]) { + ("not_sold_per_token", PaygRemapEntry::NotSoldPerToken(entry)) => { + 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", + vector.name + ); + } + ("rate_time_banded", PaygRemapEntry::RateTimeBanded(entry)) => { + 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", + vector.name + ); + } + (kind, entry) => panic!("{} must parse a {kind} entry, got {entry:?}", 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), + } + 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", + vector.name + ); + } + (None, None) => {} + _ => panic!( + "{} must declare both provider and provider_kind", + vector.name + ), + } + } +} + +#[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"); + + for vector in file.positive_vectors { + assert!( + vector.entry_kind.is_some(), + "{} 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 + ); + } +} + +#[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.observed, "2026-08-13"); + assert_eq!(entry.effective_from, None); +} + +#[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"), + ( + 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"); + } + ( + 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"); + } + ( + 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"), + ( + PaygRemapParseError::DuplicateKey { key: actual }, + ExpectedError::DuplicateKey { key: expected }, + ) => assert_eq!(actual, expected, "{name}: DuplicateKey.key"), + (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" + )); +} 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"] diff --git a/crates/cortexkit-paths/src/lib.rs b/crates/cortexkit-paths/src/lib.rs index 9190ef0..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); @@ -35,6 +53,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 +75,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 +205,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 +342,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 +383,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"); diff --git a/crates/cortexkit-provider-usage/Cargo.toml b/crates/cortexkit-provider-usage/Cargo.toml new file mode 100644 index 0000000..a3a4a2f --- /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.6.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..6f04fbd --- /dev/null +++ b/crates/cortexkit-provider-usage/src/lib.rs @@ -0,0 +1,920 @@ +//! 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`, +//! `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 +//! 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). + /// + /// **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 + /// 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, + /// 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, 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, +} + +/// 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 { + /// 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, +} + +/// 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 { + /// 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>, +} + +/// 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, **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, +} + +/// 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, + /// 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. +/// +/// 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")] +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, +} + +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, + /// 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)] + 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, + /// 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, + /// 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, + /// 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")] + 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, + /// 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 { + /// A healthy entry with resolved windows. + 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, + fetched_at: None, + saved_resets: None, + usage: Some(usage), + spend: None, + error: None, + error_class: None, + stale: 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(), + api_provider: None, + account: None, + source: None, + account_info: None, + fetched_at: None, + saved_resets: None, + usage: None, + spend: None, + error: Some(error.to_string()), + error_class: None, + stale: 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) + } + } +} + +#[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] + 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), + used_count: None, + total_count: None, + }), + ..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), + used_count: None, + total_count: None, + }; + 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), + used_count: None, + total_count: None, + }; + 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")); + } + + #[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); + } + + #[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); + } + + /// 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")); + } + + /// 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}" + ); + } + + /// 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. + #[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")); + } +} 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/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..f890073 --- /dev/null +++ b/crates/cortexkit-push-seal/examples/handseal.rs @@ -0,0 +1,123 @@ +// 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 argument accepts either bare hex or a labelled block pasted whole: +// +// 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 +// 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 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(&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 + .iter() + .map(|b| format!("{b:02x}")) + .collect::() + ); + eprintln!( + "plaintext {} bytes -> sealed {} bytes", + 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) { + // 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)) + .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()) +} diff --git a/crates/cortexkit-push-seal/examples/kp.rs b/crates/cortexkit-push-seal/examples/kp.rs new file mode 100644 index 0000000..01a6d99 --- /dev/null +++ b/crates/cortexkit-push-seal/examples/kp.rs @@ -0,0 +1,11 @@ +// 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(); + let h = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::(); + println!("SK {}", h(&sk.to_bytes())); + println!("PK {}", h(&pk.to_bytes())); +} diff --git a/crates/cortexkit-push-seal/src/lib.rs b/crates/cortexkit-push-seal/src/lib.rs new file mode 100644 index 0000000..74af45e --- /dev/null +++ b/crates/cortexkit-push-seal/src/lib.rs @@ -0,0 +1,417 @@ +//! 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. +//! +//! # 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 +//! 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"); + } +} 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) { diff --git a/docs/lease-store-density.md b/docs/lease-store-density.md new file mode 100644 index 0000000..698d67c --- /dev/null +++ b/docs/lease-store-density.md @@ -0,0 +1,51 @@ +# 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), each watched by the seat that can see it + +- 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) — 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) — watched by SUBC + (crate owner) + +## 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. 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.