ADR-033
L0 foundational types: Cost, UUIDv7, TrustLevel, TokenUsage evolution
accepted · 2026-04-16 · L0 L0.5 · cites 11
0ADR-033: L0 foundational types: Cost, UUIDv7, TrustLevel, TokenUsage evolution #
Context #
L0 today contains exactly two crates: nika-error (error codes,
NIKA-XXX ranges) and nika-catalog (provider/model catalog data).
Neither holds the foundational value types — Cost, IDs, TrustLevel —
that L1 effect crates and L2 verb crates will need.
The kernel currently leaks several types that should live one layer below it.
TokenUsage (crates/nika-kernel-ai/src/provider.rs:247-256) has 4 fields:
#[non_exhaustive]pub struct TokenUsage { pub input_tokens: u64, pub output_tokens: u64, pub cache_read_tokens: Option<u64>, pub cache_write_tokens: Option<u64>,}This is missing several axes that real providers already bill for:
reasoning_tokens— Anthropic extended thinking, OpenAI o3.thinking_tokens— separate from reasoning; visible thinking trace.cache_creation_tokens— Anthropic prompt caching write side.image_tokens_in/image_tokens_out— vision input + image output.audio_tokens_in/audio_tokens_out— Gemini Live, GPT-4o audio.video_tokens— Gemini 1.5 Pro video.tool_use_tokens— some providers bill tool definitions separately.
MemoryId (crates/nika-kernel-ai/src/memory.rs:21-22) is MemoryId(u128)
with Display as mem-{hex32}. The u128 is opaque random; not
time-sortable. Memory queries that want chronological ordering need
a separate timestamp.
TaskId (crates/nika-kernel-core/src/types.rs) is TaskId(String) —
a bare string wrapper, no validation, no time ordering, no parse
guarantee.
Cost is currently cost_usd: Option<f64> on InferResponse
(crates/nika-kernel-ai/src/provider.rs:307). Floating-point accumulation
across thousands of API calls drifts; we cannot ship billing on f64.
TrustLevel is referenced by Shield error codes
(NIKA-380..389: nika-error/src/codes.rs) but currently passed as
hardcoded strings in workflow engine code. No enum exists in any
admitted crate.
L1 effect crates and L2 verb crates need a shared vocabulary for these
values. Putting them in nika-kernel (L0.5) creates an upward
dependency from L0 catalog → L0.5 kernel just to share an IdGenerator
target type. Putting them in L0 nika-error is the only stratum that
all higher layers already depend on.
Decision #
Add 23 foundational value types to `nika-error` (L0). All higher layers re-export or import directly. `nika-error` keeps its dependency budget: std + serde + thiserror + miette only.
The 23 types group into 9 modules:
- Module · Types · Why
- `cost` · `Cost(i128)` · nano-USD; arithmetic; Display `$X.XXXXXX`; no float drift
- `id` · 9 newtyped UUIDv7 IDs: `RunId`, `EventId`, `TraceId`, `SpanId`, `CorrelationId`, `WorkflowId`, `TenantId`, `ProviderId`, `ModelId` · type-safe ID separation; chronological ordering
- `trust` · `TrustLevel(u8)` lattice + `meet` / `join` / `is_at_least` · Shield gating becomes a compile-time invariant
- `retry` · `RetryConfig`, `ErrorCategory` · shared vocabulary for ADR-019's stratified retry
- `budget` · `BudgetDirective` enum · per-task budget enforcement (cost, tokens, time)
- `schema` · `EventSchemaVersion`, `TraceFormatVersion` · wire-compat versioning for events + traces
- `hash` · `Blake3Hash`, `ContentDigest`, `BlobRef` · content-addressed storage primitive
- `baggage` · W3C Baggage (max 64 entries, 8 KB cap) · OTel propagation across spans
- `resource` · OTel `Resource { service_name, attrs }` · OTel resource attributes, span enrichment
Migration of existing kernel types (lands in S1-C):
MemoryId(u128)→MemoryId(Uuid)UUIDv7-backed.Displaybecomesmem-{uuid}. Existing serde format moves to UUIDv7 string. Pre-S1-C callers will need to be re-codegened (mock IDs in tests included).TaskId(String)→TaskId(Uuid)UUIDv7-backed. Validation on parse.InferResponse.cost_usd: Option<f64>→cost: Option<Cost>.TokenUsage4-field → 18-field expansion (reasoning, thinking, cache_creation, image*, audio*, video, tool_use, ...). All new fieldsOption<u64>so producers opt in.#[non_exhaustive]makes the expansion non-breaking.
Cost arithmetic (concrete shape):
#[non_exhaustive]pub struct Cost(i128); // nano-USD: 1 USD = 1_000_000_000impl Cost { pub const ZERO: Cost; pub fn from_usd(usd: f64) -> Cost; // saturating pub fn from_nano_usd(n: i128) -> Cost; pub fn as_nano_usd(&self) -> i128; pub fn as_usd(&self) -> f64; // lossy display}impl Add for Cost { ... }impl Sub for Cost { ... }impl Mul<u64> for Cost { ... }impl Display for Cost { /* "$X.XXXXXX" */ }i128 gives a range of ±1.7e29 nano-USD = ±170 quintillion USD. Enough for any conceivable aggregation horizon.
TrustLevel lattice (concrete shape):
#[non_exhaustive]pub struct TrustLevel(u8); // Untrusted=0, LocalTrusted=128, Trusted=255impl TrustLevel { pub const UNTRUSTED: Self; pub const LOCAL_TRUSTED: Self; pub const TRUSTED: Self; pub fn meet(self, other: Self) -> Self; // min pub fn join(self, other: Self) -> Self; // max pub fn is_at_least(self, threshold: Self) -> bool;}impl Display for TrustLevel { ... }impl FromStr for TrustLevel { ... }impl Serialize / Deserialize for TrustLevel { ... }Lattice ops let Shield gate "this tool requires Trusted data" with a
compile-time-checkable contract.
UUIDv7 is chosen over UUIDv4 because:
- Time-sortable: natural chronological ordering for memory queries, event logs, task lookup.
- Random suffix preserves uniqueness at high creation rates.
- Wire-compatible with
uuid::Uuidecosystem.
nika-error adds uuid = { version = "1", features = ["v7", "serde"] }
to its dep list (S1-C lands the dep).
Consequences #
Positive #
- Single L0 vocabulary across all higher layers — no more "what's a TrustLevel" ambiguity.
- Cost arithmetic moves from f64 (drift) to i128 (exact) — billing becomes auditable.
- UUIDv7 IDs give natural chronological ordering for memory queries and event logs without a parallel timestamp field.
- TokenUsage expansion (4 → 18 fields) covers every billable axis
current providers expose. New axes land additively under
#[non_exhaustive]. - TrustLevel becomes a typed value with lattice ops; Shield can express
"tool X requires
at_least(LocalTrusted)" as code, not docs. RetryConfig+ErrorCategorygive ADR-019's three retry layers a shared vocabulary.
Negative #
- 23 new types is a bigger admit than typical for L0. Mitigated by topological ordering in S1-C (hash → schema → id → baggage → trust → retry → budget → cost → resource).
- Migrating
MemoryIdandTaskIdto UUIDv7 changes their wire format (existing snapshots in tests must be refreshed). Pre-emption: no production data exists yet (Diamond is orphan-branch, no users). nika-errorgains auuiddependency. Acceptable —uuidis std-only withserde+v7features; no transitive runtime.- TokenUsage 18-field struct is wider; serialization size grows.
Mitigated: all new fields are
Option<u64>and producers omit them by default.
Neutral #
nika-erroralready crosses 184 tests (largest crate by test count); adding ~70-90 more keeps it under reasonable test budget.- Some types (RetryConfig, BudgetDirective) live in
nika-erroreven though "error" is not literally what they describe. This is consistent withnika-errorbeing the de-facto "L0 foundation" crate. We may rename it tonika-foundationat v0.82+ if friction appears.
Evidence / Affected code #
crates/nika-kernel-ai/src/provider.rs:247-256— current 4-field TokenUsage.crates/nika-kernel-ai/src/provider.rs:307— currentcost_usd: Option<f64>.crates/nika-kernel-ai/src/memory.rs:21-22— currentMemoryId(u128).crates/nika-kernel-core/src/types.rs— currentTaskId(String).crates/nika-error/src/codes.rs— Shield NIKA-380..389 (TrustLevel consumers).docs/architecture/forward-compat-invariants.md— Pattern 2 (#[non_exhaustive]), INV-019 (per-cratenew()ctor), INV-025 (verb error enums#[non_exhaustive]).- ADR-019 — names
RetryConfig+ErrorCategoryas the shared retry vocabulary that lands here. - ADR-034 — L0.5 traits expansion that depends on these L0 types
(IdGenerator returns
Uuid, BillingSink takesCost).
Alternatives considered #
Alt A -- Keep `cost_usd` as `f64` #
"Floats are simpler." Rejected: f64 accumulation across thousands of provider calls drifts in the 7th decimal. Auditable billing is not negotiable. i128 nano-USD is exact and trivially comparable.
Alt B -- UUIDv4 (random only) #
"Standard, well-supported." Rejected: not time-sortable. Memory queries that want "recent first" need a separate timestamp; UUIDv7 collapses both into one column. UUIDv7 is also already an RFC (9562, 2024-05).
Alt C -- Types stay in `nika-kernel` #
"Don't add a new home."
Rejected: L1 crates (nika-http, nika-fs) need Cost and TrustLevel
but must not depend on L0.5 kernel (per ADR-006 layering and
scripts/ci/check-layering.sh). L0 nika-error is the only
common ancestor.
Alt D -- Separate `nika-types` or `nika-foundation` crate #
"Logically distinct from errors."
Rejected: would proliferate L0 crates without functional gain.
nika-error already has zero deps beyond std + serde + thiserror;
adding types keeps the dep graph flat. The name is suboptimal but
not load-bearing — we may rename later.
Alt E -- Decimal floats (e.g., `decimal_rs::Decimal`) #
"Exact arithmetic without going to integer." Rejected: brings a non-trivial third-party dep into L0 (the strictest layer). i128 gives the same exactness with zero deps.
Related #
- ADR-005 — error hierarchy. ADR-033 lives in the same crate
(
nika-error) but adds value types alongside the existing error codes. - ADR-007 — forward-compat invariants. Pattern 2
(
#[non_exhaustive]) is applied to every new struct/enum. - ADR-019 — retry + timeout ownership. ADR-019 named
RetryConfig+ErrorCategoryas the shared vocabulary; ADR-033 is where they actually live. - ADR-034 — L0.5 kernel traits expansion. ADR-034's traits (IdGenerator, BillingSink, ...) consume ADR-033's types.
Notes #
Follow-ups (S1-C and beyond):
- Migrate
MemoryId(u128)→MemoryId(Uuid)UUIDv7-backed (S1-C). - Migrate
TaskId(String)→TaskId(Uuid)UUIDv7-backed (S1-C). - Replace
InferResponse.cost_usd: Option<f64>withcost: Option<Cost>(S1-C). - Wire
TrustLevelinto Shield gating when verb-* crates ship. - Consider renaming
nika-error→nika-foundationat v0.82+ if the "errors crate that holds value types" framing causes friction.
read at v0.107.0 · the decision record ships with the engine