diff --git a/Cargo.lock b/Cargo.lock
index d3573cc..265e087 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2817,6 +2817,26 @@ dependencies = [
"thiserror 2.0.18",
]
+[[package]]
+name = "nunchi-costs"
+version = "2026.6.0"
+dependencies = [
+ "async-trait",
+ "bytes",
+ "commonware-codec",
+ "commonware-cryptography",
+ "commonware-formatting",
+ "commonware-macros",
+ "futures",
+ "jsonrpsee",
+ "nunchi-common",
+ "nunchi-crypto",
+ "nunchi-rpc",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
+]
+
[[package]]
name = "nunchi-crypto"
version = "2026.6.0"
diff --git a/Cargo.toml b/Cargo.toml
index 1ceb164..1d55acf 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -3,6 +3,7 @@ members = [
"authority",
"bridge",
"common",
+ "costs",
"chain",
"clob",
"coins",
@@ -43,6 +44,7 @@ nunchi-bridge = { version = "2026.6.0", path = "bridge" }
nunchi-chain = { version = "2026.6.0", path = "chain" }
nunchi-clob = { version = "2026.6.0", path = "clob" }
nunchi-common = { version = "2026.6.0", path = "common" }
+nunchi-costs = { version = "2026.6.0", path = "costs" }
nunchi-crypto = { version = "2026.6.0", path = "crypto" }
nunchi-dkg = { version = "2026.6.0", path = "dkg" }
nunchi-mempool = { version = "2026.6.0", path = "mempool" }
diff --git a/costs/Cargo.toml b/costs/Cargo.toml
new file mode 100644
index 0000000..3307fef
--- /dev/null
+++ b/costs/Cargo.toml
@@ -0,0 +1,31 @@
+[package]
+name = "nunchi-costs"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+description = "Custodial account and usage-credit accounting primitives for Nunchi chains."
+
+[lints]
+workspace = true
+
+[features]
+default = []
+rpc = ["dep:jsonrpsee", "dep:nunchi-rpc"]
+
+[dependencies]
+async-trait = { workspace = true }
+bytes = { workspace = true }
+commonware-codec = { workspace = true }
+commonware-cryptography = { workspace = true }
+commonware-formatting = { workspace = true }
+commonware-macros = { workspace = true }
+jsonrpsee = { workspace = true, optional = true }
+nunchi-common = { workspace = true }
+nunchi-crypto = { workspace = true }
+nunchi-rpc = { workspace = true, optional = true }
+serde = { workspace = true }
+serde_json = { workspace = true }
+thiserror = { workspace = true }
+
+[dev-dependencies]
+futures = { workspace = true }
diff --git a/costs/README.md b/costs/README.md
new file mode 100644
index 0000000..2461fe9
--- /dev/null
+++ b/costs/README.md
@@ -0,0 +1,81 @@
+# nunchi-costs
+
+`nunchi-costs` is an account-scoped custodial credit-accounting primitive for
+Nunchi chains. It has no dependency on any product UI, billing provider,
+event schema, data warehouse, accounting system, or client wallet.
+
+The module owns only opaque `account_id` account state:
+
+- active/suspended accounts, available credit, and reserved credit;
+- backend writer capabilities (`Admin`, `Ingest`, `Billing`, `Adjustment`);
+- idempotent top-ups, grants, reversals, normalized spend, and reservations;
+- a registry for untracked/shared sources that has no automatic debit path;
+- staged, atomic effective-at rate-card changes with global and exact-account
+ precedence; and
+- finality-outbox envelopes derived after a transaction is finalized.
+
+All chain writes require an allowlisted backend signer. Client applications and
+end users never receive a chain key or submit a raw transaction.
+
+## Why grants and paid credits are separate
+
+They are the same unit of spend, but they are not the same economic claim.
+The ledger therefore represents each ingress as a distinct **credit lot** rather
+than keeping only one aggregate balance:
+
+- A **paid credit lot** comes from a payment rail. It retains its `rail_ref`,
+ purchase amount, terms version, and any bonus credits. Unused paid credit can
+ be refunded only to that originating rail.
+- A **grant credit lot** comes from a plan entitlement, trial, promotion, or
+ goodwill decision. It retains its reason, period, approval, audit reference,
+ and expiry. It is non-refundable.
+
+This distinction is necessary even though both lots are denominated in
+credits. A single undifferentiated balance could not answer whether a refund is
+permitted, which credits expire, or why a particular amount was issued and
+consumed. It would also allow promotional value to be accidentally returned as
+cash-equivalent value. The account read model exposes paid and grant balances
+separately while the spending interface treats both as one spendable pool.
+
+### Stored-value flow
+
+```mermaid
+flowchart LR
+ Payment["Payment rail"] -->|"Billing writer: top-up"| Paid["Paid credit lot\nrail + terms + amount"]
+ Program["Plan / trial / promotion"] -->|"Adjustment writer: grant"| Grant["Grant credit lot\nreason + period + expiry + approval"]
+ Paid --> Pool["Account's spendable credits"]
+ Grant --> Pool
+ Pool -->|"Ingest writer: reserve or record spend"| Allocate["Deterministic allocation\ngrants first, then paid lots"]
+ Allocate --> Finality["Finalized chain transaction"]
+ Finality --> Outbox["Post-finality event\nwarehouse / accounting / client projections"]
+ Paid -->|"Unused and not reserved"| Refund["Adjustment writer: refund\nto original payment rail"]
+```
+
+For a spend or reservation, the ledger first selects unexpired grants (earliest
+expiry first) and then paid lots. The exact allocations are stored with the
+spend or reservation. That makes the result deterministic and preserves the
+remaining refundable paid balance. A reservation keeps the selected lots on
+hold; it may settle into a spend or be released/expired, after which the same
+lots become available again.
+
+The authorization boundary mirrors these economic responsibilities: `Billing`
+can create paid top-ups, `Adjustment` can issue grants and process refunds, and
+`Ingest` can reserve or record product usage. Each command and post-finality
+event is idempotent, so retries cannot mint or spend a lot twice.
+
+## Local proof
+
+```sh
+cargo test -p nunchi-costs
+```
+
+The test suite validates rate activation, payment-rail top-ups, reservation and
+settlement, duplicate-event suppression, post-finality journal facts, stored
+value provenance, refunds, and untracked-cost zero-debit behavior.
+
+## Deliberate boundaries
+
+This module is not a billing-provider integration. External event decoding,
+pricing-policy approval, user interfaces, credentials, and production adapters
+remain outside the ledger. Applications must preserve the module's idempotency,
+authorization, provenance, and finality semantics.
diff --git a/costs/src/db.rs b/costs/src/db.rs
new file mode 100644
index 0000000..755f7c2
--- /dev/null
+++ b/costs/src/db.rs
@@ -0,0 +1,688 @@
+use async_trait::async_trait;
+use commonware_codec::{Encode, EncodeSize, Read, ReadExt, Write};
+use commonware_cryptography::sha256::Digest;
+use nunchi_common::{Address, Namespace, StateStore};
+
+use crate::{
+ CostsError, LedgerMutationV1, RateCardChangeSet, RateCardEntry, Reservation, CreditAccount, AccountProfile,
+ StatusHistoryEntry, UntrackedSourceV1, WriterRole,
+ StoredValueLedger,
+ COSTS_NAMESPACE,
+};
+
+const NS: Namespace = Namespace::new(COSTS_NAMESPACE);
+
+#[repr(u8)]
+#[derive(Clone, Copy)]
+enum Table {
+ Nonce = 0,
+ Account = 1,
+ Writer = 2,
+ Event = 3,
+ Rail = 4,
+ Reservation = 5,
+ UntrackedSource = 6,
+ ActiveRate = 7,
+ StagedRate = 8,
+ RateChangeSet = 9,
+ Profile = 10,
+ StatusHistory = 11,
+ StatusHistoryCount = 12,
+ AccountWriter = 13,
+ OnboardingRef = 14,
+ Journal = 15,
+ JournalCount = 16,
+ RateHistory = 17,
+ RateHistoryCount = 18,
+ ActivationEpoch = 19,
+ /// Bounded, append-only onboarding order. This is deliberately a list of
+ /// opaque `account_id`s only; it is used to materialize approved global rate
+ /// defaults to already-onboarded custodial accounts.
+ AccountRegistryCount = 20,
+ AccountRegistry = 21,
+ GlobalRateRegistryCount = 22,
+ GlobalRateRegistry = 23,
+ /// Whether a account/key's active materialization came from a global default
+ /// (rather than a account-owned override). This keeps subsequent global
+ /// propagation from treating its own previous materialization as an
+ /// override.
+ GlobalRateMaterialization = 24,
+ /// Per-history-revision provenance. A derived global snapshot must never
+ /// masquerade as an explicit account override during scoped lookup.
+ RateHistoryGlobalMaterialization = 25,
+ StoredValueLedger = 26,
+}
+
+impl From
for u8 {
+ fn from(table: Table) -> Self {
+ table as Self
+ }
+}
+
+fn encoded(value: &T) -> Vec {
+ value.encode().as_ref().to_vec()
+}
+
+fn decoded>(bytes: &[u8]) -> Result {
+ let mut buf = bytes;
+ T::read(&mut buf).map_err(|err| CostsError::Storage(err.to_string()))
+}
+
+fn decode_fingerprint(bytes: &[u8]) -> Result {
+ String::from_utf8(bytes.to_vec())
+ .map_err(|_| CostsError::Storage("invalid idempotency fingerprint".to_string()))
+}
+
+fn nonce_key(account: &Address) -> Digest {
+ NS.key(Table::Nonce, account.encode().as_ref())
+}
+
+fn account_key(account_id: &str) -> Digest {
+ NS.key(Table::Account, account_id.as_bytes())
+}
+
+fn writer_key(role: WriterRole, writer: &Address) -> Digest {
+ let mut key = Vec::with_capacity(1 + writer.encode_size());
+ role.write(&mut key);
+ writer.write(&mut key);
+ NS.key(Table::Writer, &key)
+}
+
+fn account_writer_key(role: WriterRole, account_id: &str, writer: &Address) -> Digest {
+ let mut key = Vec::with_capacity(1 + account_id.len() + writer.encode_size() + 2);
+ role.write(&mut key);
+ key.extend_from_slice(account_id.as_bytes());
+ key.push(0);
+ writer.write(&mut key);
+ NS.key(Table::AccountWriter, &key)
+}
+
+fn external_ref_key(external_ref: &str) -> Digest {
+ NS.key(Table::OnboardingRef, external_ref.as_bytes())
+}
+
+fn event_key(event_id: &str) -> Digest {
+ NS.key(Table::Event, event_id.as_bytes())
+}
+
+fn profile_key(account_id: &str) -> Digest {
+ NS.key(Table::Profile, account_id.as_bytes())
+}
+
+fn status_history_count_key(account_id: &str) -> Digest {
+ NS.key(Table::StatusHistoryCount, account_id.as_bytes())
+}
+
+fn status_history_key(account_id: &str, sequence: u64) -> Digest {
+ let mut material = account_id.as_bytes().to_vec();
+ material.extend_from_slice(&sequence.to_be_bytes());
+ NS.key(Table::StatusHistory, &material)
+}
+
+fn rail_key(rail_ref: &str) -> Digest {
+ NS.key(Table::Rail, rail_ref.as_bytes())
+}
+
+fn reservation_key(reservation_id: &str) -> Digest {
+ NS.key(Table::Reservation, reservation_id.as_bytes())
+}
+
+fn untracked_source_key(source_id: &str) -> Digest {
+ NS.key(Table::UntrackedSource, source_id.as_bytes())
+}
+
+fn rate_material(account_id: &str, event_category: &str, task_key: &str) -> Vec {
+ let mut material = Vec::with_capacity(account_id.len() + event_category.len() + task_key.len() + 2);
+ material.extend_from_slice(account_id.as_bytes());
+ material.push(0);
+ material.extend_from_slice(event_category.as_bytes());
+ material.push(0);
+ material.extend_from_slice(task_key.as_bytes());
+ material
+}
+
+fn active_rate_key(account_id: &str, event_category: &str, task_key: &str) -> Digest {
+ NS.key(Table::ActiveRate, &rate_material(account_id, event_category, task_key))
+}
+
+fn staged_rate_key(change_set_id: &str, entry: &RateCardEntry) -> Digest {
+ let mut material = change_set_id.as_bytes().to_vec();
+ material.push(0);
+ material.extend_from_slice(&rate_material(&entry.account_id, &entry.event_category, &entry.task_key));
+ NS.key(Table::StagedRate, &material)
+}
+
+fn rate_change_set_key(change_set_id: &str) -> Digest {
+ NS.key(Table::RateChangeSet, change_set_id.as_bytes())
+}
+
+fn activation_epoch_key() -> Digest {
+ NS.key(Table::ActivationEpoch, b"global")
+}
+
+fn account_registry_count_key() -> Digest {
+ NS.key(Table::AccountRegistryCount, b"all")
+}
+
+fn account_registry_key(sequence: u64) -> Digest {
+ NS.key(Table::AccountRegistry, &sequence.to_be_bytes())
+}
+
+fn global_rate_registry_count_key() -> Digest {
+ NS.key(Table::GlobalRateRegistryCount, b"all")
+}
+
+fn global_rate_registry_key(sequence: u64) -> Digest {
+ NS.key(Table::GlobalRateRegistry, &sequence.to_be_bytes())
+}
+
+fn global_rate_materialization_key(account_id: &str, event_category: &str, task_key: &str) -> Digest {
+ NS.key(Table::GlobalRateMaterialization, &rate_material(account_id, event_category, task_key))
+}
+
+fn rate_history_count_key(account_id: &str, event_category: &str, task_key: &str) -> Digest {
+ NS.key(Table::RateHistoryCount, &rate_material(account_id, event_category, task_key))
+}
+
+fn rate_history_global_materialization_key(account_id: &str, event_category: &str, task_key: &str, sequence: u64) -> Digest {
+ let mut material = rate_material(account_id, event_category, task_key);
+ material.extend_from_slice(&sequence.to_be_bytes());
+ NS.key(Table::RateHistoryGlobalMaterialization, &material)
+}
+
+fn rate_history_key(account_id: &str, event_category: &str, task_key: &str, sequence: u64) -> Digest {
+ let mut material = rate_material(account_id, event_category, task_key);
+ material.extend_from_slice(&sequence.to_be_bytes());
+ NS.key(Table::RateHistory, &material)
+}
+
+fn journal_count_key() -> Digest {
+ NS.key(Table::JournalCount, b"all")
+}
+
+fn journal_key(sequence: u64) -> Digest {
+ NS.key(Table::Journal, &sequence.to_be_bytes())
+}
+
+fn stored_value_ledger_key() -> Digest { NS.key(Table::StoredValueLedger, b"v2") }
+
+/// Typed state access required by [`crate::CostsLedger`].
+#[async_trait]
+pub trait CostsDB {
+ async fn nonce(&self, account: &Address) -> Result;
+ fn set_nonce(&mut self, account: &Address, nonce: u64);
+
+ async fn account(&self, account_id: &str) -> Result, CostsError>;
+ fn set_account(&mut self, account_id: &str, account: CreditAccount);
+
+ async fn profile(&self, account_id: &str) -> Result , CostsError>;
+ fn set_profile(&mut self, profile: AccountProfile);
+ async fn status_history_count(&self, account_id: &str) -> Result;
+ fn set_status_history_count(&mut self, account_id: &str, count: u64);
+ async fn status_history(
+ &self,
+ account_id: &str,
+ sequence: u64,
+ ) -> Result, CostsError>;
+ fn set_status_history(&mut self, account_id: &str, entry: StatusHistoryEntry);
+
+ async fn writer(&self, role: WriterRole, writer: &Address) -> Result;
+ fn set_writer(&mut self, role: WriterRole, writer: &Address, enabled: bool);
+ async fn account_writer(
+ &self,
+ role: WriterRole,
+ account_id: &str,
+ writer: &Address,
+ ) -> Result;
+ fn set_account_writer(&mut self, role: WriterRole, account_id: &str, writer: &Address, enabled: bool);
+
+ async fn onboarding_account(&self, external_ref: &str) -> Result, CostsError>;
+ fn set_onboarding_account(&mut self, external_ref: &str, account_id: &str);
+ /// Return the number of programmatically-onboarded account accounts. The
+ /// ledger bounds every scan of this registry before it writes state.
+ async fn account_registry_count(&self) -> Result;
+ fn set_account_registry_count(&mut self, count: u64);
+ async fn account_registry_account(&self, sequence: u64) -> Result, CostsError>;
+ fn set_account_registry_account(&mut self, sequence: u64, account_id: &str);
+
+ async fn global_rate_registry_count(&self) -> Result;
+ fn set_global_rate_registry_count(&mut self, count: u64);
+ async fn global_rate_registry_entry(&self, sequence: u64) -> Result, CostsError>;
+ fn set_global_rate_registry_entry(&mut self, sequence: u64, entry: RateCardEntry);
+ async fn global_rate_materialized(&self, account_id: &str, event_category: &str, task_key: &str) -> Result;
+ fn set_global_rate_materialized(&mut self, account_id: &str, event_category: &str, task_key: &str, materialized: bool);
+
+ async fn event_fingerprint(&self, event_id: &str) -> Result, CostsError>;
+ fn mark_event(&mut self, event_id: &str, fingerprint: &str);
+
+ async fn rail_fingerprint(&self, rail_ref: &str) -> Result , CostsError>;
+ fn mark_rail(&mut self, rail_ref: &str, fingerprint: &str);
+
+ async fn reservation(&self, reservation_id: &str) -> Result , CostsError>;
+ fn set_reservation(&mut self, reservation: Reservation);
+
+ async fn untracked_source(&self, source_id: &str) -> Result , CostsError>;
+ fn set_untracked_source(&mut self, source: UntrackedSourceV1);
+
+ async fn active_rate(
+ &self,
+ account_id: &str,
+ event_category: &str,
+ task_key: &str,
+ ) -> Result , CostsError>;
+ fn set_active_rate(&mut self, entry: RateCardEntry);
+ async fn rate_history_count(&self, account_id: &str, event_category: &str, task_key: &str) -> Result;
+ fn set_rate_history_count(&mut self, account_id: &str, event_category: &str, task_key: &str, count: u64);
+ async fn rate_history_entry(&self, account_id: &str, event_category: &str, task_key: &str, sequence: u64) -> Result, CostsError>;
+ fn set_rate_history_entry(&mut self, entry: RateCardEntry, sequence: u64);
+ async fn rate_history_global_materialization(&self, account_id: &str, event_category: &str, task_key: &str, sequence: u64) -> Result;
+ fn set_rate_history_global_materialization(&mut self, account_id: &str, event_category: &str, task_key: &str, sequence: u64, materialized: bool);
+
+ async fn staged_rate(
+ &self,
+ change_set_id: &str,
+ entry: &RateCardEntry,
+ ) -> Result, CostsError>;
+ fn set_staged_rate(&mut self, change_set_id: &str, entry: RateCardEntry);
+
+ async fn rate_change_set(
+ &self,
+ change_set_id: &str,
+ ) -> Result , CostsError>;
+ fn set_rate_change_set(&mut self, change_set: RateCardChangeSet);
+ /// Global high-watermark for approved rate activations. A change set may
+ /// never reuse or move this monotonic control-plane epoch backwards.
+ async fn activation_epoch_high_watermark(&self) -> Result;
+ fn set_activation_epoch_high_watermark(&mut self, epoch: u64);
+
+ async fn journal_count(&self) -> Result;
+ fn set_journal_count(&mut self, count: u64);
+ async fn journal_entry(&self, sequence: u64) -> Result, CostsError>;
+ fn set_journal_entry(&mut self, entry: LedgerMutationV1);
+
+ async fn stored_value_ledger(&self) -> Result;
+ fn set_stored_value_ledger(&mut self, ledger: StoredValueLedger);
+}
+
+#[async_trait]
+impl CostsDB for S {
+ async fn nonce(&self, account: &Address) -> Result {
+ match StateStore::get(self, &nonce_key(account))
+ .await
+ .map_err(|err| CostsError::Storage(err.to_string()))?
+ {
+ Some(bytes) => decoded(&bytes),
+ None => Ok(0),
+ }
+ }
+
+ fn set_nonce(&mut self, account: &Address, nonce: u64) {
+ StateStore::set(self, nonce_key(account), encoded(&nonce));
+ }
+
+ async fn account(&self, account_id: &str) -> Result, CostsError> {
+ match StateStore::get(self, &account_key(account_id))
+ .await
+ .map_err(|err| CostsError::Storage(err.to_string()))?
+ {
+ Some(bytes) => decoded(&bytes).map(Some),
+ None => Ok(None),
+ }
+ }
+
+ fn set_account(&mut self, account_id: &str, account: CreditAccount) {
+ StateStore::set(self, account_key(account_id), encoded(&account));
+ }
+
+ async fn profile(&self, account_id: &str) -> Result , CostsError> {
+ match StateStore::get(self, &profile_key(account_id))
+ .await
+ .map_err(|err| CostsError::Storage(err.to_string()))?
+ {
+ Some(bytes) => decoded(&bytes).map(Some),
+ None => Ok(None),
+ }
+ }
+
+ fn set_profile(&mut self, profile: AccountProfile) {
+ StateStore::set(self, profile_key(&profile.account_id), encoded(&profile));
+ }
+
+ async fn status_history_count(&self, account_id: &str) -> Result {
+ match StateStore::get(self, &status_history_count_key(account_id))
+ .await
+ .map_err(|err| CostsError::Storage(err.to_string()))?
+ {
+ Some(bytes) => decoded(&bytes),
+ None => Ok(0),
+ }
+ }
+
+ fn set_status_history_count(&mut self, account_id: &str, count: u64) {
+ StateStore::set(self, status_history_count_key(account_id), encoded(&count));
+ }
+
+ async fn status_history(
+ &self,
+ account_id: &str,
+ sequence: u64,
+ ) -> Result, CostsError> {
+ match StateStore::get(self, &status_history_key(account_id, sequence))
+ .await
+ .map_err(|err| CostsError::Storage(err.to_string()))?
+ {
+ Some(bytes) => decoded(&bytes).map(Some),
+ None => Ok(None),
+ }
+ }
+
+ fn set_status_history(&mut self, account_id: &str, entry: StatusHistoryEntry) {
+ StateStore::set(self, status_history_key(account_id, entry.sequence), encoded(&entry));
+ }
+
+ async fn writer(&self, role: WriterRole, writer: &Address) -> Result {
+ match StateStore::get(self, &writer_key(role, writer))
+ .await
+ .map_err(|err| CostsError::Storage(err.to_string()))?
+ {
+ Some(bytes) => decoded(&bytes),
+ None => Ok(false),
+ }
+ }
+
+ fn set_writer(&mut self, role: WriterRole, writer: &Address, enabled: bool) {
+ StateStore::set(self, writer_key(role, writer), encoded(&enabled));
+ }
+
+ async fn account_writer(
+ &self,
+ role: WriterRole,
+ account_id: &str,
+ writer: &Address,
+ ) -> Result {
+ match StateStore::get(self, &account_writer_key(role, account_id, writer))
+ .await
+ .map_err(|err| CostsError::Storage(err.to_string()))?
+ {
+ Some(bytes) => decoded(&bytes),
+ None => Ok(false),
+ }
+ }
+
+ fn set_account_writer(&mut self, role: WriterRole, account_id: &str, writer: &Address, enabled: bool) {
+ StateStore::set(self, account_writer_key(role, account_id, writer), encoded(&enabled));
+ }
+
+ async fn onboarding_account(&self, external_ref: &str) -> Result, CostsError> {
+ match StateStore::get(self, &external_ref_key(external_ref))
+ .await
+ .map_err(|err| CostsError::Storage(err.to_string()))?
+ {
+ Some(bytes) => decode_fingerprint(&bytes).map(Some),
+ None => Ok(None),
+ }
+ }
+
+ fn set_onboarding_account(&mut self, external_ref: &str, account_id: &str) {
+ StateStore::set(self, external_ref_key(external_ref), account_id.as_bytes().to_vec());
+ }
+
+ async fn account_registry_count(&self) -> Result {
+ match StateStore::get(self, &account_registry_count_key())
+ .await
+ .map_err(|err| CostsError::Storage(err.to_string()))?
+ {
+ Some(bytes) => decoded(&bytes),
+ None => Ok(0),
+ }
+ }
+
+ fn set_account_registry_count(&mut self, count: u64) {
+ StateStore::set(self, account_registry_count_key(), encoded(&count));
+ }
+
+ async fn account_registry_account(&self, sequence: u64) -> Result, CostsError> {
+ match StateStore::get(self, &account_registry_key(sequence))
+ .await
+ .map_err(|err| CostsError::Storage(err.to_string()))?
+ {
+ Some(bytes) => String::from_utf8(bytes)
+ .map(Some)
+ .map_err(|_| CostsError::Storage("invalid account registry entry".to_string())),
+ None => Ok(None),
+ }
+ }
+
+ fn set_account_registry_account(&mut self, sequence: u64, account_id: &str) {
+ StateStore::set(self, account_registry_key(sequence), account_id.as_bytes().to_vec());
+ }
+
+ async fn global_rate_registry_count(&self) -> Result {
+ match StateStore::get(self, &global_rate_registry_count_key()).await.map_err(|err| CostsError::Storage(err.to_string()))? {
+ Some(bytes) => decoded(&bytes), None => Ok(0),
+ }
+ }
+
+ fn set_global_rate_registry_count(&mut self, count: u64) {
+ StateStore::set(self, global_rate_registry_count_key(), encoded(&count));
+ }
+
+ async fn global_rate_registry_entry(&self, sequence: u64) -> Result, CostsError> {
+ match StateStore::get(self, &global_rate_registry_key(sequence)).await.map_err(|err| CostsError::Storage(err.to_string()))? {
+ Some(bytes) => decoded(&bytes).map(Some), None => Ok(None),
+ }
+ }
+
+ fn set_global_rate_registry_entry(&mut self, sequence: u64, entry: RateCardEntry) {
+ StateStore::set(self, global_rate_registry_key(sequence), encoded(&entry));
+ }
+
+ async fn global_rate_materialized(&self, account_id: &str, event_category: &str, task_key: &str) -> Result {
+ match StateStore::get(self, &global_rate_materialization_key(account_id, event_category, task_key)).await.map_err(|err| CostsError::Storage(err.to_string()))? {
+ Some(bytes) => decoded(&bytes), None => Ok(false),
+ }
+ }
+
+ fn set_global_rate_materialized(&mut self, account_id: &str, event_category: &str, task_key: &str, materialized: bool) {
+ StateStore::set(self, global_rate_materialization_key(account_id, event_category, task_key), encoded(&materialized));
+ }
+
+ async fn event_fingerprint(&self, event_id: &str) -> Result, CostsError> {
+ match StateStore::get(self, &event_key(event_id))
+ .await
+ .map_err(|err| CostsError::Storage(err.to_string()))?
+ {
+ Some(bytes) => decode_fingerprint(&bytes).map(Some),
+ None => Ok(None),
+ }
+ }
+
+ fn mark_event(&mut self, event_id: &str, fingerprint: &str) {
+ StateStore::set(self, event_key(event_id), fingerprint.as_bytes().to_vec());
+ }
+
+ async fn rail_fingerprint(&self, rail_ref: &str) -> Result , CostsError> {
+ match StateStore::get(self, &rail_key(rail_ref))
+ .await
+ .map_err(|err| CostsError::Storage(err.to_string()))?
+ {
+ Some(bytes) => decode_fingerprint(&bytes).map(Some),
+ None => Ok(None),
+ }
+ }
+
+ fn mark_rail(&mut self, rail_ref: &str, fingerprint: &str) {
+ StateStore::set(self, rail_key(rail_ref), fingerprint.as_bytes().to_vec());
+ }
+
+ async fn reservation(&self, reservation_id: &str) -> Result , CostsError> {
+ match StateStore::get(self, &reservation_key(reservation_id))
+ .await
+ .map_err(|err| CostsError::Storage(err.to_string()))?
+ {
+ Some(bytes) => decoded(&bytes).map(Some),
+ None => Ok(None),
+ }
+ }
+
+ fn set_reservation(&mut self, reservation: Reservation) {
+ StateStore::set(self, reservation_key(&reservation.reservation_id), encoded(&reservation));
+ }
+
+ async fn untracked_source(&self, source_id: &str) -> Result , CostsError> {
+ match StateStore::get(self, &untracked_source_key(source_id))
+ .await
+ .map_err(|err| CostsError::Storage(err.to_string()))?
+ {
+ Some(bytes) => decoded(&bytes).map(Some),
+ None => Ok(None),
+ }
+ }
+
+ fn set_untracked_source(&mut self, source: UntrackedSourceV1) {
+ StateStore::set(self, untracked_source_key(&source.source_id), encoded(&source));
+ }
+
+ async fn active_rate(
+ &self,
+ account_id: &str,
+ event_category: &str,
+ task_key: &str,
+ ) -> Result , CostsError> {
+ match StateStore::get(self, &active_rate_key(account_id, event_category, task_key))
+ .await
+ .map_err(|err| CostsError::Storage(err.to_string()))?
+ {
+ Some(bytes) => decoded(&bytes).map(Some),
+ None => Ok(None),
+ }
+ }
+
+ fn set_active_rate(&mut self, entry: RateCardEntry) {
+ StateStore::set(
+ self,
+ active_rate_key(&entry.account_id, &entry.event_category, &entry.task_key),
+ encoded(&entry),
+ );
+ }
+
+ async fn rate_history_count(&self, account_id: &str, event_category: &str, task_key: &str) -> Result {
+ match StateStore::get(self, &rate_history_count_key(account_id, event_category, task_key)).await.map_err(|err| CostsError::Storage(err.to_string()))? {
+ Some(bytes) => decoded(&bytes), None => Ok(0),
+ }
+ }
+
+ fn set_rate_history_count(&mut self, account_id: &str, event_category: &str, task_key: &str, count: u64) {
+ StateStore::set(self, rate_history_count_key(account_id, event_category, task_key), encoded(&count));
+ }
+
+ async fn rate_history_entry(&self, account_id: &str, event_category: &str, task_key: &str, sequence: u64) -> Result, CostsError> {
+ match StateStore::get(self, &rate_history_key(account_id, event_category, task_key, sequence)).await.map_err(|err| CostsError::Storage(err.to_string()))? {
+ Some(bytes) => decoded(&bytes).map(Some), None => Ok(None),
+ }
+ }
+
+ fn set_rate_history_entry(&mut self, entry: RateCardEntry, sequence: u64) {
+ StateStore::set(self, rate_history_key(&entry.account_id, &entry.event_category, &entry.task_key, sequence), encoded(&entry));
+ }
+
+ async fn rate_history_global_materialization(&self, account_id: &str, event_category: &str, task_key: &str, sequence: u64) -> Result {
+ match StateStore::get(self, &rate_history_global_materialization_key(account_id, event_category, task_key, sequence)).await.map_err(|err| CostsError::Storage(err.to_string()))? {
+ Some(bytes) => decoded(&bytes), None => Ok(false),
+ }
+ }
+
+ fn set_rate_history_global_materialization(&mut self, account_id: &str, event_category: &str, task_key: &str, sequence: u64, materialized: bool) {
+ StateStore::set(self, rate_history_global_materialization_key(account_id, event_category, task_key, sequence), encoded(&materialized));
+ }
+
+ async fn staged_rate(
+ &self,
+ change_set_id: &str,
+ entry: &RateCardEntry,
+ ) -> Result, CostsError> {
+ match StateStore::get(self, &staged_rate_key(change_set_id, entry))
+ .await
+ .map_err(|err| CostsError::Storage(err.to_string()))?
+ {
+ Some(bytes) => decoded(&bytes).map(Some),
+ None => Ok(None),
+ }
+ }
+
+ fn set_staged_rate(&mut self, change_set_id: &str, entry: RateCardEntry) {
+ StateStore::set(self, staged_rate_key(change_set_id, &entry), encoded(&entry));
+ }
+
+ async fn rate_change_set(
+ &self,
+ change_set_id: &str,
+ ) -> Result , CostsError> {
+ match StateStore::get(self, &rate_change_set_key(change_set_id))
+ .await
+ .map_err(|err| CostsError::Storage(err.to_string()))?
+ {
+ Some(bytes) => decoded(&bytes).map(Some),
+ None => Ok(None),
+ }
+ }
+
+ fn set_rate_change_set(&mut self, change_set: RateCardChangeSet) {
+ StateStore::set(
+ self,
+ rate_change_set_key(&change_set.change_set_id),
+ encoded(&change_set),
+ );
+ }
+
+ async fn activation_epoch_high_watermark(&self) -> Result {
+ match StateStore::get(self, &activation_epoch_key()).await.map_err(|err| CostsError::Storage(err.to_string()))? {
+ Some(bytes) => decoded(&bytes),
+ None => Ok(0),
+ }
+ }
+
+ fn set_activation_epoch_high_watermark(&mut self, epoch: u64) {
+ StateStore::set(self, activation_epoch_key(), encoded(&epoch));
+ }
+
+ async fn journal_count(&self) -> Result {
+ match StateStore::get(self, &journal_count_key())
+ .await
+ .map_err(|err| CostsError::Storage(err.to_string()))?
+ {
+ Some(bytes) => decoded(&bytes),
+ None => Ok(0),
+ }
+ }
+
+ fn set_journal_count(&mut self, count: u64) {
+ StateStore::set(self, journal_count_key(), encoded(&count));
+ }
+
+ async fn journal_entry(&self, sequence: u64) -> Result, CostsError> {
+ match StateStore::get(self, &journal_key(sequence))
+ .await
+ .map_err(|err| CostsError::Storage(err.to_string()))?
+ {
+ Some(bytes) => decoded(&bytes).map(Some),
+ None => Ok(None),
+ }
+ }
+
+ fn set_journal_entry(&mut self, entry: LedgerMutationV1) {
+ StateStore::set(self, journal_key(entry.sequence), encoded(&entry));
+ }
+
+ async fn stored_value_ledger(&self) -> Result {
+ match StateStore::get(self, &stored_value_ledger_key()).await.map_err(|err| CostsError::Storage(err.to_string()))? {
+ Some(bytes) => serde_json::from_slice(&bytes).map_err(|err| CostsError::Storage(err.to_string())),
+ None => Ok(StoredValueLedger::default()),
+ }
+ }
+
+ fn set_stored_value_ledger(&mut self, ledger: StoredValueLedger) {
+ let bytes = serde_json::to_vec(&ledger).expect("stored value ledger serializes");
+ StateStore::set(self, stored_value_ledger_key(), bytes);
+ }
+}
diff --git a/costs/src/genesis.rs b/costs/src/genesis.rs
new file mode 100644
index 0000000..b4850bd
--- /dev/null
+++ b/costs/src/genesis.rs
@@ -0,0 +1,34 @@
+use commonware_codec::DecodeExt;
+use commonware_formatting::from_hex;
+use nunchi_common::Address;
+use serde::{Deserialize, Serialize};
+
+use crate::{CostsDB, CostsError, CostsLedger, WriterRole};
+
+/// JSON-facing bootstrap configuration. Only backend administrator keys enter
+/// genesis; client accounts are registered through signed administrative commands.
+#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
+pub struct CostsGenesis {
+ #[serde(default)]
+ pub administrators: Vec,
+}
+
+impl CostsLedger {
+ /// Seed the initial administrator allowlist from chain genesis.
+ pub async fn apply_genesis(&mut self, genesis: &CostsGenesis) -> Result<(), CostsError> {
+ for administrator in &genesis.administrators {
+ let address = decode_hex::(administrator, "administrator")?;
+ self.db.set_writer(WriterRole::Admin, &address, true);
+ }
+ Ok(())
+ }
+}
+
+fn decode_hex(value: &str, what: &'static str) -> Result
+where
+ T: DecodeExt<()>,
+{
+ let bytes = from_hex(value).ok_or_else(|| CostsError::Storage(format!("invalid {what}")))?;
+ T::decode(bytes.as_ref())
+ .map_err(|err| CostsError::Storage(format!("invalid {what}: {err}")))
+}
diff --git a/costs/src/grant.rs b/costs/src/grant.rs
new file mode 100644
index 0000000..7986875
--- /dev/null
+++ b/costs/src/grant.rs
@@ -0,0 +1,110 @@
+//! Credit ingress taxonomy — shared `source_reason` constants and idempotency helpers.
+//!
+//! See ADR-0004: grants use `CreditAdjustmentV1` with `metadata.reason_code`;
+//! paid top-ups use `CreditTopup` with `reason_code = topup` in the journal.
+
+use crate::{AdjustmentKind, AdjustmentMetadata, CostsOperation};
+
+/// Application-defined included, trial, promotional, or goodwill credits.
+pub const REASON_INCLUDED_CREDITS: &str = "included_credits";
+pub const REASON_TRIAL: &str = "trial";
+pub const REASON_PROMOTION: &str = "promotion";
+pub const REASON_GOODWILL: &str = "goodwill";
+
+/// Paid rail journal marker (op is `CreditTopup`, not adjustment).
+pub const REASON_TOPUP: &str = "topup";
+
+/// All known grant reason codes for validation and downstream warehouse partitioning.
+pub const ALL_GRANT_REASONS: &[&str] = &[
+ REASON_INCLUDED_CREDITS,
+ REASON_TRIAL,
+ REASON_PROMOTION,
+ REASON_GOODWILL,
+];
+
+/// Returns true if `reason_code` is a recognized grant taxonomy value.
+pub fn is_known_grant_reason(reason_code: &str) -> bool {
+ ALL_GRANT_REASONS.contains(&reason_code)
+}
+
+/// Returns true if accounting export should exclude this grant from revenue recognition.
+pub fn is_non_revenue_grant(reason_code: &str) -> bool {
+ matches!(reason_code, REASON_TRIAL | REASON_PROMOTION | REASON_GOODWILL)
+}
+
+/// Idempotency key for an application's periodic grant.
+pub fn periodic_grant_ref(account_id: &str, period: &str) -> String {
+ format!("grant_{account_id}_{period}")
+}
+
+/// Idempotency key for a named campaign grant.
+pub fn campaign_grant_ref(campaign_ref: &str, account_id: &str) -> String {
+ format!("grant_{campaign_ref}_{account_id}")
+}
+
+/// Build metadata for a periodic included-credit grant.
+pub fn periodic_grant_metadata(
+ account_id: &str,
+ period: &str,
+ reason_code: &str,
+ approval_ref: &str,
+) -> AdjustmentMetadata {
+ AdjustmentMetadata {
+ reference: periodic_grant_ref(account_id, period),
+ reason_code: reason_code.to_string(),
+ period_ref: period.to_string(),
+ approval_ref: approval_ref.to_string(),
+ audit_ref: format!("periodic_grant:{period}"),
+ }
+}
+
+/// Build grant metadata for a promotional credit.
+pub fn campaign_grant_metadata(
+ campaign_ref: &str,
+ account_id: &str,
+ reason_code: &str,
+ approval_ref: &str,
+ audit_ref: &str,
+) -> AdjustmentMetadata {
+ AdjustmentMetadata {
+ reference: campaign_grant_ref(campaign_ref, account_id),
+ reason_code: reason_code.to_string(),
+ period_ref: format!("campaign:{campaign_ref}"),
+ approval_ref: approval_ref.to_string(),
+ audit_ref: audit_ref.to_string(),
+ }
+}
+
+/// Construct a signed-ready grant operation.
+pub fn credit_grant_op(
+ account_id: String,
+ credits: u64,
+ metadata: AdjustmentMetadata,
+) -> CostsOperation {
+ CostsOperation::CreditAdjustmentV1 {
+ kind: AdjustmentKind::Grant,
+ account_id,
+ credits,
+ metadata,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn periodic_grant_ref_is_stable() {
+ assert_eq!(
+ periodic_grant_ref("bravo", "2026-07"),
+ "grant_bravo_2026-07"
+ );
+ }
+
+ #[test]
+ fn non_revenue_classification_is_explicit() {
+ assert!(is_non_revenue_grant(REASON_TRIAL));
+ assert!(is_non_revenue_grant(REASON_PROMOTION));
+ assert!(!is_non_revenue_grant(REASON_INCLUDED_CREDITS));
+ }
+}
diff --git a/costs/src/ledger.rs b/costs/src/ledger.rs
new file mode 100644
index 0000000..ec5812d
--- /dev/null
+++ b/costs/src/ledger.rs
@@ -0,0 +1,1719 @@
+use std::collections::{BTreeMap, BTreeSet};
+
+use commonware_codec::Write;
+use commonware_cryptography::{Hasher, Sha256};
+use nunchi_common::Address;
+use nunchi_crypto::SignatureError;
+use thiserror::Error;
+
+use crate::{
+ AccountReadV1, AdjustmentKind, AdjustmentMetadata, BalanceMutationDirection, CostsDB, CostsOperation,
+ LedgerMutationKind, LedgerMutationV1, QuoteRequestV1, QuoteV1, RateCardChangeSetV1,
+ RateCardCompletionV1, StoredValueAccountReadV1, StoredValueFinalityEventV1,
+ StoredValueFinalityPayloadV1, StoredValueLotReadV1,
+ Reservation, ReservationStatus, CreditAccount, AccountProfile, AccountStatus, StatusHistoryEntry,
+ StatusChangeMetadataV1, Transaction, UntrackedSourceV1, WriterRole,
+};
+
+/// Maximum normalized records accepted in one metering transaction.
+pub const MAX_SPEND_RECORDS_PER_BATCH: usize = 150;
+/// Maximum rate targets carried by one staged or activation command.
+pub const MAX_RATE_ENTRIES_PER_COMMAND: usize = 64;
+/// Bound control-plane fan-out and onboarding inheritance work in one
+/// deterministic state transition. A production migration can raise this only
+/// through an explicit storage and gas review.
+pub const MAX_REGISTERED_SITES: u64 = 4_096;
+pub const MAX_GLOBAL_RATE_REVISIONS: u64 = 4_096;
+
+/// Deterministic failures exposed by the costs state machine.
+#[derive(Clone, Debug, Error, PartialEq)]
+pub enum CostsError {
+ #[error("bad costs transaction signature: {0}")]
+ BadSignature(#[from] SignatureError),
+ #[error("nonce mismatch for {account:?}: expected {expected}, got {actual}")]
+ NonceMismatch {
+ account: Box,
+ expected: u64,
+ actual: u64,
+ },
+ #[error("nonce overflow")]
+ NonceOverflow,
+ #[error("unauthorized {role:?} writer {writer:?}")]
+ Unauthorized { role: WriterRole, writer: Box },
+ #[error("invalid {field}")]
+ InvalidField { field: &'static str },
+ #[error("legacy operation {operation} is no longer accepted; use the V1 command")]
+ LegacyOperationRejected { operation: &'static str },
+ #[error("account account {account_id} already exists")]
+ AccountAlreadyExists { account_id: String },
+ #[error("account account {account_id} was not found")]
+ AccountNotFound { account_id: String },
+ #[error("account account {account_id} is suspended")]
+ AccountSuspended { account_id: String },
+ #[error("insufficient credits for {account_id}: available {available}, required {required}")]
+ InsufficientCredits {
+ account_id: String,
+ available: u64,
+ required: u64,
+ },
+ #[error("spend batch contains {actual} records; maximum is {maximum}")]
+ BatchTooLarge { actual: usize, maximum: usize },
+ #[error("credit balance overflow")]
+ CreditOverflow,
+ #[error("reserved credit balance underflow")]
+ ReservedCreditUnderflow,
+ #[error("reservation {reservation_id} already exists with different terms")]
+ ReservationConflict { reservation_id: String },
+ #[error("reservation {reservation_id} was not found")]
+ ReservationNotFound { reservation_id: String },
+ #[error("reservation {reservation_id} is not active")]
+ ReservationNotActive { reservation_id: String },
+ #[error("rate change set {change_set_id} was not found")]
+ RateChangeSetNotFound { change_set_id: String },
+ #[error("rate change set {change_set_id} conflicts with its approved manifest")]
+ RateChangeSetConflict { change_set_id: String },
+ #[error("rate change set {change_set_id} is incomplete: staged {staged}, expected {expected}")]
+ RateChangeSetIncomplete {
+ change_set_id: String,
+ staged: u16,
+ expected: u16,
+ },
+ #[error("rate command contains {actual} entries; maximum is {maximum}")]
+ RateCommandTooLarge { actual: usize, maximum: usize },
+ #[error("registered account capacity {maximum} has been reached")]
+ AccountRegistryFull { maximum: u64 },
+ #[error("global rate revision capacity {maximum} has been reached")]
+ GlobalRateRegistryFull { maximum: u64 },
+ #[error("idempotency reference {reference} was replayed with different contents")]
+ IdempotencyConflict { reference: String },
+ #[error("spend {event_id} does not match its pinned rate snapshot")]
+ PinnedRateMismatch { event_id: String },
+ #[error("reservation {reservation_id} cannot settle while its account is suspended")]
+ ReservationAccountSuspended { reservation_id: String },
+ #[error("reservation {reservation_id} expired at {expires_at}")]
+ ReservationExpired { reservation_id: String, expires_at: u64 },
+ #[error("state storage error: {0}")]
+ Storage(String),
+}
+
+/// Generic custodial ledger for client account accounts.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct CostsLedger {
+ pub(crate) db: D,
+}
+
+/// The narrow set of state changes that are entitled to a persisted finality
+/// mutation. Idempotency is a state-machine property: a successful replay
+/// advances its signer nonce but must not manufacture a second ledger event.
+#[derive(Clone, Debug)]
+enum AppliedMutation {
+ None,
+ Onboarded,
+ OnboardedWithRates(Vec),
+ BalanceChanged,
+ Spend(Vec),
+ StatusChanged,
+ ReservationChanged,
+ UntrackedSourceRegistered,
+ RatesStaged(Vec),
+ RatesApplied(Vec),
+}
+
+impl CostsLedger {
+ /// Wrap a database backend as a costs ledger.
+ pub fn new(db: D) -> Self {
+ Self { db }
+ }
+
+ /// Borrow the underlying database.
+ pub fn db(&self) -> &D {
+ &self.db
+ }
+
+ /// Consume the ledger, returning the underlying database.
+ pub fn into_inner(self) -> D {
+ self.db
+ }
+
+ /// Read one opaque account account.
+ pub async fn account(&self, account_id: &str) -> Result, CostsError> {
+ self.db.account(account_id).await
+ }
+
+ /// Read a long-running action's hold state.
+ pub async fn reservation(
+ &self,
+ reservation_id: &str,
+ ) -> Result , CostsError> {
+ self.db.reservation(reservation_id).await
+ }
+
+ /// Read non-financial onboarding/status metadata for a account. This remains
+ /// a private backend/BFF surface; it is not a public chain RPC.
+ pub async fn profile(&self, account_id: &str) -> Result , CostsError> {
+ self.db.profile(account_id).await
+ }
+
+ /// Private BFF-facing account read. Callers must enforce their own
+ /// account-scoped authorization before invoking this chain read.
+ pub async fn account_read(&self, account_id: &str) -> Result , CostsError> {
+ let Some(account) = self.db.account(account_id).await? else {
+ return Ok(None);
+ };
+ let profile = self.db.profile(account_id).await?.ok_or_else(|| CostsError::Storage(
+ "account is missing its immutable profile".to_string(),
+ ))?;
+ Ok(Some(AccountReadV1 { account, profile }))
+ }
+
+ /// Private BFF-facing, provenance-preserving stored-value read. The V2
+ /// economic ledger is intentionally separate from aggregate metering
+ /// balances while the clean-state migration remains unapproved.
+ pub async fn stored_value_account_read(
+ &self,
+ account_id: &str,
+ now: u64,
+ period_ref: &str,
+ reset_at: u64,
+ ) -> Result , CostsError> {
+ if self.db.account(account_id).await?.is_none() {
+ return Ok(None);
+ }
+ self.db
+ .stored_value_ledger()
+ .await?
+ .account_read(account_id, now, period_ref, reset_at)
+ .map(Some)
+ .map_err(stored_value_error)
+ }
+
+ /// Private sink/BFF lot history. Do not expose this from a public client
+ /// RPC: caller-side `account_id` authorization remains mandatory.
+ pub async fn stored_value_lots(
+ &self,
+ account_id: &str,
+ ) -> Result >, CostsError> {
+ if self.db.account(account_id).await?.is_none() {
+ return Ok(None);
+ }
+ self.db
+ .stored_value_ledger()
+ .await?
+ .lots_for_account(account_id)
+ .map(Some)
+ .map_err(stored_value_error)
+ }
+
+ /// Read immutable V2 projection inputs. A finality adapter must consume
+ /// these only for finalized chain transactions, then dedupe using the
+ /// stored event identity before projecting to downstream warehouse or a client-safe BFF.
+ pub async fn stored_value_finality_events(
+ &self,
+ from_sequence: u64,
+ limit: u16,
+ ) -> Result, CostsError> {
+ Ok(self.db.stored_value_ledger().await?.finality_events(from_sequence, limit))
+ }
+
+ /// Convenience one-unit quote with a complete ID and snapshot hash. New
+ /// callers should prefer `quote_request` to supply an explicit expiry.
+ pub async fn quote(
+ &self,
+ account_id: &str,
+ event_category: &str,
+ task_key: &str,
+ quoted_at: u64,
+ ) -> Result, CostsError> {
+ let Some(rate) = self.active_rate(account_id, event_category, task_key, quoted_at).await? else { return Ok(None); };
+ let expires_at = if rate.expires_at == 0 { quoted_at.checked_add(1).ok_or(CostsError::CreditOverflow)? } else { rate.expires_at };
+ self.quote_request(QuoteRequestV1 { account_id: account_id.to_string(), event_category: event_category.to_string(), task_key: task_key.to_string(), quantity: 1, quoted_at, expires_at }).await
+ }
+
+ /// Produce a fixed-price, bounded-lifetime quote snapshot for reservation.
+ pub async fn quote_request(&self, request: QuoteRequestV1) -> Result , CostsError> {
+ if request.quantity == 0 || request.expires_at <= request.quoted_at { return Err(CostsError::InvalidField { field: "quote_expiry_or_quantity" }); }
+ let Some(rate) = self.active_rate(&request.account_id, &request.event_category, &request.task_key, request.quoted_at).await? else { return Ok(None); };
+ if rate.expires_at != 0 && request.expires_at > rate.expires_at { return Err(CostsError::InvalidField { field: "quote_expires_at" }); }
+ let total_credits = rate.credits.checked_mul(request.quantity).ok_or(CostsError::CreditOverflow)?;
+ let mut quote = QuoteV1 { quote_id: String::new(), snapshot_hash: String::new(), account_id: request.account_id, event_category: request.event_category, task_key: request.task_key, quoted_at: request.quoted_at, credits_per_unit: rate.credits, quantity: request.quantity, total_credits, policy_version: rate.policy_version, rate_version: rate.rate_version, expires_at: request.expires_at };
+ quote.snapshot_hash = quote_snapshot_hash("e);
+ quote.quote_id = format!("quote:{}", "e.snapshot_hash[..32]);
+ Ok(Some(quote))
+ }
+
+ /// Read the append-only status history, oldest first.
+ pub async fn status_history(
+ &self,
+ account_id: &str,
+ ) -> Result, CostsError> {
+ let count = self.db.status_history_count(account_id).await?;
+ let mut result = Vec::with_capacity(count as usize);
+ for sequence in 0..count {
+ if let Some(entry) = self.db.status_history(account_id, sequence).await? {
+ result.push(entry);
+ }
+ }
+ Ok(result)
+ }
+
+ /// Read append-only post-state mutation records, oldest first. This is a
+ /// private chain-read seam for the finality sink and reconciliation, never
+ /// a public client endpoint.
+ pub async fn journal(&self, from_sequence: u64, limit: u16) -> Result, CostsError> {
+ let count = self.db.journal_count().await?;
+ let end = from_sequence.saturating_add(u64::from(limit)).min(count);
+ let mut entries = Vec::with_capacity((end - from_sequence) as usize);
+ for sequence in from_sequence..end {
+ if let Some(entry) = self.db.journal_entry(sequence).await? {
+ entries.push(entry);
+ }
+ }
+ Ok(entries)
+ }
+
+ /// Resolve the active scoped rate at `price_at` using account/task, account/category,
+ /// global/task, then global/category precedence.
+ pub async fn active_rate(
+ &self,
+ account_id: &str,
+ event_category: &str,
+ task_key: &str,
+ price_at: u64,
+ ) -> Result, CostsError> {
+ for (candidate_account, candidate_task) in [
+ (account_id, task_key),
+ (account_id, ""),
+ ("", task_key),
+ ("", ""),
+ ] {
+ let count = self.db.rate_history_count(candidate_account, event_category, candidate_task).await?;
+ let mut selected: Option = None;
+ for sequence in 0..count {
+ if let Some(entry) = self.db.rate_history_entry(candidate_account, event_category, candidate_task, sequence).await? {
+ // Global defaults may be materialized for read-model and
+ // reconciliation purposes, but their stored account-shaped
+ // copy is not an explicit account rule. Skip it here so the
+ // declared scope order remains account/task > account/category
+ // > global/task > global/category.
+ if !candidate_account.is_empty()
+ && self.db.rate_history_global_materialization(candidate_account, event_category, candidate_task, sequence).await? {
+ continue;
+ }
+ let eligible = entry.effective_at <= price_at && (entry.expires_at == 0 || price_at < entry.expires_at);
+ let replaces = selected
+ .as_ref()
+ .is_none_or(|current| entry.effective_at >= current.effective_at);
+ if eligible && replaces {
+ // Equal effective timestamps are a deliberate revision; the
+ // last activated entry wins while the earlier entry stays
+ // queryable in history for reconciliation.
+ selected = Some(entry);
+ }
+ }
+ }
+ if selected.is_some() {
+ return Ok(selected);
+ }
+ }
+ Ok(None)
+ }
+
+ /// Apply one signed costs transaction.
+ pub async fn apply_transaction(&mut self, tx: &Transaction) -> Result<(), CostsError> {
+ self.apply_transaction_with_outcomes(tx).await.map(|_| ())
+ }
+
+ /// Apply one transaction and return the ledger-generated, persisted
+ /// post-state outcomes. Adapters may publish them only after chain finality.
+ pub async fn apply_transaction_with_outcomes(
+ &mut self,
+ tx: &Transaction,
+ ) -> Result, CostsError> {
+ tx.verify()?;
+
+ let expected = self.db.nonce(&tx.account_id).await?;
+ if tx.payload.nonce != expected {
+ return Err(CostsError::NonceMismatch {
+ account: Box::new(tx.account_id.clone()),
+ expected,
+ actual: tx.payload.nonce,
+ });
+ }
+
+ let stored_value_transaction_id = format!("{:?}", tx.digest());
+ let applied = match &tx.payload.operation {
+ CostsOperation::RegisterAccount { .. } => return Err(CostsError::LegacyOperationRejected { operation: "RegisterAccount" }),
+ CostsOperation::CreateAccount { account_id, external_ref, policy_ref, cohort_ref, created_at } => {
+ validate_identifier(account_id, "account_id")?;
+ validate_identifier(external_ref, "external_ref")?;
+ validate_identifier(policy_ref, "policy_ref")?;
+ if !cohort_ref.is_empty() { validate_identifier(cohort_ref, "cohort_ref")?; }
+ self.require_writer(WriterRole::Admin, &tx.account_id).await?;
+ if let Some(existing_account) = self.db.onboarding_account(external_ref).await? {
+ if existing_account != *account_id {
+ return Err(CostsError::IdempotencyConflict { reference: external_ref.clone() });
+ }
+ }
+ let requested = AccountProfile {
+ account_id: account_id.clone(), external_ref: external_ref.clone(),
+ policy_ref: policy_ref.clone(), cohort_ref: cohort_ref.clone(), created_at: *created_at,
+ status_reason: "onboarded".to_string(), status_changed_at: *created_at,
+ };
+ match (self.db.account(account_id).await?, self.db.profile(account_id).await?) {
+ (None, None) => {
+ self.preflight_account_registry_append(account_id).await?;
+ self.db.set_account(account_id, CreditAccount::active());
+ let mut stored_value = self.db.stored_value_ledger().await?;
+ stored_value.onboard(account_id).map_err(stored_value_error)?;
+ self.db.set_stored_value_ledger(stored_value);
+ self.db.set_profile(requested);
+ self.db.set_onboarding_account(external_ref, account_id);
+ self.append_account_to_registry(account_id).await?;
+ self.append_status_history(account_id, AccountStatus::Active, "onboarded", *created_at, "onboarded", "onboarded").await?;
+ let inherited = self.materialize_active_global_rates(account_id, *created_at).await?;
+ if inherited.is_empty() { AppliedMutation::Onboarded } else { AppliedMutation::OnboardedWithRates(inherited) }
+ }
+ (Some(_), Some(existing)) if existing == requested => AppliedMutation::None,
+ _ => return Err(CostsError::IdempotencyConflict { reference: external_ref.clone() }),
+ }
+ }
+ CostsOperation::SetWriter { .. } => return Err(CostsError::LegacyOperationRejected { operation: "SetWriter" }),
+ CostsOperation::SetAccountWriter { account_id, role, writer, enabled } => {
+ validate_identifier(account_id, "account_id")?;
+ self.require_writer(WriterRole::Admin, &tx.account_id).await?;
+ self.require_account(account_id).await?;
+ self.db.set_account_writer(*role, account_id, writer, *enabled);
+ AppliedMutation::None
+ }
+ CostsOperation::RotateAdmin { replacement } => {
+ self.require_writer(WriterRole::Admin, &tx.account_id).await?;
+ if replacement == &tx.account_id {
+ return Err(CostsError::InvalidField { field: "replacement" });
+ }
+ self.db.set_writer(WriterRole::Admin, replacement, true);
+ self.db.set_writer(WriterRole::Admin, &tx.account_id, false);
+ AppliedMutation::None
+ }
+ CostsOperation::CreditTopup {
+ account_id,
+ credits,
+ rail_ref,
+ } => {
+ validate_identifier(account_id, "account_id")?;
+ validate_identifier(rail_ref, "rail_ref")?;
+ if *credits == 0 {
+ return Err(CostsError::InvalidField { field: "credits" });
+ }
+ self.require_writer_for_account(WriterRole::Billing, account_id, &tx.account_id).await?;
+ let fingerprint = topup_fingerprint(account_id, *credits);
+ if let Some(existing) = self.db.rail_fingerprint(rail_ref).await? {
+ if existing == fingerprint {
+ self.advance_nonce(&tx.account_id, expected)?;
+ return Ok(Vec::new());
+ }
+ return Err(CostsError::IdempotencyConflict {
+ reference: rail_ref.clone(),
+ });
+ }
+ let mut account = self.require_account(account_id).await?;
+ account.available_credits = account
+ .available_credits
+ .checked_add(*credits)
+ .ok_or(CostsError::CreditOverflow)?;
+ self.db.set_account(account_id, account);
+ self.db.mark_rail(rail_ref, &fingerprint);
+ AppliedMutation::BalanceChanged
+ }
+ CostsOperation::StoredValueTopupV2 { topup } => {
+ self.require_writer_for_account(WriterRole::Billing, &topup.account_id, &tx.account_id).await?;
+ self.require_account(&topup.account_id).await?;
+ let mut stored_value = self.db.stored_value_ledger().await?;
+ stored_value.credit_topup(topup.clone()).map_err(stored_value_error)?;
+ stored_value.append_finality_event(
+ format!("topup:{}", topup.rail_ref), stored_value_transaction_id.clone(), topup.account_id.clone(),
+ StoredValueFinalityPayloadV1::Topup(topup.clone()),
+ ).map_err(stored_value_error)?;
+ self.db.set_stored_value_ledger(stored_value);
+ AppliedMutation::None
+ }
+ CostsOperation::StoredValueGrantV2 { grant } => {
+ self.require_writer_for_account(WriterRole::Adjustment, &grant.account_id, &tx.account_id).await?;
+ self.require_account(&grant.account_id).await?;
+ let mut stored_value = self.db.stored_value_ledger().await?;
+ stored_value.credit_grant(grant.clone()).map_err(stored_value_error)?;
+ stored_value.append_finality_event(
+ format!("grant:{}", grant.reference), stored_value_transaction_id.clone(), grant.account_id.clone(),
+ StoredValueFinalityPayloadV1::Grant(grant.clone()),
+ ).map_err(stored_value_error)?;
+ self.db.set_stored_value_ledger(stored_value);
+ AppliedMutation::None
+ }
+ CostsOperation::StoredValueSpendV2 { spend } => {
+ self.require_writer_for_account(WriterRole::Ingest, &spend.account_id, &tx.account_id).await?;
+ let account = self.require_account(&spend.account_id).await?;
+ if account.status == AccountStatus::Suspended {
+ return Err(CostsError::AccountSuspended { account_id: spend.account_id.clone() });
+ }
+ let mut stored_value = self.db.stored_value_ledger().await?;
+ let allocations = stored_value.record_spend(spend.clone()).map_err(stored_value_error)?;
+ stored_value.append_finality_event(
+ format!("spend:{}", spend.event_id), stored_value_transaction_id.clone(), spend.account_id.clone(),
+ StoredValueFinalityPayloadV1::Spend { spend: spend.clone(), allocations },
+ ).map_err(stored_value_error)?;
+ self.db.set_stored_value_ledger(stored_value);
+ AppliedMutation::None
+ }
+ CostsOperation::RefundPaidLotV1 { refund } => {
+ self.require_writer_for_account(WriterRole::Adjustment, &refund.account_id, &tx.account_id).await?;
+ self.require_account(&refund.account_id).await?;
+ let mut stored_value = self.db.stored_value_ledger().await?;
+ stored_value.refund_paid_lot(refund.clone()).map_err(stored_value_error)?;
+ stored_value.append_finality_event(
+ format!("refund:{}", refund.refund_rail_ref), stored_value_transaction_id.clone(), refund.account_id.clone(),
+ StoredValueFinalityPayloadV1::Refund(refund.clone()),
+ ).map_err(stored_value_error)?;
+ self.db.set_stored_value_ledger(stored_value);
+ AppliedMutation::None
+ }
+ CostsOperation::ReserveStoredValueV2 { reservation_id, account_id, credits, expires_at, reserved_at } => {
+ self.require_writer_for_account(WriterRole::Ingest, account_id, &tx.account_id).await?;
+ let account = self.require_account(account_id).await?;
+ if account.status == AccountStatus::Suspended {
+ return Err(CostsError::AccountSuspended { account_id: account_id.clone() });
+ }
+ let mut stored_value = self.db.stored_value_ledger().await?;
+ stored_value.reserve(reservation_id, account_id, *credits, *expires_at, *reserved_at).map_err(stored_value_error)?;
+ self.db.set_stored_value_ledger(stored_value);
+ AppliedMutation::None
+ }
+ CostsOperation::ReleaseStoredValueReservationV2 { reservation_id, released_at } => {
+ let mut stored_value = self.db.stored_value_ledger().await?;
+ let account_id = stored_value.reservation(reservation_id)
+ .ok_or_else(|| stored_value_error(crate::StoredValueError::ReservationNotFound(reservation_id.clone())))?
+ .account_id.clone();
+ self.require_writer_for_account(WriterRole::Ingest, &account_id, &tx.account_id).await?;
+ stored_value.release_reservation(reservation_id, *released_at).map_err(stored_value_error)?;
+ self.db.set_stored_value_ledger(stored_value);
+ AppliedMutation::None
+ }
+ CostsOperation::ExpireStoredValueReservationV2 { reservation_id, expired_at } => {
+ let mut stored_value = self.db.stored_value_ledger().await?;
+ let account_id = stored_value.reservation(reservation_id)
+ .ok_or_else(|| stored_value_error(crate::StoredValueError::ReservationNotFound(reservation_id.clone())))?
+ .account_id.clone();
+ self.require_writer_for_account(WriterRole::Ingest, &account_id, &tx.account_id).await?;
+ stored_value.expire_reservation(reservation_id, *expired_at).map_err(stored_value_error)?;
+ self.db.set_stored_value_ledger(stored_value);
+ AppliedMutation::None
+ }
+ CostsOperation::SettleStoredValueReservationV2 { reservation_id, spend } => {
+ let mut stored_value = self.db.stored_value_ledger().await?;
+ let account_id = stored_value.reservation(reservation_id)
+ .ok_or_else(|| stored_value_error(crate::StoredValueError::ReservationNotFound(reservation_id.clone())))?
+ .account_id.clone();
+ if account_id != spend.account_id {
+ return Err(CostsError::InvalidField { field: "stored_value_reservation_account" });
+ }
+ self.require_writer_for_account(WriterRole::Ingest, &account_id, &tx.account_id).await?;
+ let account = self.require_account(&account_id).await?;
+ if account.status == AccountStatus::Suspended {
+ return Err(CostsError::AccountSuspended { account_id });
+ }
+ let allocations = stored_value.settle_reservation(reservation_id, spend.clone()).map_err(stored_value_error)?;
+ stored_value.append_finality_event(
+ format!("spend:{}", spend.event_id), stored_value_transaction_id.clone(), spend.account_id.clone(),
+ StoredValueFinalityPayloadV1::Spend { spend: spend.clone(), allocations },
+ ).map_err(stored_value_error)?;
+ self.db.set_stored_value_ledger(stored_value);
+ AppliedMutation::None
+ }
+ CostsOperation::RecordSpendBatch { records } => {
+ AppliedMutation::Spend(self.apply_spend_batch(records, &tx.account_id).await?)
+ }
+ CostsOperation::SetAccountStatus { .. } => return Err(CostsError::LegacyOperationRejected { operation: "SetAccountStatus" }),
+ CostsOperation::SetAccountStatusV1 { account_id, status, metadata } => {
+ self.require_writer(WriterRole::Admin, &tx.account_id).await?;
+ self.set_account_status_v1(account_id, *status, metadata).await?
+ }
+ CostsOperation::CreditGrant { .. } => return Err(CostsError::LegacyOperationRejected { operation: "CreditGrant" }),
+ CostsOperation::CreditReversal { .. } => return Err(CostsError::LegacyOperationRejected { operation: "CreditReversal" }),
+ CostsOperation::ReserveCredits { .. } => return Err(CostsError::LegacyOperationRejected { operation: "ReserveCredits" }),
+ CostsOperation::ReserveCreditsV1 { reservation_id, quote, lineage_ref, reserved_at } => {
+ self.require_writer_for_account(WriterRole::Ingest, "e.account_id, &tx.account_id).await?;
+ if self.reserve_v1(reservation_id, quote, lineage_ref, *reserved_at).await? { AppliedMutation::ReservationChanged } else { AppliedMutation::None }
+ }
+ CostsOperation::ReleaseReservation { .. } => return Err(CostsError::LegacyOperationRejected { operation: "ReleaseReservation" }),
+ CostsOperation::ExpireReservation { .. } => return Err(CostsError::LegacyOperationRejected { operation: "ExpireReservation" }),
+ CostsOperation::ReleaseReservationV1 { reservation_id, metadata } => {
+ let reservation = self.db.reservation(reservation_id).await?.ok_or_else(|| CostsError::ReservationNotFound { reservation_id: reservation_id.clone() })?;
+ self.require_writer_for_account(WriterRole::Ingest, &reservation.account_id, &tx.account_id).await?;
+ validate_reservation_metadata(metadata)?;
+ if self.release_reservation(reservation_id).await? { AppliedMutation::ReservationChanged } else { AppliedMutation::None }
+ }
+ CostsOperation::ExpireReservationV1 { reservation_id, metadata } => {
+ let reservation = self.db.reservation(reservation_id).await?.ok_or_else(|| CostsError::ReservationNotFound { reservation_id: reservation_id.clone() })?;
+ self.require_writer_for_account(WriterRole::Ingest, &reservation.account_id, &tx.account_id).await?;
+ validate_reservation_metadata(metadata)?;
+ if self.expire_reservation(reservation_id, metadata.occurred_at).await? { AppliedMutation::ReservationChanged } else { AppliedMutation::None }
+ }
+ CostsOperation::SettleSpend { .. } => return Err(CostsError::LegacyOperationRejected { operation: "SettleSpend" }),
+ CostsOperation::SettleSpendV1 { reservation_id, event_id, event_category, task_key, quote_id, snapshot_hash, lineage_ref, metadata } => {
+ let reservation = self.db.reservation(reservation_id).await?.ok_or_else(|| CostsError::ReservationNotFound { reservation_id: reservation_id.clone() })?;
+ self.require_writer_for_account(WriterRole::Ingest, &reservation.account_id, &tx.account_id).await?;
+ validate_reservation_metadata(metadata)?;
+ if self.settle_reservation_v1(reservation_id, event_id, event_category, task_key, quote_id, snapshot_hash, lineage_ref, metadata.occurred_at).await? { AppliedMutation::ReservationChanged } else { AppliedMutation::None }
+ }
+ CostsOperation::RegisterUntrackedSource { .. } => return Err(CostsError::LegacyOperationRejected { operation: "RegisterUntrackedSource" }),
+ CostsOperation::RegisterUntrackedSourceV1 { source } => {
+ self.require_writer(WriterRole::Admin, &tx.account_id).await?;
+ validate_untracked_source(source)?;
+ match self.db.untracked_source(&source.source_id).await? {
+ Some(existing) if existing == *source => AppliedMutation::None,
+ Some(_) => return Err(CostsError::IdempotencyConflict { reference: source.source_id.clone() }),
+ None => { self.db.set_untracked_source(source.clone()); AppliedMutation::UntrackedSourceRegistered }
+ }
+ }
+ CostsOperation::CreditAdjustmentV1 { kind, account_id, credits, metadata } => {
+ if self.apply_adjustment_v1(*kind, account_id, *credits, metadata, &tx.account_id).await? { AppliedMutation::BalanceChanged } else { AppliedMutation::None }
+ }
+ CostsOperation::StageRateCardEntries { .. } => return Err(CostsError::LegacyOperationRejected { operation: "StageRateCardEntries" }),
+ CostsOperation::ApplyRateCardChangeSet { .. } => return Err(CostsError::LegacyOperationRejected { operation: "ApplyRateCardChangeSet" }),
+ CostsOperation::StageRateCardChangeSetV1 { change_set, entries } => {
+ self.require_writer(WriterRole::Admin, &tx.account_id).await?;
+ AppliedMutation::RatesStaged(self.stage_rate_entries_v1(change_set, entries).await?)
+ }
+ CostsOperation::ApplyRateCardChangeSetV1 { change_set, entries } => {
+ self.require_writer(WriterRole::Admin, &tx.account_id).await?;
+ let applied = self.apply_rate_change_set_v1(change_set, entries).await?;
+ if applied.is_empty() { AppliedMutation::None } else { AppliedMutation::RatesApplied(applied) }
+ }
+ };
+
+ self.advance_nonce(&tx.account_id, expected)?;
+ self.append_mutation_outcomes(tx, applied).await
+ }
+
+ async fn append_mutation_outcomes(
+ &mut self,
+ tx: &Transaction, applied: AppliedMutation,
+ ) -> Result, CostsError> {
+ let transaction_id = format!("{:?}", tx.digest());
+ let mut entries = Vec::new();
+ match (applied, &tx.payload.operation) {
+ (AppliedMutation::Onboarded, CostsOperation::CreateAccount { account_id, cohort_ref, .. }) => {
+ entries.push(self.outcome_for_account(&transaction_id, LedgerMutationKind::AccountOnboarded, account_id, cohort_ref, "").await?);
+ }
+ (AppliedMutation::OnboardedWithRates(rates), CostsOperation::CreateAccount { account_id, cohort_ref, .. }) => {
+ entries.push(self.outcome_for_account(&transaction_id, LedgerMutationKind::AccountOnboarded, account_id, cohort_ref, "").await?);
+ for rate in rates {
+ let mut entry = self.outcome_for_rate(&transaction_id, LedgerMutationKind::RateCardApplied, "onboarding_inheritance", &rate).await?;
+ entry.has_rate = true;
+ entry.reason_code = "global_rate_inherited".to_string();
+ entries.push(entry);
+ }
+ }
+ (AppliedMutation::BalanceChanged, CostsOperation::CreditTopup { account_id, rail_ref, credits }) => {
+ let mut entry = self.outcome_for_account(&transaction_id, LedgerMutationKind::BalanceChanged, account_id, "", rail_ref).await?;
+ entry.balance_direction = BalanceMutationDirection::Credit;
+ entry.credit_delta = *credits;
+ entry.reason_code = "topup".to_string();
+ entries.push(entry);
+ }
+ (AppliedMutation::BalanceChanged, CostsOperation::CreditAdjustmentV1 { kind, account_id, credits, metadata }) => {
+ let mut entry = self.outcome_for_account(&transaction_id, LedgerMutationKind::BalanceChanged, account_id, "", &metadata.reference).await?;
+ entry.balance_direction = match kind { AdjustmentKind::Grant => BalanceMutationDirection::Credit, AdjustmentKind::Reversal => BalanceMutationDirection::Debit };
+ entry.credit_delta = *credits;
+ entry.reason_code = metadata.reason_code.clone();
+ entry.period_ref = metadata.period_ref.clone();
+ entry.approval_ref = metadata.approval_ref.clone();
+ entry.audit_ref = metadata.audit_ref.clone();
+ entries.push(entry);
+ }
+ (AppliedMutation::Spend(records), CostsOperation::RecordSpendBatch { .. }) => {
+ for record in &records {
+ entries.push(self.outcome_for_account(
+ &transaction_id, LedgerMutationKind::SpendRecorded, &record.account_id,
+ &record.cohort_ref, &record.source_ref,
+ ).await?);
+ }
+ }
+ (AppliedMutation::StatusChanged, CostsOperation::SetAccountStatusV1 { account_id, metadata, .. }) => {
+ let mut entry = self.outcome_for_account(&transaction_id, LedgerMutationKind::AccountStatusChanged, account_id, "", "").await?;
+ entry.reason_code = metadata.reason_code.clone();
+ entry.occurred_at = metadata.changed_at;
+ entry.approval_ref = metadata.approval_ref.clone();
+ entry.audit_ref = metadata.audit_ref.clone();
+ entries.push(entry);
+ }
+ (AppliedMutation::ReservationChanged, CostsOperation::ReserveCreditsV1 { reservation_id, .. })
+ | (AppliedMutation::ReservationChanged, CostsOperation::ReleaseReservationV1 { reservation_id, .. })
+ | (AppliedMutation::ReservationChanged, CostsOperation::ExpireReservationV1 { reservation_id, .. })
+ | (AppliedMutation::ReservationChanged, CostsOperation::SettleSpendV1 { reservation_id, .. }) => {
+ let reservation = self.db.reservation(reservation_id).await?.ok_or_else(|| CostsError::ReservationNotFound { reservation_id: reservation_id.clone() })?;
+ let profile = self.db.profile(&reservation.account_id).await?;
+ let mut entry = self.outcome_for_account(
+ &transaction_id, LedgerMutationKind::ReservationChanged, &reservation.account_id,
+ profile.as_ref().map_or("", |value| value.cohort_ref.as_str()), "",
+ ).await?;
+ entry.has_reservation = true;
+ entry.reservation = reservation;
+ match &tx.payload.operation {
+ CostsOperation::ReleaseReservationV1 { metadata, .. }
+ | CostsOperation::ExpireReservationV1 { metadata, .. }
+ | CostsOperation::SettleSpendV1 { metadata, .. } => {
+ entry.reason_code = metadata.reason_code.clone();
+ entry.occurred_at = metadata.occurred_at;
+ entry.approval_ref = metadata.approval_ref.clone();
+ entry.audit_ref = metadata.audit_ref.clone();
+ }
+ CostsOperation::ReserveCreditsV1 { reserved_at, .. } => entry.occurred_at = *reserved_at,
+ _ => {}
+ }
+ entries.push(entry);
+ }
+ (AppliedMutation::UntrackedSourceRegistered, CostsOperation::RegisterUntrackedSourceV1 { source }) => {
+ let stored = self.db.untracked_source(&source.source_id).await?.ok_or_else(|| CostsError::Storage("untracked source was not persisted".to_string()))?;
+ let mut entry = empty_mutation(&transaction_id, LedgerMutationKind::UntrackedSourceRegistered);
+ entry.source_ref = stored.provenance_ref.clone();
+ entry.cohort_ref = stored.cohort_ref.clone();
+ entry.has_untracked_source = true;
+ entry.untracked_source = stored;
+ entries.push(entry);
+ }
+ (AppliedMutation::RatesStaged(rate_entries), CostsOperation::StageRateCardChangeSetV1 { change_set, .. }) => {
+ for rate in &rate_entries {
+ let mut entry = self.outcome_for_rate(&transaction_id, LedgerMutationKind::RateCardStaged, &change_set.change_set_id, rate).await?;
+ entry.has_rate = true;
+ entry.approval_ref = change_set.approval_ref.clone();
+ entry.audit_ref = change_set.audit_ref.clone();
+ entries.push(entry);
+ }
+ }
+ (AppliedMutation::RatesApplied(rate_entries), CostsOperation::ApplyRateCardChangeSetV1 { change_set, .. }) => {
+ for rate in &rate_entries {
+ let kind = if rate.account_id.is_empty() { LedgerMutationKind::RateCardGlobalApplied } else { LedgerMutationKind::RateCardApplied };
+ let mut entry = self.outcome_for_rate(&transaction_id, kind, &change_set.change_set_id, rate).await?;
+ entry.has_rate = true;
+ entry.approval_ref = change_set.approval_ref.clone();
+ entry.audit_ref = change_set.audit_ref.clone();
+ entries.push(entry);
+ }
+ let mut completion = empty_mutation(&transaction_id, LedgerMutationKind::RateCardCompleted);
+ completion.rate_change_set_id = change_set.change_set_id.clone();
+ completion.reason_code = "rate_card_applied".to_string();
+ completion.occurred_at = change_set.activation_epoch;
+ completion.approval_ref = change_set.approval_ref.clone();
+ completion.audit_ref = change_set.audit_ref.clone();
+ completion.has_rate_card_completion = true;
+ completion.rate_card_completion = RateCardCompletionV1 {
+ change_set_id: change_set.change_set_id.clone(), manifest_hash: change_set.manifest_hash.clone(),
+ entry_count: change_set.expected_entry_count, target_count: change_set.expected_target_count,
+ activation_epoch: change_set.activation_epoch, approval_ref: change_set.approval_ref.clone(),
+ audit_ref: change_set.audit_ref.clone(),
+ affected_rates: rate_entries.clone(),
+ };
+ entries.push(completion);
+ }
+ // Writer capability changes and rejected legacy commands cannot
+ // change a custodial balance, status, reservation, rate, or source.
+ _ => {}
+ }
+ let mut committed = Vec::with_capacity(entries.len());
+ for mut entry in entries {
+ let sequence = self.db.journal_count().await?;
+ entry.sequence = sequence;
+ self.db.set_journal_entry(entry.clone());
+ self.db.set_journal_count(sequence.checked_add(1).ok_or(CostsError::NonceOverflow)?);
+ committed.push(entry);
+ }
+ Ok(committed)
+ }
+
+ async fn outcome_for_account(
+ &self, transaction_id: &str, kind: LedgerMutationKind, account_id: &str,
+ cohort_ref: &str, source_ref: &str,
+ ) -> Result {
+ let mut entry = empty_mutation(transaction_id, kind);
+ entry.account_id = account_id.to_string();
+ entry.has_account = true;
+ entry.account = self.require_account(account_id).await?;
+ entry.cohort_ref = if cohort_ref.is_empty() {
+ self.db.profile(account_id).await?.map_or_else(String::new, |profile| profile.cohort_ref)
+ } else { cohort_ref.to_string() };
+ entry.source_ref = source_ref.to_string();
+ Ok(entry)
+ }
+
+ async fn outcome_for_rate(
+ &self, transaction_id: &str, kind: LedgerMutationKind, change_set_id: &str,
+ rate: &crate::RateCardEntry,
+ ) -> Result {
+ let mut entry = empty_mutation(transaction_id, kind);
+ entry.rate_change_set_id = change_set_id.to_string();
+ entry.account_id = rate.account_id.clone();
+ entry.has_rate = true;
+ entry.rate = rate.clone();
+ if !rate.account_id.is_empty() {
+ entry.has_account = self.db.account(&rate.account_id).await?.is_some();
+ if entry.has_account {
+ entry.account = self.require_account(&rate.account_id).await?;
+ entry.cohort_ref = self.db.profile(&rate.account_id).await?.map_or_else(String::new, |profile| profile.cohort_ref);
+ }
+ }
+ Ok(entry)
+ }
+
+ async fn require_writer(&self, role: WriterRole, writer: &Address) -> Result<(), CostsError> {
+ if self.db.writer(role, writer).await? {
+ Ok(())
+ } else {
+ Err(CostsError::Unauthorized {
+ role,
+ writer: Box::new(writer.clone()),
+ })
+ }
+ }
+
+ async fn require_writer_for_account(
+ &self,
+ role: WriterRole,
+ account_id: &str,
+ writer: &Address,
+ ) -> Result<(), CostsError> {
+ if self.db.writer(role, writer).await? || self.db.account_writer(role, account_id, writer).await? {
+ Ok(())
+ } else {
+ Err(CostsError::Unauthorized { role, writer: Box::new(writer.clone()) })
+ }
+ }
+
+ async fn require_account(&self, account_id: &str) -> Result {
+ self.db
+ .account(account_id)
+ .await?
+ .ok_or_else(|| CostsError::AccountNotFound {
+ account_id: account_id.to_string(),
+ })
+ }
+
+ async fn append_status_history(
+ &mut self,
+ account_id: &str,
+ status: AccountStatus,
+ reason_code: &str,
+ changed_at: u64,
+ approval_ref: &str,
+ audit_ref: &str,
+ ) -> Result<(), CostsError> {
+ let sequence = self.db.status_history_count(account_id).await?;
+ self.db.set_status_history(
+ account_id,
+ StatusHistoryEntry {
+ sequence,
+ status,
+ reason_code: reason_code.to_string(),
+ changed_at,
+ approval_ref: approval_ref.to_string(), audit_ref: audit_ref.to_string(),
+ },
+ );
+ self.db.set_status_history_count(
+ account_id,
+ sequence.checked_add(1).ok_or(CostsError::NonceOverflow)?,
+ );
+ Ok(())
+ }
+
+ async fn update_profile_status(
+ &mut self,
+ account_id: &str,
+ status: AccountStatus,
+ reason_code: &str,
+ changed_at: u64, approval_ref: &str, audit_ref: &str,
+ ) -> Result<(), CostsError> {
+ let mut profile = self.db.profile(account_id).await?.unwrap_or(AccountProfile {
+ account_id: account_id.to_string(),
+ external_ref: format!("legacy:{account_id}"),
+ policy_ref: "legacy".to_string(),
+ created_at: 0,
+ cohort_ref: String::new(),
+ status_reason: String::new(),
+ status_changed_at: 0,
+ });
+ profile.status_reason = reason_code.to_string();
+ profile.status_changed_at = changed_at;
+ self.db.set_profile(profile);
+ self.append_status_history(account_id, status, reason_code, changed_at, approval_ref, audit_ref)
+ .await
+ }
+
+ async fn set_account_status_v1(&mut self, account_id: &str, status: AccountStatus, metadata: &StatusChangeMetadataV1) -> Result {
+ validate_identifier(account_id, "account_id")?; validate_identifier(&metadata.reason_code, "reason_code")?; validate_identifier(&metadata.approval_ref, "approval_ref")?; validate_identifier(&metadata.audit_ref, "audit_ref")?;
+ if metadata.changed_at == 0 { return Err(CostsError::InvalidField { field: "changed_at" }); }
+ let mut account = self.require_account(account_id).await?;
+ let profile = self.db.profile(account_id).await?.ok_or_else(|| CostsError::AccountNotFound { account_id: account_id.to_string() })?;
+ if account.status == status && profile.status_reason == metadata.reason_code && profile.status_changed_at == metadata.changed_at { return Ok(AppliedMutation::None); }
+ account.status = status; self.db.set_account(account_id, account);
+ self.update_profile_status(account_id, status, &metadata.reason_code, metadata.changed_at, &metadata.approval_ref, &metadata.audit_ref).await?;
+ Ok(AppliedMutation::StatusChanged)
+ }
+
+ async fn verify_pinned_rate(&self, record: &crate::SpendRecordV1) -> Result<(), CostsError> {
+ if !record.task_key.is_empty() {
+ validate_identifier(&record.task_key, "task_key")?;
+ }
+ validate_identifier(&record.policy_version, "policy_version")?;
+ validate_identifier(&record.rate_version, "rate_version")?;
+ validate_identifier(&record.source_ref, "source_ref")?;
+ validate_identifier(&record.lineage_ref, "lineage_ref")?;
+ if !record.cohort_ref.is_empty() {
+ validate_identifier(&record.cohort_ref, "cohort_ref")?;
+ }
+ if record.quantity == 0 || record.observed_at == 0 {
+ return Err(CostsError::PinnedRateMismatch {
+ event_id: record.event_id.clone(),
+ });
+ }
+ let profile = self.db.profile(&record.account_id).await?.ok_or_else(|| CostsError::AccountNotFound {
+ account_id: record.account_id.clone(),
+ })?;
+ if profile.cohort_ref != record.cohort_ref {
+ return Err(CostsError::PinnedRateMismatch {
+ event_id: record.event_id.clone(),
+ });
+ }
+ let Some(rate) = self
+ .active_rate(
+ &record.account_id,
+ &record.event_category,
+ &record.task_key,
+ record.observed_at,
+ )
+ .await?
+ else {
+ return Err(CostsError::PinnedRateMismatch {
+ event_id: record.event_id.clone(),
+ });
+ };
+ let expected = rate
+ .credits
+ .checked_mul(record.quantity)
+ .ok_or(CostsError::CreditOverflow)?;
+ if rate.rate_version != record.rate_version
+ || rate.policy_version != record.policy_version
+ || expected != record.credits
+ {
+ return Err(CostsError::PinnedRateMismatch {
+ event_id: record.event_id.clone(),
+ });
+ }
+ Ok(())
+ }
+
+ async fn reserve_v1(&mut self, reservation_id: &str, quote: &QuoteV1, lineage_ref: &str, reserved_at: u64) -> Result {
+ validate_identifier(reservation_id, "reservation_id")?; validate_quote(quote)?;
+ validate_identifier(lineage_ref, "lineage_ref")?;
+ if reserved_at < quote.quoted_at || reserved_at >= quote.expires_at { return Err(CostsError::ReservationExpired { reservation_id: reservation_id.to_string(), expires_at: quote.expires_at }); }
+ let Some(rate) = self.active_rate("e.account_id, "e.event_category, "e.task_key, quote.quoted_at).await? else { return Err(CostsError::PinnedRateMismatch { event_id: quote.quote_id.clone() }); };
+ if rate.credits != quote.credits_per_unit || rate.policy_version != quote.policy_version || rate.rate_version != quote.rate_version || quote.total_credits != quote.credits_per_unit.checked_mul(quote.quantity).ok_or(CostsError::CreditOverflow)? || quote.snapshot_hash != quote_snapshot_hash(quote) { return Err(CostsError::PinnedRateMismatch { event_id: quote.quote_id.clone() }); }
+ let requested = Reservation { reservation_id: reservation_id.to_string(), account_id: quote.account_id.clone(), quote: quote.clone(), lineage_ref: lineage_ref.to_string(), credits: quote.total_credits, expires_at: quote.expires_at, status: ReservationStatus::Active };
+ if let Some(existing) = self.db.reservation(reservation_id).await? { return if existing == requested { Ok(false) } else { Err(CostsError::ReservationConflict { reservation_id: reservation_id.to_string() }) }; }
+ let mut account = self.require_account("e.account_id).await?; if account.status == AccountStatus::Suspended { return Err(CostsError::AccountSuspended { account_id: quote.account_id.clone() }); }
+ let available = account.available_credits; account.available_credits = available.checked_sub(quote.total_credits).ok_or_else(|| CostsError::InsufficientCredits { account_id: quote.account_id.clone(), available, required: quote.total_credits })?; account.reserved_credits = account.reserved_credits.checked_add(quote.total_credits).ok_or(CostsError::CreditOverflow)?;
+ self.db.set_account("e.account_id, account); self.db.set_reservation(requested); Ok(true)
+ }
+
+ async fn release_reservation(&mut self, reservation_id: &str) -> Result {
+ validate_identifier(reservation_id, "reservation_id")?;
+ let mut reservation = self
+ .db
+ .reservation(reservation_id)
+ .await?
+ .ok_or_else(|| CostsError::ReservationNotFound {
+ reservation_id: reservation_id.to_string(),
+ })?;
+ if reservation.status == ReservationStatus::Released {
+ return Ok(false);
+ }
+ if reservation.status != ReservationStatus::Active {
+ return Err(CostsError::ReservationNotActive {
+ reservation_id: reservation_id.to_string(),
+ });
+ }
+ let mut account = self.require_account(&reservation.account_id).await?;
+ account.reserved_credits = account
+ .reserved_credits
+ .checked_sub(reservation.credits)
+ .ok_or(CostsError::ReservedCreditUnderflow)?;
+ account.available_credits = account
+ .available_credits
+ .checked_add(reservation.credits)
+ .ok_or(CostsError::CreditOverflow)?;
+ reservation.status = ReservationStatus::Released;
+ self.db.set_account(&reservation.account_id, account);
+ self.db.set_reservation(reservation);
+ Ok(true)
+ }
+
+ async fn expire_reservation(
+ &mut self,
+ reservation_id: &str,
+ expired_at: u64,
+ ) -> Result {
+ validate_identifier(reservation_id, "reservation_id")?;
+ let mut reservation = self.db.reservation(reservation_id).await?.ok_or_else(|| {
+ CostsError::ReservationNotFound { reservation_id: reservation_id.to_string() }
+ })?;
+ if reservation.status == ReservationStatus::Expired { return Ok(false); }
+ if reservation.status != ReservationStatus::Active {
+ return Err(CostsError::ReservationNotActive { reservation_id: reservation_id.to_string() });
+ }
+ if expired_at < reservation.expires_at {
+ return Err(CostsError::ReservationExpired { reservation_id: reservation_id.to_string(), expires_at: reservation.expires_at });
+ }
+ let mut account = self.require_account(&reservation.account_id).await?;
+ account.reserved_credits = account.reserved_credits.checked_sub(reservation.credits)
+ .ok_or(CostsError::ReservedCreditUnderflow)?;
+ account.available_credits = account.available_credits.checked_add(reservation.credits)
+ .ok_or(CostsError::CreditOverflow)?;
+ reservation.status = ReservationStatus::Expired;
+ self.db.set_account(&reservation.account_id, account);
+ self.db.set_reservation(reservation);
+ Ok(true)
+ }
+
+ async fn apply_adjustment_v1(
+ &mut self,
+ kind: AdjustmentKind,
+ account_id: &str,
+ credits: u64,
+ metadata: &AdjustmentMetadata,
+ writer: &Address,
+ ) -> Result {
+ validate_identifier(account_id, "account_id")?;
+ validate_identifier(&metadata.reference, "adjustment_reference")?;
+ validate_identifier(&metadata.reason_code, "reason_code")?;
+ validate_identifier(&metadata.period_ref, "period_ref")?;
+ if credits == 0 { return Err(CostsError::InvalidField { field: "credits" }); }
+ self.require_writer_for_account(WriterRole::Adjustment, account_id, writer).await?;
+ validate_identifier(&metadata.approval_ref, "approval_ref")?;
+ validate_identifier(&metadata.audit_ref, "audit_ref")?;
+ let fingerprint = adjustment_fingerprint(kind, account_id, credits, metadata);
+ if let Some(existing) = self.db.rail_fingerprint(&metadata.reference).await? {
+ if existing == fingerprint { return Ok(false); }
+ return Err(CostsError::IdempotencyConflict { reference: metadata.reference.clone() });
+ }
+ let mut account = self.require_account(account_id).await?;
+ match kind {
+ AdjustmentKind::Grant => account.available_credits = account.available_credits.checked_add(credits).ok_or(CostsError::CreditOverflow)?,
+ AdjustmentKind::Reversal => {
+ let available = account.available_credits;
+ account.available_credits = available.checked_sub(credits).ok_or_else(|| CostsError::InsufficientCredits {
+ account_id: account_id.to_string(), available, required: credits,
+ })?;
+ }
+ }
+ self.db.set_account(account_id, account);
+ self.db.mark_rail(&metadata.reference, &fingerprint);
+ Ok(true)
+ }
+
+ async fn settle_reservation(
+ &mut self,
+ reservation_id: &str,
+ event_id: &str,
+ event_category: &str,
+ ) -> Result {
+ validate_identifier(reservation_id, "reservation_id")?;
+ validate_identifier(event_id, "event_id")?;
+ validate_identifier(event_category, "event_category")?;
+ let mut reservation = self
+ .db
+ .reservation(reservation_id)
+ .await?
+ .ok_or_else(|| CostsError::ReservationNotFound {
+ reservation_id: reservation_id.to_string(),
+ })?;
+ let fingerprint = reservation_event_fingerprint(reservation_id, event_id, event_category);
+ if let Some(existing) = self.db.event_fingerprint(event_id).await? {
+ if existing == fingerprint && reservation.status == ReservationStatus::Settled {
+ return Ok(false);
+ }
+ return Err(CostsError::IdempotencyConflict {
+ reference: event_id.to_string(),
+ });
+ }
+ if reservation.status != ReservationStatus::Active {
+ return Err(CostsError::ReservationNotActive {
+ reservation_id: reservation_id.to_string(),
+ });
+ }
+ let mut account = self.require_account(&reservation.account_id).await?;
+ if account.status == AccountStatus::Suspended {
+ return Err(CostsError::ReservationAccountSuspended {
+ reservation_id: reservation_id.to_string(),
+ });
+ }
+ account.reserved_credits = account
+ .reserved_credits
+ .checked_sub(reservation.credits)
+ .ok_or(CostsError::ReservedCreditUnderflow)?;
+ reservation.status = ReservationStatus::Settled;
+ self.db.set_account(&reservation.account_id, account);
+ self.db.set_reservation(reservation);
+ self.db.mark_event(event_id, &fingerprint);
+ Ok(true)
+ }
+
+ #[allow(clippy::too_many_arguments)]
+ async fn settle_reservation_v1(&mut self, reservation_id: &str, event_id: &str, event_category: &str, task_key: &str, quote_id: &str, snapshot_hash: &str, lineage_ref: &str, settled_at: u64) -> Result {
+ validate_identifier(quote_id, "quote_id")?; validate_identifier(snapshot_hash, "snapshot_hash")?; validate_identifier(lineage_ref, "lineage_ref")?;
+ if !task_key.is_empty() { validate_identifier(task_key, "task_key")?; }
+ let reservation = self.db.reservation(reservation_id).await?.ok_or_else(|| CostsError::ReservationNotFound { reservation_id: reservation_id.to_string() })?;
+ if reservation.quote.quote_id != quote_id || reservation.quote.snapshot_hash != snapshot_hash
+ || reservation.quote.event_category != event_category || reservation.quote.task_key != task_key
+ || reservation.lineage_ref != lineage_ref { return Err(CostsError::PinnedRateMismatch { event_id: event_id.to_string() }); }
+ if settled_at >= reservation.expires_at { return Err(CostsError::ReservationExpired { reservation_id: reservation_id.to_string(), expires_at: reservation.expires_at }); }
+ self.settle_reservation(reservation_id, event_id, event_category).await
+ }
+
+ #[allow(dead_code)]
+ async fn stage_rate_entries(
+ &mut self,
+ change_set_id: &str,
+ expected_entry_count: u16,
+ manifest_hash: &str,
+ entries: &[crate::RateCardEntry],
+ ) -> Result, CostsError> {
+ validate_identifier(change_set_id, "change_set_id")?;
+ validate_identifier(manifest_hash, "manifest_hash")?;
+ if expected_entry_count == 0 {
+ return Err(CostsError::InvalidField {
+ field: "expected_entry_count",
+ });
+ }
+ validate_rate_entries(entries)?;
+ let mut change_set = match self.db.rate_change_set(change_set_id).await? {
+ Some(change_set) => {
+ if change_set.expected_entry_count != expected_entry_count
+ || change_set.manifest_hash != manifest_hash
+ {
+ return Err(CostsError::RateChangeSetConflict {
+ change_set_id: change_set_id.to_string(),
+ });
+ }
+ change_set
+ }
+ None => crate::RateCardChangeSet {
+ change_set_id: change_set_id.to_string(),
+ expected_entry_count,
+ target_account_ids: Vec::new(),
+ expected_target_count: 0,
+ manifest_hash: manifest_hash.to_string(),
+ staged_entry_count: 0,
+ // The current command format predates the explicit V1 control
+ // plane envelope. Keep its approved manifest correlation
+ // deterministic until callers move to that envelope.
+ approval_ref: format!("approved:{manifest_hash}"),
+ audit_ref: "legacy".to_string(),
+ activation_epoch: 0,
+ applied: false,
+ },
+ };
+
+ let mut new_entries = Vec::new();
+ for entry in entries {
+ match self.db.staged_rate(change_set_id, entry).await? {
+ Some(existing) if existing == *entry => {}
+ Some(_) => {
+ return Err(CostsError::RateChangeSetConflict {
+ change_set_id: change_set_id.to_string(),
+ })
+ }
+ None => new_entries.push(entry.clone()),
+ }
+ }
+ let new_count = u16::try_from(new_entries.len()).map_err(|_| CostsError::RateCommandTooLarge {
+ actual: new_entries.len(),
+ maximum: MAX_RATE_ENTRIES_PER_COMMAND,
+ })?;
+ change_set.staged_entry_count = change_set
+ .staged_entry_count
+ .checked_add(new_count)
+ .ok_or(CostsError::CreditOverflow)?;
+ if change_set.staged_entry_count > expected_entry_count {
+ return Err(CostsError::RateChangeSetConflict {
+ change_set_id: change_set_id.to_string(),
+ });
+ }
+ for entry in &new_entries {
+ self.db.set_staged_rate(change_set_id, entry.clone());
+ }
+ self.db.set_rate_change_set(change_set);
+ Ok(new_entries)
+ }
+
+ async fn stage_rate_entries_v1(&mut self, envelope: &RateCardChangeSetV1, entries: &[crate::RateCardEntry]) -> Result, CostsError> {
+ validate_rate_change_set_v1(envelope)?;
+ validate_rate_entries(entries)?;
+ // A change set may be staged in bounded chunks; its target manifest is
+ // verified atomically at apply once every approved entry is present.
+ if entries.len() == envelope.expected_entry_count as usize {
+ self.validate_target_manifest(envelope, entries).await?;
+ self.validate_canonical_manifest(envelope, entries)?;
+ self.validate_activation_boundary(envelope, entries)?;
+ }
+ let high_watermark = self.db.activation_epoch_high_watermark().await?;
+ let mut change_set = match self.db.rate_change_set(&envelope.change_set_id).await? {
+ Some(existing) => {
+ if !same_rate_change_set_envelope(&existing, envelope) {
+ return Err(CostsError::RateChangeSetConflict { change_set_id: envelope.change_set_id.clone() });
+ }
+ existing
+ }
+ None => {
+ if envelope.activation_epoch <= high_watermark || envelope.applied {
+ return Err(CostsError::RateChangeSetConflict { change_set_id: envelope.change_set_id.clone() });
+ }
+ let mut staged = envelope.clone();
+ staged.staged_entry_count = 0;
+ staged.applied = false;
+ staged
+ }
+ };
+ let mut new_entries = Vec::new();
+ for entry in entries {
+ match self.db.staged_rate(&envelope.change_set_id, entry).await? {
+ Some(existing) if existing == *entry => {}
+ Some(_) => return Err(CostsError::RateChangeSetConflict { change_set_id: envelope.change_set_id.clone() }),
+ None => new_entries.push(entry.clone()),
+ }
+ }
+ let added = u16::try_from(new_entries.len()).map_err(|_| CostsError::RateCommandTooLarge { actual: new_entries.len(), maximum: MAX_RATE_ENTRIES_PER_COMMAND })?;
+ change_set.staged_entry_count = change_set.staged_entry_count.checked_add(added).ok_or(CostsError::CreditOverflow)?;
+ if change_set.staged_entry_count > change_set.expected_entry_count {
+ return Err(CostsError::RateChangeSetConflict { change_set_id: envelope.change_set_id.clone() });
+ }
+ for entry in &new_entries { self.db.set_staged_rate(&envelope.change_set_id, entry.clone()); }
+ self.db.set_rate_change_set(change_set);
+ Ok(new_entries)
+ }
+
+ async fn apply_rate_change_set(
+ &mut self,
+ change_set_id: &str,
+ manifest_hash: &str,
+ entries: &[crate::RateCardEntry],
+ ) -> Result, CostsError> {
+ validate_identifier(change_set_id, "change_set_id")?;
+ validate_identifier(manifest_hash, "manifest_hash")?;
+ validate_rate_entries(entries)?;
+ let change_set = self
+ .db
+ .rate_change_set(change_set_id)
+ .await?
+ .ok_or_else(|| CostsError::RateChangeSetNotFound {
+ change_set_id: change_set_id.to_string(),
+ })?;
+ if change_set.manifest_hash != manifest_hash
+ || entries.len() != change_set.expected_entry_count as usize
+ {
+ return Err(CostsError::RateChangeSetConflict {
+ change_set_id: change_set_id.to_string(),
+ });
+ }
+ if change_set.staged_entry_count != change_set.expected_entry_count {
+ return Err(CostsError::RateChangeSetIncomplete {
+ change_set_id: change_set_id.to_string(),
+ staged: change_set.staged_entry_count,
+ expected: change_set.expected_entry_count,
+ });
+ }
+ if change_set.applied {
+ return Ok(Vec::new());
+ }
+ let global_entries = entries.iter().filter(|entry| entry.account_id.is_empty()).count() as u64;
+ let global_count = self.db.global_rate_registry_count().await?;
+ if global_count.checked_add(global_entries).is_none_or(|count| count > MAX_GLOBAL_RATE_REVISIONS) {
+ return Err(CostsError::GlobalRateRegistryFull { maximum: MAX_GLOBAL_RATE_REVISIONS });
+ }
+ for entry in entries {
+ if self.db.staged_rate(change_set_id, entry).await? != Some(entry.clone()) {
+ return Err(CostsError::RateChangeSetConflict {
+ change_set_id: change_set_id.to_string(),
+ });
+ }
+ }
+ for entry in entries {
+ self.db.set_active_rate(entry.clone());
+ self.append_rate_history(entry.clone(), false).await?;
+ if entry.account_id.is_empty() {
+ self.append_global_rate_revision(entry.clone()).await?;
+ } else {
+ self.db.set_global_rate_materialized(&entry.account_id, &entry.event_category, &entry.task_key, false);
+ }
+ }
+ let mut applied = change_set;
+ applied.applied = true;
+ self.db.set_rate_change_set(applied);
+ let mut materialized = entries.to_vec();
+ for entry in entries.iter().filter(|entry| entry.account_id.is_empty()) {
+ materialized.extend(self.propagate_global_rate(entry).await?);
+ }
+ Ok(materialized)
+ }
+
+ async fn apply_rate_change_set_v1(&mut self, envelope: &RateCardChangeSetV1, entries: &[crate::RateCardEntry]) -> Result, CostsError> {
+ validate_rate_change_set_v1(envelope)?;
+ validate_rate_entries(entries)?;
+ self.validate_target_manifest(envelope, entries).await?;
+ self.validate_canonical_manifest(envelope, entries)?;
+ self.validate_activation_boundary(envelope, entries)?;
+ let stored = self.db.rate_change_set(&envelope.change_set_id).await?.ok_or_else(|| CostsError::RateChangeSetNotFound { change_set_id: envelope.change_set_id.clone() })?;
+ if !same_rate_change_set_envelope(&stored, envelope) { return Err(CostsError::RateChangeSetConflict { change_set_id: envelope.change_set_id.clone() }); }
+ if stored.applied { return Ok(Vec::new()); }
+ if envelope.applied || envelope.activation_epoch <= self.db.activation_epoch_high_watermark().await? { return Err(CostsError::RateChangeSetConflict { change_set_id: envelope.change_set_id.clone() }); }
+ let applied = self.apply_rate_change_set(&envelope.change_set_id, &envelope.manifest_hash, entries).await?;
+ self.db.set_activation_epoch_high_watermark(envelope.activation_epoch);
+ Ok(applied)
+ }
+
+ fn validate_canonical_manifest(&self, envelope: &RateCardChangeSetV1, entries: &[crate::RateCardEntry]) -> Result<(), CostsError> {
+ if envelope.manifest_hash != envelope.expected_manifest_hash(entries) {
+ return Err(CostsError::RateChangeSetConflict { change_set_id: envelope.change_set_id.clone() });
+ }
+ Ok(())
+ }
+
+ fn validate_activation_boundary(&self, envelope: &RateCardChangeSetV1, entries: &[crate::RateCardEntry]) -> Result<(), CostsError> {
+ // `activation_epoch` is a monotonic control-plane boundary, not wall
+ // clock time. Runtime/finality time validation belongs at the
+ // operator service integration boundary; this only prevents a command from
+ // declaring a rate before its own approved logical epoch.
+ if entries.iter().any(|entry| entry.effective_at < envelope.activation_epoch) {
+ return Err(CostsError::InvalidField { field: "rate_effective_at_before_activation_epoch" });
+ }
+ Ok(())
+ }
+
+ async fn append_rate_history(&mut self, entry: crate::RateCardEntry, global_materialization: bool) -> Result<(), CostsError> {
+ let count = self.db.rate_history_count(&entry.account_id, &entry.event_category, &entry.task_key).await?;
+ // Exact replay is normally caught by the changeset applied marker; the
+ // defensive scan also prevents a caller from duplicating a revision
+ // through a distinct change set.
+ for sequence in 0..count {
+ if self.db.rate_history_entry(&entry.account_id, &entry.event_category, &entry.task_key, sequence).await? == Some(entry.clone())
+ && self.db.rate_history_global_materialization(&entry.account_id, &entry.event_category, &entry.task_key, sequence).await? == global_materialization {
+ return Ok(());
+ }
+ }
+ self.db.set_rate_history_entry(entry.clone(), count);
+ self.db.set_rate_history_global_materialization(&entry.account_id, &entry.event_category, &entry.task_key, count, global_materialization);
+ self.db.set_rate_history_count(&entry.account_id, &entry.event_category, &entry.task_key, count.checked_add(1).ok_or(CostsError::NonceOverflow)?);
+ Ok(())
+ }
+
+ async fn validate_target_manifest(&self, envelope: &RateCardChangeSetV1, entries: &[crate::RateCardEntry]) -> Result<(), CostsError> {
+ let mut expected = BTreeSet::new();
+ let explicit = entries.iter().filter(|entry| !entry.account_id.is_empty()).map(|entry| (entry.account_id.clone(), entry.event_category.clone(), entry.task_key.clone())).collect::>();
+ for entry in entries {
+ if !entry.account_id.is_empty() { expected.insert(entry.account_id.clone()); continue; }
+ let count = self.db.account_registry_count().await?;
+ if count > MAX_REGISTERED_SITES { return Err(CostsError::AccountRegistryFull { maximum: MAX_REGISTERED_SITES }); }
+ for sequence in 0..count {
+ let Some(account_id) = self.db.account_registry_account(sequence).await? else { continue };
+ if explicit.contains(&(account_id.clone(), entry.event_category.clone(), entry.task_key.clone())) { continue; }
+ if self.global_rate_applies_to_account(&account_id, entry, entry.effective_at).await? { expected.insert(account_id); }
+ }
+ }
+ let supplied = envelope.target_account_ids.iter().cloned().collect::>();
+ if supplied.len() != envelope.target_account_ids.len() || supplied != expected || envelope.expected_target_count as usize != expected.len() {
+ return Err(CostsError::RateChangeSetConflict { change_set_id: envelope.change_set_id.clone() });
+ }
+ Ok(())
+ }
+
+ async fn append_account_to_registry(&mut self, account_id: &str) -> Result<(), CostsError> {
+ self.preflight_account_registry_append(account_id).await?;
+ let count = self.db.account_registry_count().await?;
+ for sequence in 0..count {
+ if self.db.account_registry_account(sequence).await?.as_deref() == Some(account_id) {
+ return Ok(());
+ }
+ }
+ self.db.set_account_registry_account(count, account_id);
+ self.db.set_account_registry_count(count + 1);
+ Ok(())
+ }
+
+ async fn preflight_account_registry_append(&self, account_id: &str) -> Result<(), CostsError> {
+ let count = self.db.account_registry_count().await?;
+ for sequence in 0..count {
+ if self.db.account_registry_account(sequence).await?.as_deref() == Some(account_id) { return Ok(()); }
+ }
+ if count >= MAX_REGISTERED_SITES { return Err(CostsError::AccountRegistryFull { maximum: MAX_REGISTERED_SITES }); }
+ Ok(())
+ }
+
+ async fn append_global_rate_revision(&mut self, entry: crate::RateCardEntry) -> Result<(), CostsError> {
+ let count = self.db.global_rate_registry_count().await?;
+ if count >= MAX_GLOBAL_RATE_REVISIONS { return Err(CostsError::GlobalRateRegistryFull { maximum: MAX_GLOBAL_RATE_REVISIONS }); }
+ self.db.set_global_rate_registry_entry(count, entry);
+ self.db.set_global_rate_registry_count(count + 1);
+ Ok(())
+ }
+
+ /// Materialize a newly-approved global key to every current account which
+ /// does not own an exact account/key override. The derived local history is
+ /// marked so later global updates replace it, while an explicit account rate
+ /// clears that marker and remains authoritative.
+ async fn propagate_global_rate(&mut self, global: &crate::RateCardEntry) -> Result, CostsError> {
+ let mut materialized = Vec::new();
+ let count = self.db.account_registry_count().await?;
+ if count > MAX_REGISTERED_SITES { return Err(CostsError::AccountRegistryFull { maximum: MAX_REGISTERED_SITES }); }
+ for sequence in 0..count {
+ let Some(account_id) = self.db.account_registry_account(sequence).await? else { continue };
+ if !self.global_rate_applies_to_account(&account_id, global, global.effective_at).await? { continue; }
+ let derived = crate::RateCardEntry { account_id: account_id.clone(), ..global.clone() };
+ self.db.set_active_rate(derived.clone());
+ self.append_rate_history(derived.clone(), true).await?;
+ self.db.set_global_rate_materialized(&account_id, &global.event_category, &global.task_key, true);
+ materialized.push(derived);
+ }
+ Ok(materialized)
+ }
+
+ async fn global_rate_applies_to_account(&self, account_id: &str, global: &crate::RateCardEntry, at: u64) -> Result {
+ // An explicit account/category is more specific than a global task/SKU;
+ // avoid even materializing the lower-priority global target, so the
+ // finality stream never falsely claims it became effective there.
+ if !global.task_key.is_empty()
+ && self.account_has_explicit_active_rate(account_id, &global.event_category, "", at).await? {
+ return Ok(false);
+ }
+ Ok(!self.account_has_explicit_active_rate(account_id, &global.event_category, &global.task_key, at).await?)
+ }
+
+ async fn account_has_explicit_active_rate(&self, account_id: &str, event_category: &str, task_key: &str, at: u64) -> Result {
+ let count = self.db.rate_history_count(account_id, event_category, task_key).await?;
+ for sequence in 0..count {
+ let Some(entry) = self.db.rate_history_entry(account_id, event_category, task_key, sequence).await? else { continue };
+ if self.db.rate_history_global_materialization(account_id, event_category, task_key, sequence).await? { continue; }
+ if entry.effective_at <= at && (entry.expires_at == 0 || at < entry.expires_at) { return Ok(true); }
+ }
+ Ok(false)
+ }
+
+ /// New account accounts inherit all global defaults effective at onboarding.
+ /// This writes local snapshots and emits rate mutation records, so clients
+ /// can reconcile account creation without querying global internals.
+ async fn materialize_active_global_rates(&mut self, account_id: &str, at: u64) -> Result, CostsError> {
+ let count = self.db.global_rate_registry_count().await?;
+ if count > MAX_GLOBAL_RATE_REVISIONS { return Err(CostsError::GlobalRateRegistryFull { maximum: MAX_GLOBAL_RATE_REVISIONS }); }
+ let mut selected = BTreeMap::<(String, String), crate::RateCardEntry>::new();
+ for sequence in 0..count {
+ let Some(entry) = self.db.global_rate_registry_entry(sequence).await? else { continue };
+ if entry.effective_at > at || (entry.expires_at != 0 && at >= entry.expires_at) { continue; }
+ let key = (entry.event_category.clone(), entry.task_key.clone());
+ if selected.get(&key).is_none_or(|current| entry.effective_at >= current.effective_at) { selected.insert(key, entry); }
+ }
+ let mut materialized = Vec::with_capacity(selected.len());
+ for (_, global) in selected {
+ let derived = crate::RateCardEntry { account_id: account_id.to_string(), ..global };
+ self.db.set_active_rate(derived.clone());
+ self.append_rate_history(derived.clone(), true).await?;
+ self.db.set_global_rate_materialized(account_id, &derived.event_category, &derived.task_key, true);
+ materialized.push(derived);
+ }
+ Ok(materialized)
+ }
+
+ fn advance_nonce(&mut self, account: &Address, expected: u64) -> Result<(), CostsError> {
+ let next = expected.checked_add(1).ok_or(CostsError::NonceOverflow)?;
+ self.db.set_nonce(account, next);
+ Ok(())
+ }
+
+ async fn apply_spend_batch(
+ &mut self,
+ records: &[crate::SpendRecordV1],
+ writer: &Address,
+ ) -> Result, CostsError> {
+ if records.is_empty() || records.len() > MAX_SPEND_RECORDS_PER_BATCH {
+ return Err(CostsError::BatchTooLarge {
+ actual: records.len(),
+ maximum: MAX_SPEND_RECORDS_PER_BATCH,
+ });
+ }
+
+ let mut accounts = BTreeMap::::new();
+ let mut unseen_events = BTreeMap::::new();
+ let mut new_records = Vec::new();
+
+ for record in records {
+ validate_identifier(&record.event_id, "event_id")?;
+ validate_identifier(&record.account_id, "account_id")?;
+ validate_identifier(&record.event_category, "event_category")?;
+ if record.credits == 0 {
+ return Err(CostsError::InvalidField { field: "credits" });
+ }
+ let fingerprint = spend_fingerprint(record);
+ if let Some(existing) = self.db.event_fingerprint(&record.event_id).await? {
+ if existing != fingerprint {
+ return Err(CostsError::IdempotencyConflict {
+ reference: record.event_id.clone(),
+ });
+ }
+ continue;
+ }
+ if let Some(existing) = unseen_events.insert(record.event_id.clone(), fingerprint.clone()) {
+ if existing != fingerprint {
+ return Err(CostsError::IdempotencyConflict {
+ reference: record.event_id.clone(),
+ });
+ }
+ continue;
+ }
+
+ self.require_writer_for_account(WriterRole::Ingest, &record.account_id, writer).await?;
+
+ self.verify_pinned_rate(record).await?;
+
+ let account = match accounts.get(&record.account_id) {
+ Some(account) => account.clone(),
+ None => self.require_account(&record.account_id).await?,
+ };
+ if account.status == AccountStatus::Suspended {
+ return Err(CostsError::AccountSuspended {
+ account_id: record.account_id.clone(),
+ });
+ }
+ let available = account.available_credits;
+ let next = available.checked_sub(record.credits).ok_or_else(|| {
+ CostsError::InsufficientCredits {
+ account_id: record.account_id.clone(),
+ available,
+ required: record.credits,
+ }
+ })?;
+ accounts.insert(
+ record.account_id.clone(),
+ CreditAccount {
+ available_credits: next,
+ ..account
+ },
+ );
+ new_records.push(record);
+ }
+
+ for (account_id, account) in accounts {
+ self.db.set_account(&account_id, account);
+ }
+ let applied_records = new_records.iter().map(|record| (*record).clone()).collect();
+ for record in new_records {
+ self.db.mark_event(&record.event_id, &spend_fingerprint(record));
+ }
+ Ok(applied_records)
+ }
+}
+
+fn validate_identifier(value: &str, field: &'static str) -> Result<(), CostsError> {
+ if value.is_empty()
+ || value.len() > 128
+ || !value
+ .bytes()
+ .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b':' | b'/'))
+ {
+ return Err(CostsError::InvalidField { field });
+ }
+ Ok(())
+}
+
+fn stored_value_error(error: crate::StoredValueError) -> CostsError {
+ CostsError::Storage(format!("stored value: {error}"))
+}
+
+fn empty_mutation(transaction_id: &str, kind: LedgerMutationKind) -> LedgerMutationV1 {
+ LedgerMutationV1 {
+ sequence: 0,
+ transaction_id: transaction_id.to_string(),
+ kind,
+ account_id: String::new(),
+ has_account: false,
+ account: CreditAccount::active(),
+ cohort_ref: String::new(),
+ source_ref: String::new(),
+ balance_direction: BalanceMutationDirection::None,
+ credit_delta: 0,
+ period_ref: String::new(),
+ reason_code: String::new(),
+ occurred_at: 0,
+ approval_ref: String::new(),
+ audit_ref: String::new(),
+ has_reservation: false,
+ reservation: Reservation {
+ reservation_id: String::new(), account_id: String::new(), lineage_ref: String::new(), quote: QuoteV1 { quote_id: String::new(), snapshot_hash: String::new(), account_id: String::new(), event_category: String::new(), task_key: String::new(), quoted_at: 0, credits_per_unit: 0, quantity: 0, total_credits: 0, policy_version: String::new(), rate_version: String::new(), expires_at: 0 }, credits: 0,
+ expires_at: 0, status: ReservationStatus::Active,
+ },
+ rate_change_set_id: String::new(),
+ has_rate: false,
+ rate: crate::RateCardEntry {
+ account_id: String::new(), event_category: String::new(), task_key: String::new(),
+ credits: 0, effective_at: 0, expires_at: 0,
+ policy_version: String::new(), rate_version: String::new(),
+ },
+ has_untracked_source: false,
+ untracked_source: UntrackedSourceV1 {
+ source_id: String::new(), reason_code: String::new(), owner_ref: String::new(),
+ period_ref: String::new(), provenance_ref: String::new(), coverage_code: String::new(),
+ confidence_code: String::new(), evidence_ref: String::new(), cohort_ref: String::new(),
+ },
+ has_rate_card_completion: false,
+ rate_card_completion: RateCardCompletionV1 {
+ change_set_id: String::new(), manifest_hash: String::new(), entry_count: 0, target_count: 0,
+ activation_epoch: 0, approval_ref: String::new(), audit_ref: String::new(), affected_rates: Vec::new(),
+ },
+ }
+}
+
+fn validate_rate_entries(entries: &[crate::RateCardEntry]) -> Result<(), CostsError> {
+ if entries.is_empty() || entries.len() > MAX_RATE_ENTRIES_PER_COMMAND {
+ return Err(CostsError::RateCommandTooLarge {
+ actual: entries.len(),
+ maximum: MAX_RATE_ENTRIES_PER_COMMAND,
+ });
+ }
+ let mut targets = BTreeSet::new();
+ for entry in entries {
+ if !entry.account_id.is_empty() {
+ validate_identifier(&entry.account_id, "account_id")?;
+ }
+ validate_identifier(&entry.event_category, "event_category")?;
+ if !entry.task_key.is_empty() {
+ validate_identifier(&entry.task_key, "task_key")?;
+ }
+ validate_identifier(&entry.policy_version, "policy_version")?;
+ validate_identifier(&entry.rate_version, "rate_version")?;
+ if entry.credits == 0 {
+ return Err(CostsError::InvalidField { field: "credits" });
+ }
+ if entry.expires_at != 0 && entry.expires_at <= entry.effective_at {
+ return Err(CostsError::InvalidField { field: "expires_at" });
+ }
+ let target = format!(
+ "{}\u{0}{}\u{0}{}",
+ entry.account_id, entry.event_category, entry.task_key
+ );
+ if !targets.insert(target) {
+ return Err(CostsError::RateChangeSetConflict {
+ change_set_id: "duplicate_rate_target".to_string(),
+ });
+ }
+ }
+ Ok(())
+}
+
+fn validate_rate_change_set_v1(change_set: &RateCardChangeSetV1) -> Result<(), CostsError> {
+ validate_identifier(&change_set.change_set_id, "change_set_id")?; validate_identifier(&change_set.manifest_hash, "manifest_hash")?;
+ validate_identifier(&change_set.approval_ref, "approval_ref")?; validate_identifier(&change_set.audit_ref, "audit_ref")?;
+ if change_set.activation_epoch == 0 || change_set.expected_entry_count == 0 || change_set.staged_entry_count != 0 || change_set.applied {
+ return Err(CostsError::InvalidField { field: "activation_epoch_or_entry_count" });
+ }
+ if change_set.expected_target_count as usize != change_set.target_account_ids.len()
+ || change_set.target_account_ids.windows(2).any(|pair| pair[0] >= pair[1]) {
+ return Err(CostsError::InvalidField { field: "target_account_ids" });
+ }
+ for account_id in &change_set.target_account_ids { validate_identifier(account_id, "target_account_id")?; }
+ Ok(())
+}
+
+fn same_rate_change_set_envelope(stored: &RateCardChangeSetV1, supplied: &RateCardChangeSetV1) -> bool {
+ stored.change_set_id == supplied.change_set_id
+ && stored.expected_entry_count == supplied.expected_entry_count
+ && stored.target_account_ids == supplied.target_account_ids
+ && stored.expected_target_count == supplied.expected_target_count
+ && stored.manifest_hash == supplied.manifest_hash
+ && stored.approval_ref == supplied.approval_ref
+ && stored.audit_ref == supplied.audit_ref
+ && stored.activation_epoch == supplied.activation_epoch
+}
+
+fn validate_reservation_metadata(metadata: &crate::ReservationActionMetadataV1) -> Result<(), CostsError> {
+ validate_identifier(&metadata.reason_code, "reason_code")?;
+ validate_identifier(&metadata.approval_ref, "approval_ref")?;
+ validate_identifier(&metadata.audit_ref, "audit_ref")?;
+ if metadata.occurred_at == 0 { return Err(CostsError::InvalidField { field: "occurred_at" }); }
+ Ok(())
+}
+
+fn validate_quote(quote: &QuoteV1) -> Result<(), CostsError> {
+ validate_identifier("e.quote_id, "quote_id")?; validate_identifier("e.snapshot_hash, "snapshot_hash")?; validate_identifier("e.account_id, "account_id")?; validate_identifier("e.event_category, "event_category")?; if !quote.task_key.is_empty() { validate_identifier("e.task_key, "task_key")?; }
+ validate_identifier("e.policy_version, "policy_version")?; validate_identifier("e.rate_version, "rate_version")?;
+ if quote.quantity == 0 || quote.credits_per_unit == 0 || quote.quoted_at == 0 || quote.expires_at <= quote.quoted_at { return Err(CostsError::InvalidField { field: "quote" }); }
+ Ok(())
+}
+
+fn quote_snapshot_hash(quote: &QuoteV1) -> String {
+ fingerprint(b"nunchi-costs/quote/v1", |buf| {
+ crate::types::write_identifier("e.account_id, buf); crate::types::write_identifier("e.event_category, buf); crate::types::write_identifier("e.task_key, buf); quote.quoted_at.write(buf); quote.credits_per_unit.write(buf); quote.quantity.write(buf); quote.total_credits.write(buf); crate::types::write_identifier("e.policy_version, buf); crate::types::write_identifier("e.rate_version, buf); quote.expires_at.write(buf);
+ })
+}
+
+fn fingerprint(domain: &[u8], write: impl FnOnce(&mut Vec)) -> String {
+ let mut bytes = Vec::with_capacity(domain.len() + 128);
+ (domain.len() as u16).write(&mut bytes);
+ bytes.extend_from_slice(domain);
+ write(&mut bytes);
+ Sha256::hash(&bytes).to_string()
+}
+
+fn topup_fingerprint(account_id: &str, credits: u64) -> String {
+ fingerprint(b"nunchi-costs/topup/v1", |buf| {
+ crate::types::write_identifier(account_id, buf);
+ credits.write(buf);
+ })
+}
+
+fn adjustment_fingerprint(
+ kind: AdjustmentKind,
+ account_id: &str,
+ credits: u64,
+ metadata: &AdjustmentMetadata,
+) -> String {
+ fingerprint(b"nunchi-costs/adjustment/v1", |buf| {
+ kind.write(buf);
+ crate::types::write_identifier(account_id, buf);
+ credits.write(buf);
+ metadata.write(buf);
+ })
+}
+
+fn reservation_event_fingerprint(
+ reservation_id: &str,
+ event_id: &str,
+ event_category: &str,
+) -> String {
+ fingerprint(b"nunchi-costs/reservation-settlement/v1", |buf| {
+ crate::types::write_identifier(reservation_id, buf);
+ crate::types::write_identifier(event_id, buf);
+ crate::types::write_identifier(event_category, buf);
+ })
+}
+
+fn spend_fingerprint(record: &crate::SpendRecordV1) -> String {
+ fingerprint(b"nunchi-costs/spend/v1", |buf| record.write(buf))
+}
+
+fn validate_untracked_source(source: &UntrackedSourceV1) -> Result<(), CostsError> {
+ validate_identifier(&source.source_id, "source_id")?;
+ validate_identifier(&source.reason_code, "reason_code")?;
+ validate_identifier(&source.owner_ref, "owner_ref")?;
+ validate_identifier(&source.period_ref, "period_ref")?;
+ validate_identifier(&source.provenance_ref, "provenance_ref")?;
+ validate_identifier(&source.coverage_code, "coverage_code")?;
+ validate_identifier(&source.confidence_code, "confidence_code")?;
+ validate_identifier(&source.evidence_ref, "evidence_ref")?;
+ if !source.cohort_ref.is_empty() {
+ validate_identifier(&source.cohort_ref, "cohort_ref")?;
+ }
+ Ok(())
+}
diff --git a/costs/src/lib.rs b/costs/src/lib.rs
new file mode 100644
index 0000000..3f02c11
--- /dev/null
+++ b/costs/src/lib.rs
@@ -0,0 +1,46 @@
+//! Custodial, account-scoped credit accounting for Nunchi chains.
+//!
+//! The module is intentionally generic: it manages opaque credit accounts,
+//! allowlisted backend writers, credits, and metered spend. Product-specific
+//! event decoding and billing rails remain off-chain.
+
+commonware_macros::stability_scope!(ALPHA {
+mod db;
+mod genesis;
+mod grant;
+mod ledger;
+mod outcome;
+pub mod stored_value;
+#[cfg(feature = "rpc")]
+pub mod rpc;
+mod transaction;
+mod types;
+#[cfg(test)]
+mod tests;
+
+pub use db::CostsDB;
+pub use genesis::CostsGenesis;
+pub use grant::{
+ campaign_grant_metadata, campaign_grant_ref, credit_grant_op, is_known_grant_reason,
+ is_non_revenue_grant, periodic_grant_metadata, periodic_grant_ref, ALL_GRANT_REASONS,
+ REASON_GOODWILL, REASON_INCLUDED_CREDITS, REASON_PROMOTION, REASON_TOPUP, REASON_TRIAL,
+};
+pub use ledger::{CostsError, CostsLedger};
+pub use outcome::{FinalizedOutcomeKind, FinalizedOutcomeV1};
+pub use stored_value::{
+ CreditGrantV2, CreditLotKind, CreditTopupV2, LotAllocationV1, RefundPaidLotV1,
+ StoredValueAccountReadV1, StoredValueError, StoredValueFinalityEventV1,
+ StoredValueFinalityPayloadV1, StoredValueLedger, StoredValueLotReadV1,
+ StoredValueReservationV1, StoredValueSpendV2,
+};
+pub use transaction::{CostsOperation, Transaction, TransactionPayload};
+pub use types::{
+ AccountReadV1, AdjustmentKind, AdjustmentMetadata, BalanceMutationDirection, LedgerMutationKind, LedgerMutationV1,
+ QuoteRequestV1, QuoteV1, RateCardChangeSet, RateCardChangeSetV1, RateCardCompletionV1, RateCardEntry,
+ Reservation, ReservationActionMetadataV1, ReservationStatus, CreditAccount, AccountProfile, AccountStatus, SpendRecordV1,
+ StatusChangeMetadataV1, StatusHistoryEntry, UntrackedSourceV1, WriterRole,
+};
+
+/// Domain separator used for costs transaction signatures and state keys.
+pub const COSTS_NAMESPACE: &[u8] = b"_NUNCHI_COSTS";
+});
diff --git a/costs/src/outcome.rs b/costs/src/outcome.rs
new file mode 100644
index 0000000..d80f0e8
--- /dev/null
+++ b/costs/src/outcome.rs
@@ -0,0 +1,292 @@
+use commonware_cryptography::sha256::Digest;
+
+use crate::{CostsOperation, LedgerMutationKind, LedgerMutationV1, RateCardCompletionV1, Transaction};
+
+/// A normalized state-transition result for a post-finality outbox. This is a
+/// read-model contract, not a transaction payload and contains no raw source
+/// data or client PII.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct FinalizedOutcomeV1 {
+ /// Stable outbox envelope schema; consumers dedupe on `outbox_event_id`.
+ pub schema_version: u16,
+ pub outbox_event_id: String,
+ pub event_type: &'static str,
+ pub transaction_id: Digest,
+ /// Hash of the immutable finalized transaction payload envelope.
+ pub payload_hash: Digest,
+ pub finalized_at: u64,
+ pub kind: FinalizedOutcomeKind,
+ pub account_id: Option,
+ pub source_ref: Option,
+ pub rate_change_set_id: Option,
+ /// Present only for the one durable completion mutation. The finality
+ /// outbox retains exact affected targets and policy/rate versions so
+ /// `pricing.rate_card_updated` never depends on reconstructing siblings.
+ pub rate_card_completion: Option,
+}
+
+/// Machine-readable category used by the finality consumer for idempotent
+/// projections and reconciliation.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum FinalizedOutcomeKind {
+ AccountRegistered,
+ AccountOnboarded,
+ WriterChanged,
+ CreditsAdded,
+ SpendRecorded,
+ AccountStatusChanged,
+ ReservationCreated,
+ ReservationReleased,
+ ReservationExpired,
+ ReservationSettled,
+ UntrackedSourceRegistered,
+ CreditAdjustmentApplied,
+ RateCardEntriesStaged,
+ RateCardApplied,
+ RateCardCompleted,
+}
+
+impl FinalizedOutcomeV1 {
+ /// Build a publishable event from a persisted ledger mutation after the
+ /// containing chain transaction is final. A successful idempotent replay
+ /// writes no mutation, therefore it cannot produce an outbox event.
+ pub fn from_finalized_mutation(
+ mutation: &LedgerMutationV1,
+ transaction_id: Digest,
+ finalized_at: u64,
+ ) -> Self {
+ let kind = FinalizedOutcomeKind::from_mutation_kind(mutation.kind);
+ Self {
+ schema_version: 1,
+ outbox_event_id: format!("ledger:{transaction_id}:{}", mutation.sequence),
+ event_type: kind.event_type(),
+ transaction_id,
+ payload_hash: transaction_id,
+ finalized_at,
+ kind,
+ account_id: (!mutation.account_id.is_empty()).then(|| mutation.account_id.clone()),
+ source_ref: (!mutation.source_ref.is_empty()).then(|| mutation.source_ref.clone()),
+ rate_change_set_id: (!mutation.rate_change_set_id.is_empty()).then(|| mutation.rate_change_set_id.clone()),
+ rate_card_completion: mutation.has_rate_card_completion.then(|| mutation.rate_card_completion.clone()),
+ }
+ }
+
+ /// Derive a finality-safe envelope only after the containing transaction has
+ /// been finalized by the chain runtime.
+ #[deprecated(note = "derive outbox events from persisted LedgerMutationV1 after finality")]
+ pub fn from_finalized_transaction(tx: &Transaction, finalized_at: u64) -> Self {
+ let (kind, account_id, source_ref, rate_change_set_id) = match &tx.payload.operation {
+ CostsOperation::RegisterAccount { account_id } => (
+ FinalizedOutcomeKind::AccountRegistered,
+ Some(account_id.clone()),
+ None,
+ None,
+ ),
+ CostsOperation::CreateAccount { account_id, external_ref, .. } => (
+ FinalizedOutcomeKind::AccountOnboarded,
+ Some(account_id.clone()),
+ Some(external_ref.clone()),
+ None,
+ ),
+ CostsOperation::SetWriter { .. } => {
+ (FinalizedOutcomeKind::WriterChanged, None, None, None)
+ }
+ CostsOperation::SetAccountWriter { account_id, .. } => {
+ (FinalizedOutcomeKind::WriterChanged, Some(account_id.clone()), None, None)
+ }
+ CostsOperation::RotateAdmin { .. } => {
+ (FinalizedOutcomeKind::WriterChanged, None, None, None)
+ }
+ CostsOperation::CreditTopup { account_id, rail_ref, .. } => (
+ FinalizedOutcomeKind::CreditsAdded,
+ Some(account_id.clone()),
+ Some(rail_ref.clone()),
+ None,
+ ),
+ CostsOperation::StoredValueTopupV2 { topup } => (
+ FinalizedOutcomeKind::CreditsAdded,
+ Some(topup.account_id.clone()),
+ Some(topup.rail_ref.clone()),
+ None,
+ ),
+ CostsOperation::StoredValueGrantV2 { grant } => (
+ FinalizedOutcomeKind::CreditAdjustmentApplied,
+ Some(grant.account_id.clone()),
+ Some(grant.reference.clone()),
+ None,
+ ),
+ CostsOperation::StoredValueSpendV2 { spend } => (
+ FinalizedOutcomeKind::SpendRecorded,
+ Some(spend.account_id.clone()),
+ Some(spend.event_id.clone()),
+ None,
+ ),
+ CostsOperation::RefundPaidLotV1 { refund } => (
+ FinalizedOutcomeKind::CreditAdjustmentApplied,
+ Some(refund.account_id.clone()),
+ Some(refund.refund_rail_ref.clone()),
+ None,
+ ),
+ CostsOperation::ReserveStoredValueV2 { reservation_id, account_id, .. } => (
+ FinalizedOutcomeKind::ReservationCreated,
+ Some(account_id.clone()),
+ Some(reservation_id.clone()),
+ None,
+ ),
+ CostsOperation::ReleaseStoredValueReservationV2 { reservation_id, .. } => (
+ FinalizedOutcomeKind::ReservationReleased,
+ None,
+ Some(reservation_id.clone()),
+ None,
+ ),
+ CostsOperation::ExpireStoredValueReservationV2 { reservation_id, .. } => (
+ FinalizedOutcomeKind::ReservationExpired,
+ None,
+ Some(reservation_id.clone()),
+ None,
+ ),
+ CostsOperation::SettleStoredValueReservationV2 { reservation_id, spend } => (
+ FinalizedOutcomeKind::ReservationSettled,
+ Some(spend.account_id.clone()),
+ Some(format!("{reservation_id}:{}", spend.event_id)),
+ None,
+ ),
+ CostsOperation::CreditGrant { account_id, grant_ref, .. } => (
+ FinalizedOutcomeKind::CreditsAdded,
+ Some(account_id.clone()),
+ Some(grant_ref.clone()),
+ None,
+ ),
+ CostsOperation::CreditReversal { account_id, reversal_ref, .. } => (
+ FinalizedOutcomeKind::CreditsAdded,
+ Some(account_id.clone()),
+ Some(reversal_ref.clone()),
+ None,
+ ),
+ CostsOperation::RecordSpendBatch { .. } => {
+ (FinalizedOutcomeKind::SpendRecorded, None, None, None)
+ }
+ CostsOperation::SetAccountStatus { account_id, .. } => (
+ FinalizedOutcomeKind::AccountStatusChanged,
+ Some(account_id.clone()),
+ None,
+ None,
+ ),
+ CostsOperation::SetAccountStatusV1 { account_id, .. } => (FinalizedOutcomeKind::AccountStatusChanged, Some(account_id.clone()), None, None),
+ CostsOperation::ReserveCredits { reservation_id, account_id, .. } => (
+ FinalizedOutcomeKind::ReservationCreated,
+ Some(account_id.clone()),
+ Some(reservation_id.clone()),
+ None,
+ ),
+ CostsOperation::ReserveCreditsV1 { reservation_id, quote, .. } => (FinalizedOutcomeKind::ReservationCreated, Some(quote.account_id.clone()), Some(reservation_id.clone()), None),
+ CostsOperation::ReleaseReservation { reservation_id } => (
+ FinalizedOutcomeKind::ReservationReleased,
+ None,
+ Some(reservation_id.clone()),
+ None,
+ ),
+ CostsOperation::ExpireReservation { reservation_id, .. } => (
+ FinalizedOutcomeKind::ReservationExpired,
+ None,
+ Some(reservation_id.clone()),
+ None,
+ ),
+ CostsOperation::ReleaseReservationV1 { reservation_id, .. } => (
+ FinalizedOutcomeKind::ReservationReleased, None, Some(reservation_id.clone()), None,
+ ),
+ CostsOperation::ExpireReservationV1 { reservation_id, .. } => (
+ FinalizedOutcomeKind::ReservationExpired, None, Some(reservation_id.clone()), None,
+ ),
+ CostsOperation::SettleSpend { reservation_id, event_id, .. } => (
+ FinalizedOutcomeKind::ReservationSettled,
+ None,
+ Some(format!("{reservation_id}:{event_id}")),
+ None,
+ ),
+ CostsOperation::SettleSpendV1 { reservation_id, event_id, .. } => (FinalizedOutcomeKind::ReservationSettled, None, Some(format!("{reservation_id}:{event_id}")), None),
+ CostsOperation::RegisterUntrackedSource { source_id, .. } => (
+ FinalizedOutcomeKind::UntrackedSourceRegistered,
+ None,
+ Some(source_id.clone()),
+ None,
+ ),
+ CostsOperation::RegisterUntrackedSourceV1 { source } => (
+ FinalizedOutcomeKind::UntrackedSourceRegistered,
+ None,
+ Some(source.source_id.clone()),
+ None,
+ ),
+ CostsOperation::CreditAdjustmentV1 { account_id, metadata, .. } => (
+ FinalizedOutcomeKind::CreditAdjustmentApplied,
+ Some(account_id.clone()),
+ Some(metadata.reference.clone()),
+ None,
+ ),
+ CostsOperation::StageRateCardEntries { change_set_id, .. } => (
+ FinalizedOutcomeKind::RateCardEntriesStaged,
+ None,
+ None,
+ Some(change_set_id.clone()),
+ ),
+ CostsOperation::ApplyRateCardChangeSet { change_set_id, .. } => (
+ FinalizedOutcomeKind::RateCardApplied,
+ None,
+ None,
+ Some(change_set_id.clone()),
+ ),
+ CostsOperation::StageRateCardChangeSetV1 { change_set, .. } => (FinalizedOutcomeKind::RateCardEntriesStaged, None, Some(change_set.approval_ref.clone()), Some(change_set.change_set_id.clone())),
+ CostsOperation::ApplyRateCardChangeSetV1 { change_set, .. } => (FinalizedOutcomeKind::RateCardApplied, None, Some(change_set.approval_ref.clone()), Some(change_set.change_set_id.clone())),
+ };
+ let transaction_id = tx.digest();
+ Self {
+ schema_version: 1,
+ outbox_event_id: format!("ledger:{transaction_id}"),
+ event_type: kind.event_type(),
+ transaction_id,
+ payload_hash: transaction_id,
+ finalized_at,
+ kind,
+ account_id,
+ source_ref,
+ rate_change_set_id,
+ rate_card_completion: None,
+ }
+ }
+}
+
+impl FinalizedOutcomeKind {
+ pub const fn from_mutation_kind(kind: LedgerMutationKind) -> Self {
+ match kind {
+ LedgerMutationKind::AccountOnboarded => Self::AccountOnboarded,
+ LedgerMutationKind::BalanceChanged => Self::CreditAdjustmentApplied,
+ LedgerMutationKind::SpendRecorded => Self::SpendRecorded,
+ LedgerMutationKind::AccountStatusChanged => Self::AccountStatusChanged,
+ LedgerMutationKind::ReservationChanged => Self::ReservationCreated,
+ LedgerMutationKind::RateCardStaged => Self::RateCardEntriesStaged,
+ LedgerMutationKind::RateCardApplied => Self::RateCardApplied,
+ LedgerMutationKind::RateCardGlobalApplied => Self::RateCardApplied,
+ LedgerMutationKind::RateCardCompleted => Self::RateCardCompleted,
+ LedgerMutationKind::UntrackedSourceRegistered => Self::UntrackedSourceRegistered,
+ }
+ }
+ /// Stable event-bus type for post-finality consumers. This output cannot
+ /// authorize a debit and is intentionally distinct from chain payloads.
+ pub const fn event_type(self) -> &'static str {
+ match self {
+ Self::AccountRegistered | Self::AccountOnboarded => "ledger.account_registered",
+ Self::WriterChanged => "ledger.writer_changed",
+ Self::CreditsAdded | Self::CreditAdjustmentApplied => "ledger.balance_changed",
+ Self::SpendRecorded => "ledger.spend_recorded",
+ Self::AccountStatusChanged => "ledger.account_status_changed",
+ Self::ReservationCreated => "ledger.reservation_created",
+ Self::ReservationReleased => "ledger.reservation_released",
+ Self::ReservationExpired => "ledger.reservation_expired",
+ Self::ReservationSettled => "ledger.reservation_settled",
+ Self::UntrackedSourceRegistered => "ledger.untracked_source_registered",
+ Self::RateCardEntriesStaged => "ledger.rate_card_staged",
+ Self::RateCardApplied => "ledger.rate_card_applied",
+ Self::RateCardCompleted => "pricing.rate_card_updated",
+ }
+ }
+}
diff --git a/costs/src/rpc.rs b/costs/src/rpc.rs
new file mode 100644
index 0000000..effa273
--- /dev/null
+++ b/costs/src/rpc.rs
@@ -0,0 +1,74 @@
+//! Bounded, server-only read surface for authorized costs projections.
+//!
+//! This trait intentionally exposes no transaction, signer, writer, or
+//! mutation capability. A BFF must authorize a account before calling it.
+
+use jsonrpsee::{core::{async_trait, RegisterMethodError, RpcResult}, proc_macros::rpc};
+use nunchi_rpc::{module_error, RpcRouter};
+use serde::{Deserialize, Serialize};
+
+use crate::{AccountReadV1, CostsError, LedgerMutationV1, QuoteV1, RateCardEntry, Reservation, AccountProfile};
+
+pub const MAX_READ_PAGE: u16 = 100;
+
+#[async_trait]
+pub trait CostsQuery: Clone + Send + Sync + 'static {
+ async fn account_read(&self, account_id: String) -> Result, CostsError>;
+ async fn profile(&self, account_id: String) -> Result , CostsError>;
+ async fn quote(&self, account_id: String, event_category: String, task_key: String, quantity: u64, quoted_at: u64, expires_at: u64) -> Result , CostsError>;
+ async fn reservation(&self, reservation_id: String) -> Result , CostsError>;
+ async fn journal(&self, from_sequence: u64, limit: u16) -> Result, CostsError>;
+ async fn active_rate(&self, account_id: String, event_category: String, task_key: String, price_at: u64) -> Result, CostsError>;
+}
+
+#[derive(Clone)]
+pub struct CostsRpc { query: Q }
+impl CostsRpc { pub fn new(query: Q) -> Self { Self { query } } }
+
+#[rpc(server, namespace = "costs", namespace_separator = ".")]
+pub trait Costs {
+ #[method(name = "account_read", param_kind = map)]
+ async fn account_read(&self, account_id: String) -> RpcResult>;
+ #[method(name = "profile", param_kind = map)]
+ async fn profile(&self, account_id: String) -> RpcResult >;
+ #[method(name = "quote", param_kind = map)]
+ async fn quote(&self, account_id: String, event_category: String, task_key: String, quantity: u64, quoted_at: u64, expires_at: u64) -> RpcResult >;
+ #[method(name = "reservation", param_kind = map)]
+ async fn reservation(&self, reservation_id: String) -> RpcResult >;
+ #[method(name = "journal", param_kind = map)]
+ async fn journal(&self, from_sequence: u64, limit: u16) -> RpcResult>;
+ #[method(name = "active_rate", param_kind = map)]
+ async fn active_rate(&self, account_id: String, event_category: String, task_key: String, price_at: u64) -> RpcResult>;
+}
+
+#[async_trait]
+impl CostsServer for CostsRpc {
+ async fn account_read(&self, account_id: String) -> RpcResult> { Ok(self.query.account_read(account_id).await.map_err(rpc_error)?.map(Into::into)) }
+ async fn profile(&self, account_id: String) -> RpcResult > { Ok(self.query.profile(account_id).await.map_err(rpc_error)?.map(Into::into)) }
+ async fn quote(&self, account_id: String, event_category: String, task_key: String, quantity: u64, quoted_at: u64, expires_at: u64) -> RpcResult > { Ok(self.query.quote(account_id, event_category, task_key, quantity, quoted_at, expires_at).await.map_err(rpc_error)?.map(Into::into)) }
+ async fn reservation(&self, reservation_id: String) -> RpcResult > { Ok(self.query.reservation(reservation_id).await.map_err(rpc_error)?.map(Into::into)) }
+ async fn journal(&self, from_sequence: u64, limit: u16) -> RpcResult> { if limit == 0 || limit > MAX_READ_PAGE { return Err(module_error("invalid journal limit")); } Ok(self.query.journal(from_sequence, limit).await.map_err(rpc_error)?.into_iter().map(Into::into).collect()) }
+ async fn active_rate(&self, account_id: String, event_category: String, task_key: String, price_at: u64) -> RpcResult> { Ok(self.query.active_rate(account_id, event_category, task_key, price_at).await.map_err(rpc_error)?.map(Into::into)) }
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+pub struct AccountReadResponse { pub status: String, pub available_credits: u64, pub reserved_credits: u64, pub policy_ref: String, pub cohort_ref: String }
+impl From for AccountReadResponse { fn from(v: AccountReadV1) -> Self { Self { status: format!("{:?}", v.account.status).to_lowercase(), available_credits: v.account.available_credits, reserved_credits: v.account.reserved_credits, policy_ref: v.profile.policy_ref, cohort_ref: v.profile.cohort_ref } } }
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+pub struct ProfileResponse { pub account_id: String, pub external_ref: String, pub policy_ref: String, pub cohort_ref: String, pub created_at: u64, pub status_reason: String, pub status_changed_at: u64 }
+impl From for ProfileResponse { fn from(v: AccountProfile) -> Self { Self { account_id: v.account_id, external_ref: v.external_ref, policy_ref: v.policy_ref, cohort_ref: v.cohort_ref, created_at: v.created_at, status_reason: v.status_reason, status_changed_at: v.status_changed_at } } }
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+pub struct QuoteResponse { pub quote_id: String, pub snapshot_hash: String, pub account_id: String, pub event_category: String, pub task_key: String, pub total_credits: u64, pub quantity: u64, pub expires_at: u64, pub policy_version: String, pub rate_version: String }
+impl From for QuoteResponse { fn from(v: QuoteV1) -> Self { Self { quote_id: v.quote_id, snapshot_hash: v.snapshot_hash, account_id: v.account_id, event_category: v.event_category, task_key: v.task_key, total_credits: v.total_credits, quantity: v.quantity, expires_at: v.expires_at, policy_version: v.policy_version, rate_version: v.rate_version } } }
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+pub struct ReservationResponse { pub reservation_id: String, pub account_id: String, pub quote_id: String, pub snapshot_hash: String, pub lineage_ref: String, pub credits: u64, pub expires_at: u64, pub status: String }
+impl From for ReservationResponse { fn from(v: Reservation) -> Self { Self { reservation_id: v.reservation_id, account_id: v.account_id, quote_id: v.quote.quote_id, snapshot_hash: v.quote.snapshot_hash, lineage_ref: v.lineage_ref, credits: v.credits, expires_at: v.expires_at, status: format!("{:?}", v.status).to_lowercase() } } }
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+pub struct RateResponse { pub account_id: String, pub event_category: String, pub task_key: String, pub credits: u64, pub effective_at: u64, pub expires_at: u64, pub policy_version: String, pub rate_version: String }
+impl From for RateResponse { fn from(v: RateCardEntry) -> Self { Self { account_id: v.account_id, event_category: v.event_category, task_key: v.task_key, credits: v.credits, effective_at: v.effective_at, expires_at: v.expires_at, policy_version: v.policy_version, rate_version: v.rate_version } } }
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+pub struct JournalResponse { pub sequence: u64, pub transaction_id: String, pub kind: String, pub account_id: String, pub reason_code: String, pub occurred_at: u64, pub approval_ref: String, pub audit_ref: String }
+impl From for JournalResponse { fn from(v: LedgerMutationV1) -> Self { Self { sequence: v.sequence, transaction_id: v.transaction_id, kind: format!("{:?}", v.kind), account_id: v.account_id, reason_code: v.reason_code, occurred_at: v.occurred_at, approval_ref: v.approval_ref, audit_ref: v.audit_ref } } }
+
+pub fn register(router: &mut RpcRouter, rpc: CostsRpc) -> Result<(), RegisterMethodError> where Q: CostsQuery { router.merge(rpc.into_rpc()) }
+fn rpc_error(error: CostsError) -> jsonrpsee::types::ErrorObjectOwned { module_error(error.to_string()) }
diff --git a/costs/src/stored_value.rs b/costs/src/stored_value.rs
new file mode 100644
index 0000000..61e419c
--- /dev/null
+++ b/costs/src/stored_value.rs
@@ -0,0 +1,245 @@
+//! Clean-state V2 provenance ledger for refundable B2B stored value.
+//!
+//! Paid lots refund only to their original rail; grant lots are non-refundable;
+//! spend deterministically consumes grants before paid credit.
+
+use std::collections::BTreeMap;
+use commonware_codec::{EncodeSize, Error as CodecError, Read, ReadExt, Write};
+use serde::{Deserialize, Serialize};
+use thiserror::Error;
+use crate::types::{identifier_encode_size, read_identifier, write_identifier};
+
+#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
+pub enum CreditLotKind { Grant, Paid }
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+struct CreditLotV2 { lot_id: String, account_id: String, kind: CreditLotKind, remaining: u64, reserved: u64, issued_at: u64, expires_at: u64, period_ref: String, amount_usd_cents: u64, base_credits: u64, bonus_credits: u64, terms_version: String, reason_code: String }
+
+/// Immutable paid purchase. `rail_ref` is both idempotency key and lot ID.
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+pub struct CreditTopupV2 { pub account_id: String, pub rail_ref: String, pub amount_usd_cents: u64, pub base_credits: u64, pub bonus_credits: u64, pub purchased_at: u64, pub terms_version: String }
+
+/// Immutable non-refundable periodic program/campaign grant with a policy-defined expiry.
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+pub struct CreditGrantV2 { pub account_id: String, pub reference: String, pub credits: u64, pub reason_code: String, pub period_ref: String, pub issued_at: u64, pub expires_at: u64, pub approval_ref: String, pub audit_ref: String }
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+pub struct StoredValueSpendV2 { pub event_id: String, pub account_id: String, pub credits: u64, pub occurred_at: u64 }
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+pub struct LotAllocationV1 { pub lot_id: String, pub kind: CreditLotKind, pub credits: u64 }
+
+#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
+pub enum StoredValueReservationStatus { Active, Released, Settled, Expired }
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+pub struct StoredValueReservationV1 { pub reservation_id: String, pub account_id: String, pub credits: u64, pub expires_at: u64, pub allocations: Vec, pub status: StoredValueReservationStatus }
+
+/// Support-mediated reversal of unused paid credit to the original rail.
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+pub struct RefundPaidLotV1 { pub account_id: String, pub rail_ref: String, pub refund_rail_ref: String, pub credits: u64, pub requested_at: u64, pub reason_code: String, pub approval_ref: String, pub audit_ref: String }
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+pub struct StoredValueAccountReadV1 { pub account_id: String, pub paid_available_credits: u64, pub grant_available_credits: u64, pub reserved_credits: u64, pub refundable_paid_credits: u64, pub included_period_consumed: u64, pub reset_at: u64 }
+
+/// Immutable ledger payload retained in chain state for a finality adapter.
+/// It is never a transaction input and is published only after the containing
+/// transaction reaches chain finality.
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+pub enum StoredValueFinalityPayloadV1 {
+ Topup(CreditTopupV2),
+ Grant(CreditGrantV2),
+ Spend { spend: StoredValueSpendV2, allocations: Vec },
+ Refund(RefundPaidLotV1),
+}
+
+/// Append-only bridge from the V2 chain ledger to post-finality downstream warehouse, accounting export,
+/// and client-safe projections. This is state output, not a chain command.
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+pub struct StoredValueFinalityEventV1 {
+ pub sequence: u64,
+ pub event_key: String,
+ pub transaction_id: String,
+ pub account_id: String,
+ pub payload: StoredValueFinalityPayloadV1,
+}
+
+/// Private BFF/sink lot history. It contains no payment credentials or PII.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct StoredValueLotReadV1 { pub lot_id: String, pub account_id: String, pub kind: CreditLotKind, pub amount_usd_cents: u64, pub base_credits: u64, pub bonus_credits: u64, pub remaining_credits: u64, pub reserved_credits: u64, pub issued_at: u64, pub expires_at: u64, pub terms_version: String, pub reason_code: String, pub period_ref: String }
+
+#[derive(Clone, Debug, Error, Eq, PartialEq)]
+pub enum StoredValueError {
+ #[error("invalid {0}")] Invalid(&'static str),
+ #[error("account {0} was not initialized for stored value")] AccountNotFound(String),
+ #[error("idempotency conflict for {0}")] IdempotencyConflict(String),
+ #[error("insufficient credits: available {available}, required {required}")] InsufficientCredits { available: u64, required: u64 },
+ #[error("paid lot {0} was not found")] PaidLotNotFound(String),
+ #[error("paid lot {0} has reserved credits")] PaidLotReserved(String),
+ #[error("refund {refund_id} exceeds unused paid credit in {rail_ref}")] RefundExceedsUnused { refund_id: String, rail_ref: String },
+ #[error("reservation {0} was not found")] ReservationNotFound(String),
+ #[error("reservation {0} is not active")] ReservationNotActive(String),
+ #[error("reservation {0} expired")] ReservationExpired(String),
+ #[error("arithmetic overflow")] Overflow,
+}
+
+/// Deterministic V2 economic state. Network, signer, downstream warehouse, and UI adapters are
+/// deliberately outside this state machine.
+#[derive(Clone, Debug, Default, Deserialize, Serialize)]
+pub struct StoredValueLedger {
+ accounts: BTreeMap, lots: BTreeMap,
+ spends: BTreeMap)>,
+ reservations: BTreeMap,
+ refunds: BTreeMap, consumed_by_lot: BTreeMap,
+ finality_events: Vec,
+}
+
+impl Write for CreditTopupV2 { fn write(&self, buf: &mut impl bytes::BufMut) { write_identifier(&self.account_id, buf); write_identifier(&self.rail_ref, buf); self.amount_usd_cents.write(buf); self.base_credits.write(buf); self.bonus_credits.write(buf); self.purchased_at.write(buf); write_identifier(&self.terms_version, buf); } }
+impl Read for CreditTopupV2 { type Cfg = (); fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { Ok(Self { account_id: read_identifier(buf)?, rail_ref: read_identifier(buf)?, amount_usd_cents: u64::read(buf)?, base_credits: u64::read(buf)?, bonus_credits: u64::read(buf)?, purchased_at: u64::read(buf)?, terms_version: read_identifier(buf)? }) } }
+impl EncodeSize for CreditTopupV2 { fn encode_size(&self) -> usize { identifier_encode_size(&self.account_id) + identifier_encode_size(&self.rail_ref) + self.amount_usd_cents.encode_size() + self.base_credits.encode_size() + self.bonus_credits.encode_size() + self.purchased_at.encode_size() + identifier_encode_size(&self.terms_version) } }
+
+impl Write for CreditGrantV2 { fn write(&self, buf: &mut impl bytes::BufMut) { write_identifier(&self.account_id, buf); write_identifier(&self.reference, buf); self.credits.write(buf); write_identifier(&self.reason_code, buf); write_identifier(&self.period_ref, buf); self.issued_at.write(buf); self.expires_at.write(buf); write_identifier(&self.approval_ref, buf); write_identifier(&self.audit_ref, buf); } }
+impl Read for CreditGrantV2 { type Cfg = (); fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { Ok(Self { account_id: read_identifier(buf)?, reference: read_identifier(buf)?, credits: u64::read(buf)?, reason_code: read_identifier(buf)?, period_ref: read_identifier(buf)?, issued_at: u64::read(buf)?, expires_at: u64::read(buf)?, approval_ref: read_identifier(buf)?, audit_ref: read_identifier(buf)? }) } }
+impl EncodeSize for CreditGrantV2 { fn encode_size(&self) -> usize { identifier_encode_size(&self.account_id) + identifier_encode_size(&self.reference) + self.credits.encode_size() + identifier_encode_size(&self.reason_code) + identifier_encode_size(&self.period_ref) + self.issued_at.encode_size() + self.expires_at.encode_size() + identifier_encode_size(&self.approval_ref) + identifier_encode_size(&self.audit_ref) } }
+
+impl Write for StoredValueSpendV2 { fn write(&self, buf: &mut impl bytes::BufMut) { write_identifier(&self.event_id, buf); write_identifier(&self.account_id, buf); self.credits.write(buf); self.occurred_at.write(buf); } }
+impl Read for StoredValueSpendV2 { type Cfg = (); fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { Ok(Self { event_id: read_identifier(buf)?, account_id: read_identifier(buf)?, credits: u64::read(buf)?, occurred_at: u64::read(buf)? }) } }
+impl EncodeSize for StoredValueSpendV2 { fn encode_size(&self) -> usize { identifier_encode_size(&self.event_id) + identifier_encode_size(&self.account_id) + self.credits.encode_size() + self.occurred_at.encode_size() } }
+
+impl Write for RefundPaidLotV1 { fn write(&self, buf: &mut impl bytes::BufMut) { write_identifier(&self.account_id, buf); write_identifier(&self.rail_ref, buf); write_identifier(&self.refund_rail_ref, buf); self.credits.write(buf); self.requested_at.write(buf); write_identifier(&self.reason_code, buf); write_identifier(&self.approval_ref, buf); write_identifier(&self.audit_ref, buf); } }
+impl Read for RefundPaidLotV1 { type Cfg = (); fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { Ok(Self { account_id: read_identifier(buf)?, rail_ref: read_identifier(buf)?, refund_rail_ref: read_identifier(buf)?, credits: u64::read(buf)?, requested_at: u64::read(buf)?, reason_code: read_identifier(buf)?, approval_ref: read_identifier(buf)?, audit_ref: read_identifier(buf)? }) } }
+impl EncodeSize for RefundPaidLotV1 { fn encode_size(&self) -> usize { identifier_encode_size(&self.account_id) + identifier_encode_size(&self.rail_ref) + identifier_encode_size(&self.refund_rail_ref) + self.credits.encode_size() + self.requested_at.encode_size() + identifier_encode_size(&self.reason_code) + identifier_encode_size(&self.approval_ref) + identifier_encode_size(&self.audit_ref) } }
+
+impl StoredValueLedger {
+ pub fn onboard(&mut self, account_id: &str) -> Result<(), StoredValueError> { validate(account_id, "account_id")?; self.accounts.insert(account_id.into(), ()); Ok(()) }
+
+ pub fn credit_topup(&mut self, topup: CreditTopupV2) -> Result<(), StoredValueError> {
+ self.require_account(&topup.account_id)?; validate(&topup.rail_ref, "rail_ref")?; validate(&topup.terms_version, "terms_version")?;
+ if topup.amount_usd_cents == 0 || topup.purchased_at == 0 { return Err(StoredValueError::Invalid("purchase_amount_or_time")); }
+ let total = topup.base_credits.checked_add(topup.bonus_credits).ok_or(StoredValueError::Overflow)?;
+ if total == 0 { return Err(StoredValueError::Invalid("total_credits")); }
+ self.insert_lot(topup.rail_ref.clone(), CreditLotV2 { lot_id: topup.rail_ref, account_id: topup.account_id, kind: CreditLotKind::Paid, remaining: total, reserved: 0, issued_at: topup.purchased_at, expires_at: 0, period_ref: String::new(), amount_usd_cents: topup.amount_usd_cents, base_credits: topup.base_credits, bonus_credits: topup.bonus_credits, terms_version: topup.terms_version, reason_code: "topup".into() })
+ }
+
+ pub fn credit_grant(&mut self, grant: CreditGrantV2) -> Result<(), StoredValueError> {
+ self.require_account(&grant.account_id)?;
+ for (value, field) in [(&grant.reference, "grant_reference"), (&grant.reason_code, "grant_reason"), (&grant.period_ref, "grant_period"), (&grant.approval_ref, "approval_ref"), (&grant.audit_ref, "audit_ref")] { validate(value, field)?; }
+ if grant.credits == 0 || grant.issued_at == 0 || grant.expires_at <= grant.issued_at { return Err(StoredValueError::Invalid("grant_credits_or_expiry")); }
+ self.insert_lot(grant.reference.clone(), CreditLotV2 { lot_id: grant.reference, account_id: grant.account_id, kind: CreditLotKind::Grant, remaining: grant.credits, reserved: 0, issued_at: grant.issued_at, expires_at: grant.expires_at, period_ref: grant.period_ref, amount_usd_cents: 0, base_credits: grant.credits, bonus_credits: 0, terms_version: String::new(), reason_code: grant.reason_code })
+ }
+
+ pub fn record_spend(&mut self, spend: StoredValueSpendV2) -> Result, StoredValueError> {
+ self.require_account(&spend.account_id)?; validate(&spend.event_id, "event_id")?;
+ if spend.credits == 0 || spend.occurred_at == 0 { return Err(StoredValueError::Invalid("spend_credits_or_time")); }
+ if let Some((existing, allocations)) = self.spends.get(&spend.event_id) { return if existing == &spend { Ok(allocations.clone()) } else { Err(StoredValueError::IdempotencyConflict(spend.event_id)) }; }
+ let allocations = self.allocate(&spend.account_id, spend.credits, spend.occurred_at)?;
+ self.consume(&allocations)?; self.spends.insert(spend.event_id.clone(), (spend, allocations.clone())); Ok(allocations)
+ }
+
+ pub fn reserve(&mut self, reservation_id: &str, account_id: &str, credits: u64, expires_at: u64, now: u64) -> Result {
+ self.require_account(account_id)?; validate(reservation_id, "reservation_id")?;
+ if credits == 0 || expires_at <= now { return Err(StoredValueError::Invalid("reservation_credits_or_expiry")); }
+ if let Some(existing) = self.reservations.get(reservation_id) { return if existing.account_id == account_id && existing.credits == credits && existing.expires_at == expires_at { Ok(existing.clone()) } else { Err(StoredValueError::IdempotencyConflict(reservation_id.into())) }; }
+ let allocations = self.allocate(account_id, credits, now)?;
+ for allocation in &allocations { let lot = self.lots.get_mut(&allocation.lot_id).expect("allocated lot"); lot.remaining -= allocation.credits; lot.reserved += allocation.credits; }
+ let reservation = StoredValueReservationV1 { reservation_id: reservation_id.into(), account_id: account_id.into(), credits, expires_at, allocations, status: StoredValueReservationStatus::Active };
+ self.reservations.insert(reservation_id.into(), reservation.clone()); Ok(reservation)
+ }
+
+ pub fn release_reservation(&mut self, reservation_id: &str, now: u64) -> Result<(), StoredValueError> {
+ let reservation = self.reservations.get(reservation_id).cloned().ok_or_else(|| StoredValueError::ReservationNotFound(reservation_id.into()))?;
+ if reservation.status != StoredValueReservationStatus::Active { return Err(StoredValueError::ReservationNotActive(reservation_id.into())); }
+ self.return_reserved(&reservation)?;
+ self.reservations.get_mut(reservation_id).expect("reservation").status = if now >= reservation.expires_at { StoredValueReservationStatus::Expired } else { StoredValueReservationStatus::Released }; Ok(())
+ }
+
+ /// Deterministically expire a still-active reservation. A clock caller
+ /// cannot release a valid hold early by calling the expiry operation.
+ pub fn expire_reservation(&mut self, reservation_id: &str, now: u64) -> Result<(), StoredValueError> {
+ let reservation = self.reservations.get(reservation_id).cloned().ok_or_else(|| StoredValueError::ReservationNotFound(reservation_id.into()))?;
+ if reservation.status == StoredValueReservationStatus::Expired { return Ok(()); }
+ if reservation.status != StoredValueReservationStatus::Active { return Err(StoredValueError::ReservationNotActive(reservation_id.into())); }
+ if now < reservation.expires_at { return Err(StoredValueError::ReservationExpired(reservation_id.into())); }
+ self.return_reserved(&reservation)?;
+ self.reservations.get_mut(reservation_id).expect("reservation").status = StoredValueReservationStatus::Expired;
+ Ok(())
+ }
+
+ pub fn settle_reservation(&mut self, reservation_id: &str, spend: StoredValueSpendV2) -> Result, StoredValueError> {
+ let reservation = self.reservations.get(reservation_id).cloned().ok_or_else(|| StoredValueError::ReservationNotFound(reservation_id.into()))?;
+ if reservation.status != StoredValueReservationStatus::Active { return Err(StoredValueError::ReservationNotActive(reservation_id.into())); }
+ if spend.account_id != reservation.account_id || spend.credits != reservation.credits { return Err(StoredValueError::Invalid("reservation_spend_terms")); }
+ if spend.occurred_at >= reservation.expires_at { return Err(StoredValueError::ReservationExpired(reservation_id.into())); }
+ if let Some((existing, allocations)) = self.spends.get(&spend.event_id) { return if existing == &spend { Ok(allocations.clone()) } else { Err(StoredValueError::IdempotencyConflict(spend.event_id)) }; }
+ for allocation in &reservation.allocations { let lot = self.lots.get_mut(&allocation.lot_id).expect("reserved lot"); lot.reserved -= allocation.credits; *self.consumed_by_lot.entry(allocation.lot_id.clone()).or_default() += allocation.credits; }
+ self.spends.insert(spend.event_id.clone(), (spend, reservation.allocations.clone())); self.reservations.get_mut(reservation_id).expect("reservation").status = StoredValueReservationStatus::Settled; Ok(reservation.allocations)
+ }
+
+ pub fn refund_paid_lot(&mut self, refund: RefundPaidLotV1) -> Result<(), StoredValueError> {
+ self.require_account(&refund.account_id)?;
+ for (value, field) in [(&refund.refund_rail_ref, "refund_rail_ref"), (&refund.rail_ref, "rail_ref"), (&refund.reason_code, "refund_reason"), (&refund.approval_ref, "approval_ref"), (&refund.audit_ref, "audit_ref")] { validate(value, field)?; }
+ if refund.credits == 0 || refund.requested_at == 0 { return Err(StoredValueError::Invalid("refund_credits_or_time")); }
+ if let Some(existing) = self.refunds.get(&refund.refund_rail_ref) { return if existing == &refund { Ok(()) } else { Err(StoredValueError::IdempotencyConflict(refund.refund_rail_ref)) }; }
+ let lot = self.lots.get_mut(&refund.rail_ref).ok_or_else(|| StoredValueError::PaidLotNotFound(refund.rail_ref.clone()))?;
+ if lot.kind != CreditLotKind::Paid || lot.account_id != refund.account_id { return Err(StoredValueError::PaidLotNotFound(refund.rail_ref)); }
+ if lot.reserved != 0 { return Err(StoredValueError::PaidLotReserved(lot.lot_id.clone())); }
+ if lot.remaining < refund.credits { return Err(StoredValueError::RefundExceedsUnused { refund_id: refund.refund_rail_ref.clone(), rail_ref: refund.rail_ref.clone() }); }
+ lot.remaining -= refund.credits; self.refunds.insert(refund.refund_rail_ref.clone(), refund); Ok(())
+ }
+
+ pub fn account_read(&self, account_id: &str, now: u64, period_ref: &str, reset_at: u64) -> Result {
+ self.require_account(account_id)?; let mut paid: u64 = 0; let mut grant: u64 = 0; let mut reserved: u64 = 0; let mut included: u64 = 0;
+ for lot in self.lots.values().filter(|lot| lot.account_id == account_id) { reserved = reserved.checked_add(lot.reserved).ok_or(StoredValueError::Overflow)?; match lot.kind { CreditLotKind::Paid => paid = paid.checked_add(lot.remaining).ok_or(StoredValueError::Overflow)?, CreditLotKind::Grant if lot.expires_at > now => { grant = grant.checked_add(lot.remaining).ok_or(StoredValueError::Overflow)?; if lot.period_ref == period_ref { included = included.checked_add(*self.consumed_by_lot.get(&lot.lot_id).unwrap_or(&0)).ok_or(StoredValueError::Overflow)?; } }, CreditLotKind::Grant => {} } }
+ Ok(StoredValueAccountReadV1 { account_id: account_id.into(), paid_available_credits: paid, grant_available_credits: grant, reserved_credits: reserved, refundable_paid_credits: paid, included_period_consumed: included, reset_at })
+ }
+
+ pub fn lots_for_account(&self, account_id: &str) -> Result, StoredValueError> {
+ self.require_account(account_id)?;
+ Ok(self.lots.values().filter(|lot| lot.account_id == account_id).map(|lot| StoredValueLotReadV1 { lot_id: lot.lot_id.clone(), account_id: lot.account_id.clone(), kind: lot.kind, amount_usd_cents: lot.amount_usd_cents, base_credits: lot.base_credits, bonus_credits: lot.bonus_credits, remaining_credits: lot.remaining, reserved_credits: lot.reserved, issued_at: lot.issued_at, expires_at: lot.expires_at, terms_version: lot.terms_version.clone(), reason_code: lot.reason_code.clone(), period_ref: lot.period_ref.clone() }).collect())
+ }
+
+ pub fn reservation(&self, reservation_id: &str) -> Option<&StoredValueReservationV1> {
+ self.reservations.get(reservation_id)
+ }
+
+ /// Persist exactly one post-state projection input for an idempotent V2
+ /// operation. The caller must publish it only after chain finality.
+ pub fn append_finality_event(&mut self, event_key: String, transaction_id: String, account_id: String, payload: StoredValueFinalityPayloadV1) -> Result<(), StoredValueError> {
+ validate(&event_key, "finality_event_key")?;
+ validate(&transaction_id, "transaction_id")?;
+ self.require_account(&account_id)?;
+ if let Some(existing) = self.finality_events.iter().find(|event| event.event_key == event_key) {
+ if existing.account_id == account_id && existing.payload == payload { return Ok(()); }
+ return Err(StoredValueError::IdempotencyConflict(event_key));
+ }
+ let sequence = u64::try_from(self.finality_events.len()).map_err(|_| StoredValueError::Overflow)?;
+ self.finality_events.push(StoredValueFinalityEventV1 { sequence, event_key, transaction_id, account_id, payload });
+ Ok(())
+ }
+
+ pub fn finality_events(&self, from_sequence: u64, limit: u16) -> Vec {
+ let end = from_sequence.saturating_add(u64::from(limit)).min(self.finality_events.len() as u64);
+ self.finality_events[from_sequence as usize..end as usize].to_vec()
+ }
+
+ fn insert_lot(&mut self, key: String, lot: CreditLotV2) -> Result<(), StoredValueError> { match self.lots.get(&key) { None => { self.lots.insert(key, lot); Ok(()) }, Some(existing) if existing == &lot => Ok(()), Some(_) => Err(StoredValueError::IdempotencyConflict(key)) } }
+ fn allocate(&self, account_id: &str, credits: u64, now: u64) -> Result, StoredValueError> { let mut lots = self.lots.values().filter(|lot| lot.account_id == account_id && (lot.kind == CreditLotKind::Paid || lot.expires_at > now)).collect::>(); lots.sort_by_key(|lot| (lot.kind, if lot.expires_at == 0 { u64::MAX } else { lot.expires_at }, lot.issued_at, lot.lot_id.clone())); let available = lots.iter().try_fold(0u64, |total, lot| total.checked_add(lot.remaining).ok_or(StoredValueError::Overflow))?; if available < credits { return Err(StoredValueError::InsufficientCredits { available, required: credits }); } let mut left = credits; let mut allocations = Vec::new(); for lot in lots { if left == 0 { break; } let used = left.min(lot.remaining); if used > 0 { allocations.push(LotAllocationV1 { lot_id: lot.lot_id.clone(), kind: lot.kind, credits: used }); left -= used; } } Ok(allocations) }
+ fn consume(&mut self, allocations: &[LotAllocationV1]) -> Result<(), StoredValueError> { for allocation in allocations { let lot = self.lots.get_mut(&allocation.lot_id).expect("allocated lot"); lot.remaining = lot.remaining.checked_sub(allocation.credits).ok_or(StoredValueError::Overflow)?; *self.consumed_by_lot.entry(allocation.lot_id.clone()).or_default() += allocation.credits; } Ok(()) }
+ fn return_reserved(&mut self, reservation: &StoredValueReservationV1) -> Result<(), StoredValueError> { for allocation in &reservation.allocations { let lot = self.lots.get_mut(&allocation.lot_id).expect("reserved lot"); lot.reserved = lot.reserved.checked_sub(allocation.credits).ok_or(StoredValueError::Overflow)?; lot.remaining = lot.remaining.checked_add(allocation.credits).ok_or(StoredValueError::Overflow)?; } Ok(()) }
+ fn require_account(&self, account_id: &str) -> Result<(), StoredValueError> { if self.accounts.contains_key(account_id) { Ok(()) } else { Err(StoredValueError::AccountNotFound(account_id.into())) } }
+}
+
+fn validate(value: &str, field: &'static str) -> Result<(), StoredValueError> { if value.is_empty() || value.len() > 128 || !value.bytes().all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b':' | b'/')) { Err(StoredValueError::Invalid(field)) } else { Ok(()) } }
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ fn topup() -> CreditTopupV2 { CreditTopupV2 { account_id: "account_demo".into(), rail_ref: "pi_paid_1".into(), amount_usd_cents: 100_000, base_credits: 8_000, bonus_credits: 888, purchased_at: 100, terms_version: "terms_v1".into() } }
+ fn grant() -> CreditGrantV2 { CreditGrantV2 { account_id: "account_demo".into(), reference: "grant_account_demo_2026_07".into(), credits: 3_000, reason_code: "included_credits".into(), period_ref: "2026-07".into(), issued_at: 100, expires_at: 200, approval_ref: "policy_auto".into(), audit_ref: "audit_1".into() } }
+ fn ready() -> StoredValueLedger { let mut ledger = StoredValueLedger::default(); ledger.onboard("account_demo").unwrap(); ledger.credit_topup(topup()).unwrap(); ledger.credit_grant(grant()).unwrap(); ledger }
+ fn refund(credits: u64, id: &str) -> RefundPaidLotV1 { RefundPaidLotV1 { account_id: "account_demo".into(), rail_ref: "pi_paid_1".into(), refund_rail_ref: id.into(), credits, requested_at: 150, reason_code: "charge.refunded".into(), approval_ref: "support_1".into(), audit_ref: "audit_2".into() } }
+ #[test] fn grant_first_spend_preserves_paid_value() { let mut ledger = ready(); let allocations = ledger.record_spend(StoredValueSpendV2 { event_id: "event_1".into(), account_id: "account_demo".into(), credits: 3_100, occurred_at: 150 }).unwrap(); assert_eq!(allocations[0].kind, CreditLotKind::Grant); assert_eq!(allocations[1].kind, CreditLotKind::Paid); assert_eq!(ledger.account_read("account_demo", 150, "2026-07", 200).unwrap().refundable_paid_credits, 8_788); }
+ #[test] fn reservation_prevents_refund_until_release() { let mut ledger = ready(); ledger.record_spend(StoredValueSpendV2 { event_id: "event_1".into(), account_id: "account_demo".into(), credits: 3_000, occurred_at: 150 }).unwrap(); ledger.reserve("res_1", "account_demo", 100, 180, 151).unwrap(); assert!(matches!(ledger.refund_paid_lot(refund(100, "re_1")), Err(StoredValueError::PaidLotReserved(_)))); ledger.release_reservation("res_1", 152).unwrap(); }
+ #[test] fn partial_refund_is_idempotent_and_lot_bound() { let mut ledger = ready(); ledger.refund_paid_lot(refund(888, "re_1")).unwrap(); ledger.refund_paid_lot(refund(888, "re_1")).unwrap(); assert_eq!(ledger.account_read("account_demo", 150, "2026-07", 200).unwrap().paid_available_credits, 8_000); let mut bad = refund(1, "re_bad"); bad.rail_ref = "grant_account_demo_2026_07".into(); assert!(matches!(ledger.refund_paid_lot(bad), Err(StoredValueError::PaidLotNotFound(_)))); }
+ #[test] fn expired_grant_does_not_fund_spend() { let mut ledger = ready(); assert!(matches!(ledger.record_spend(StoredValueSpendV2 { event_id: "event_expired".into(), account_id: "account_demo".into(), credits: 9_000, occurred_at: 201 }), Err(StoredValueError::InsufficientCredits { .. }))); }
+}
diff --git a/costs/src/tests.rs b/costs/src/tests.rs
new file mode 100644
index 0000000..3f27b87
--- /dev/null
+++ b/costs/src/tests.rs
@@ -0,0 +1,1390 @@
+#![allow(clippy::cloned_ref_to_slice_refs)] // keeps rate-card fixture setup readable.
+
+use std::collections::BTreeMap;
+
+use commonware_cryptography::sha256::Digest;
+use nunchi_common::{Address, StateError, StateStore};
+use nunchi_crypto::PrivateKey;
+
+use crate::{
+ AdjustmentKind, AdjustmentMetadata, CostsDB, CostsError, CostsLedger, CostsOperation,
+ CreditGrantV2, CreditTopupV2, FinalizedOutcomeKind, FinalizedOutcomeV1, LedgerMutationKind,
+ RateCardEntry, RefundPaidLotV1, AccountStatus, ReservationActionMetadataV1, SpendRecordV1,
+ StatusChangeMetadataV1, StoredValueSpendV2, Transaction, UntrackedSourceV1, WriterRole,
+};
+
+#[derive(Default)]
+struct MemoryState {
+ values: BTreeMap>,
+}
+
+fn canonical_changeset(mut change_set: crate::RateCardChangeSetV1, entries: &[RateCardEntry]) -> crate::RateCardChangeSetV1 {
+ change_set.manifest_hash = change_set.expected_manifest_hash(entries);
+ change_set
+}
+
+#[test]
+fn stored_value_v2_commands_are_signed_scoped_and_persisted() {
+ futures::executor::block_on(async {
+ let admin = PrivateKey::ed25519_from_seed(701);
+ let billing = PrivateKey::ed25519_from_seed(702);
+ let adjustment = PrivateKey::ed25519_from_seed(703);
+ let ingest = PrivateKey::ed25519_from_seed(704);
+ let mut state = MemoryState::default();
+ state.set_writer(WriterRole::Admin, &address(&admin), true);
+ state.set_writer(WriterRole::Billing, &address(&billing), true);
+ state.set_writer(WriterRole::Adjustment, &address(&adjustment), true);
+ state.set_writer(WriterRole::Ingest, &address(&ingest), true);
+
+ {
+ let mut ledger = CostsLedger::new(&mut state);
+ ledger.apply_transaction(&Transaction::sign(&admin, 0, onboard("account_stored"))).await.unwrap();
+ let topup = CreditTopupV2 {
+ account_id: "account_stored".to_string(), rail_ref: "pi_stored_1".to_string(),
+ amount_usd_cents: 10_000, base_credits: 800, bonus_credits: 88,
+ purchased_at: 100, terms_version: "terms_v1".to_string(),
+ };
+ ledger.apply_transaction(&Transaction::sign(&billing, 0, CostsOperation::StoredValueTopupV2 { topup: topup.clone() })).await.unwrap();
+ // An idempotent replay advances only its signer nonce; it cannot
+ // make a second paid lot.
+ ledger.apply_transaction(&Transaction::sign(&billing, 1, CostsOperation::StoredValueTopupV2 { topup })).await.unwrap();
+ ledger.apply_transaction(&Transaction::sign(&adjustment, 0, CostsOperation::StoredValueGrantV2 {
+ grant: CreditGrantV2 {
+ account_id: "account_stored".to_string(), reference: "grant_account_stored_2026_07".to_string(),
+ credits: 300, reason_code: "included_credits".to_string(), period_ref: "2026-07".to_string(),
+ issued_at: 100, expires_at: 200, approval_ref: "approval_grant_1".to_string(), audit_ref: "audit_grant_1".to_string(),
+ },
+ })).await.unwrap();
+ ledger.apply_transaction(&Transaction::sign(&ingest, 0, CostsOperation::StoredValueSpendV2 {
+ spend: StoredValueSpendV2 { event_id: "event_stored_1".to_string(), account_id: "account_stored".to_string(), credits: 350, occurred_at: 150 },
+ })).await.unwrap();
+ ledger.apply_transaction(&Transaction::sign(&adjustment, 1, CostsOperation::RefundPaidLotV1 {
+ refund: RefundPaidLotV1 {
+ account_id: "account_stored".to_string(), rail_ref: "pi_stored_1".to_string(),
+ refund_rail_ref: "re_stored_1".to_string(), credits: 100, requested_at: 160,
+ reason_code: "charge.refunded".to_string(), approval_ref: "approval_refund_1".to_string(), audit_ref: "audit_refund_1".to_string(),
+ },
+ })).await.unwrap();
+ // V2 is deliberately independent from the legacy aggregate path.
+ assert_eq!(ledger.account("account_stored").await.unwrap().unwrap().available_credits, 0);
+ }
+
+ let ledger = CostsLedger::new(&mut state);
+ let read = ledger.stored_value_account_read("account_stored", 160, "2026-07", 200).await.unwrap().unwrap();
+ assert_eq!(read.paid_available_credits, 738);
+ assert_eq!(read.refundable_paid_credits, 738);
+ assert_eq!(read.grant_available_credits, 0);
+ assert_eq!(read.included_period_consumed, 300);
+ assert_eq!(ledger.stored_value_lots("account_stored").await.unwrap().unwrap().len(), 2);
+ let events = ledger.stored_value_finality_events(0, 10).await.unwrap();
+ assert_eq!(events.len(), 4);
+ assert_eq!(events[0].event_key, "topup:pi_stored_1");
+ assert_eq!(events[2].event_key, "spend:event_stored_1");
+ assert_eq!(events[3].event_key, "refund:re_stored_1");
+ });
+}
+
+#[test]
+fn stored_value_v2_reservations_hold_lot_allocations_until_settlement() {
+ futures::executor::block_on(async {
+ let admin = PrivateKey::ed25519_from_seed(711);
+ let billing = PrivateKey::ed25519_from_seed(712);
+ let adjustment = PrivateKey::ed25519_from_seed(713);
+ let ingest = PrivateKey::ed25519_from_seed(714);
+ let mut state = MemoryState::default();
+ state.set_writer(WriterRole::Admin, &address(&admin), true);
+ state.set_writer(WriterRole::Billing, &address(&billing), true);
+ state.set_writer(WriterRole::Adjustment, &address(&adjustment), true);
+ state.set_writer(WriterRole::Ingest, &address(&ingest), true);
+ let mut ledger = CostsLedger::new(&mut state);
+ ledger.apply_transaction(&Transaction::sign(&admin, 0, onboard("account_stored_reservation"))).await.unwrap();
+ ledger.apply_transaction(&Transaction::sign(&billing, 0, CostsOperation::StoredValueTopupV2 {
+ topup: CreditTopupV2 {
+ account_id: "account_stored_reservation".to_string(), rail_ref: "pi_reservation_1".to_string(),
+ amount_usd_cents: 1_000, base_credits: 100, bonus_credits: 0,
+ purchased_at: 100, terms_version: "terms_v1".to_string(),
+ },
+ })).await.unwrap();
+ ledger.apply_transaction(&Transaction::sign(&adjustment, 0, CostsOperation::StoredValueGrantV2 {
+ grant: CreditGrantV2 {
+ account_id: "account_stored_reservation".to_string(), reference: "grant_reservation_1".to_string(),
+ credits: 100, reason_code: "included_credits".to_string(), period_ref: "2026-07".to_string(),
+ issued_at: 100, expires_at: 200, approval_ref: "approval_reservation_1".to_string(), audit_ref: "audit_reservation_1".to_string(),
+ },
+ })).await.unwrap();
+ ledger.apply_transaction(&Transaction::sign(&ingest, 0, CostsOperation::ReserveStoredValueV2 {
+ reservation_id: "sv_reservation_1".to_string(), account_id: "account_stored_reservation".to_string(),
+ credits: 150, expires_at: 200, reserved_at: 110,
+ })).await.unwrap();
+ let refund_while_reserved = ledger.apply_transaction(&Transaction::sign(&adjustment, 1, CostsOperation::RefundPaidLotV1 {
+ refund: RefundPaidLotV1 {
+ account_id: "account_stored_reservation".to_string(), rail_ref: "pi_reservation_1".to_string(),
+ refund_rail_ref: "re_reserved_1".to_string(), credits: 1, requested_at: 111,
+ reason_code: "charge.refunded".to_string(), approval_ref: "approval_refund_reserved".to_string(), audit_ref: "audit_refund_reserved".to_string(),
+ },
+ })).await.unwrap_err();
+ assert!(matches!(refund_while_reserved, CostsError::Storage(_)));
+ ledger.apply_transaction(&Transaction::sign(&ingest, 1, CostsOperation::SettleStoredValueReservationV2 {
+ reservation_id: "sv_reservation_1".to_string(),
+ spend: StoredValueSpendV2 { event_id: "event_reserved_1".to_string(), account_id: "account_stored_reservation".to_string(), credits: 150, occurred_at: 120 },
+ })).await.unwrap();
+ let read = ledger.stored_value_account_read("account_stored_reservation", 120, "2026-07", 200).await.unwrap().unwrap();
+ assert_eq!(read.reserved_credits, 0);
+ assert_eq!(read.grant_available_credits, 0);
+ assert_eq!(read.paid_available_credits, 50);
+ });
+}
+
+#[test]
+fn v1_control_envelopes_bind_approval_status_and_quote_reservation() {
+ futures::executor::block_on(async {
+ let admin = PrivateKey::ed25519_from_seed(121);
+ let billing = PrivateKey::ed25519_from_seed(122);
+ let ingest = PrivateKey::ed25519_from_seed(123);
+ let mut state = MemoryState::default();
+ state.set_writer(WriterRole::Admin, &address(&admin), true);
+ state.set_writer(WriterRole::Billing, &address(&billing), true);
+ state.set_writer(WriterRole::Ingest, &address(&ingest), true);
+ let mut ledger = CostsLedger::new(&mut state);
+ ledger.apply_transaction(&Transaction::sign(&admin, 0, onboard("account_v1"))).await.unwrap();
+ let rate = RateCardEntry { account_id: String::new(), event_category: "creative.render".to_string(), task_key: String::new(), credits: 7, effective_at: 10, expires_at: 0, policy_version: "policy_v1".to_string(), rate_version: "rate_v1".to_string() };
+ let envelope = crate::RateCardChangeSetV1 { change_set_id: "cs_v1".to_string(), expected_entry_count: 1, target_account_ids: vec!["account_v1".to_string()], expected_target_count: 1, manifest_hash: "manifest_v1".to_string(), staged_entry_count: 0, approval_ref: "approval_v1".to_string(), audit_ref: "audit_v1".to_string(), activation_epoch: 1, applied: false };
+ let envelope = canonical_changeset(envelope, &[rate.clone()]);
+ ledger.apply_transaction(&Transaction::sign(&admin, 1, CostsOperation::StageRateCardChangeSetV1 { change_set: envelope.clone(), entries: vec![rate.clone()] })).await.unwrap();
+ ledger.apply_transaction(&Transaction::sign(&admin, 2, CostsOperation::ApplyRateCardChangeSetV1 { change_set: envelope.clone(), entries: vec![rate] })).await.unwrap();
+ ledger.apply_transaction(&Transaction::sign(&billing, 0, CostsOperation::CreditTopup { account_id: "account_v1".to_string(), credits: 20, rail_ref: "rail_v1".to_string() })).await.unwrap();
+ let quote = ledger.quote_request(crate::QuoteRequestV1 { account_id: "account_v1".to_string(), event_category: "creative.render".to_string(), task_key: String::new(), quantity: 2, quoted_at: 11, expires_at: 20 }).await.unwrap().unwrap();
+ ledger.apply_transaction(&Transaction::sign(&ingest, 0, CostsOperation::ReserveCreditsV1 { reservation_id: "reservation_v1".to_string(), quote: quote.clone(), lineage_ref: "lineage_v1".to_string(), reserved_at: 12 })).await.unwrap();
+ let settlement_metadata = ReservationActionMetadataV1 { reason_code: "settled".to_string(), occurred_at: 13, approval_ref: "approval_settle".to_string(), audit_ref: "audit_settle".to_string() };
+ let bad = ledger.apply_transaction(&Transaction::sign(&ingest, 1, CostsOperation::SettleSpendV1 { reservation_id: "reservation_v1".to_string(), event_id: "evt_v1".to_string(), event_category: "creative.render".to_string(), task_key: String::new(), quote_id: quote.quote_id.clone(), snapshot_hash: "wrong_hash".to_string(), lineage_ref: "lineage_v1".to_string(), metadata: settlement_metadata.clone() })).await.unwrap_err();
+ assert!(matches!(bad, CostsError::PinnedRateMismatch { .. }));
+ ledger.apply_transaction(&Transaction::sign(&ingest, 1, CostsOperation::SettleSpendV1 { reservation_id: "reservation_v1".to_string(), event_id: "evt_v1".to_string(), event_category: "creative.render".to_string(), task_key: String::new(), quote_id: quote.quote_id.clone(), snapshot_hash: quote.snapshot_hash.clone(), lineage_ref: "lineage_v1".to_string(), metadata: settlement_metadata })).await.unwrap();
+ ledger.apply_transaction(&Transaction::sign(&admin, 3, CostsOperation::SetAccountStatusV1 { account_id: "account_v1".to_string(), status: AccountStatus::Suspended, metadata: StatusChangeMetadataV1 { reason_code: "close_block".to_string(), changed_at: 14, approval_ref: "approval_status".to_string(), audit_ref: "audit_status".to_string() } })).await.unwrap();
+ let history = ledger.status_history("account_v1").await.unwrap();
+ assert_eq!(history.last().unwrap().approval_ref, "approval_status");
+ assert_eq!(ledger.account("account_v1").await.unwrap().unwrap().reserved_credits, 0);
+ let reservation = ledger.reservation("reservation_v1").await.unwrap().unwrap();
+ assert_eq!(reservation.quote, quote);
+ assert_eq!(reservation.lineage_ref, "lineage_v1");
+ let status = ledger.journal(0, 100).await.unwrap().pop().unwrap();
+ assert_eq!(status.reason_code, "close_block");
+ assert_eq!(status.occurred_at, 14);
+ assert_eq!(status.approval_ref, "approval_status");
+ assert_eq!(status.audit_ref, "audit_status");
+ });
+}
+
+#[test]
+fn global_rates_propagate_to_existing_accounts_preserve_overrides_and_inherit_onboarding() {
+ futures::executor::block_on(async {
+ let admin = PrivateKey::ed25519_from_seed(201);
+ let mut state = MemoryState::default();
+ state.set_writer(WriterRole::Admin, &address(&admin), true);
+ let mut ledger = CostsLedger::new(&mut state);
+ ledger.apply_transaction(&Transaction::sign(&admin, 0, onboard("account_global_a"))).await.unwrap();
+ ledger.apply_transaction(&Transaction::sign(&admin, 1, onboard("account_global_b"))).await.unwrap();
+ let global = RateCardEntry { account_id: String::new(), event_category: "sms.send".to_string(), task_key: String::new(), credits: 2, effective_at: 10, expires_at: 0, policy_version: "policy_global".to_string(), rate_version: "global_v1".to_string() };
+ let changeset = crate::RateCardChangeSetV1 { change_set_id: "global_rates_1".to_string(), expected_entry_count: 1, target_account_ids: vec!["account_global_a".to_string(), "account_global_b".to_string()], expected_target_count: 2, manifest_hash: "manifest_global_1".to_string(), staged_entry_count: 0, approval_ref: "approval_global_1".to_string(), audit_ref: "audit_global_1".to_string(), activation_epoch: 1, applied: false };
+ let changeset = canonical_changeset(changeset, &[global.clone()]);
+ ledger.apply_transaction(&Transaction::sign(&admin, 2, CostsOperation::StageRateCardChangeSetV1 { change_set: changeset.clone(), entries: vec![global.clone()] })).await.unwrap();
+ let applied = ledger.apply_transaction_with_outcomes(&Transaction::sign(&admin, 3, CostsOperation::ApplyRateCardChangeSetV1 { change_set: changeset, entries: vec![global.clone()] })).await.unwrap();
+ assert_eq!(applied.iter().filter(|entry| entry.kind == LedgerMutationKind::RateCardApplied && entry.account_id.starts_with("account_global_")).count(), 2);
+ assert_eq!(ledger.quote("account_global_a", "sms.send", "", 11).await.unwrap().unwrap().credits_per_unit, 2);
+
+ let scoped = RateCardEntry { account_id: "account_global_a".to_string(), credits: 9, rate_version: "account_v1".to_string(), ..global.clone() };
+ let scoped_set = crate::RateCardChangeSetV1 { change_set_id: "account_override_1".to_string(), expected_entry_count: 1, target_account_ids: vec!["account_global_a".to_string()], expected_target_count: 1, manifest_hash: "manifest_override_1".to_string(), staged_entry_count: 0, approval_ref: "approval_override_1".to_string(), audit_ref: "audit_override_1".to_string(), activation_epoch: 2, applied: false };
+ let scoped_set = canonical_changeset(scoped_set, &[scoped.clone()]);
+ ledger.apply_transaction(&Transaction::sign(&admin, 4, CostsOperation::StageRateCardChangeSetV1 { change_set: scoped_set.clone(), entries: vec![scoped] })).await.unwrap();
+ ledger.apply_transaction(&Transaction::sign(&admin, 5, CostsOperation::ApplyRateCardChangeSetV1 { change_set: scoped_set, entries: vec![RateCardEntry { account_id: "account_global_a".to_string(), credits: 9, rate_version: "account_v1".to_string(), ..global.clone() }] })).await.unwrap();
+ let global_v2 = RateCardEntry { credits: 4, effective_at: 20, rate_version: "global_v2".to_string(), ..global };
+ let second = crate::RateCardChangeSetV1 { change_set_id: "global_rates_2".to_string(), expected_entry_count: 1, target_account_ids: vec!["account_global_b".to_string()], expected_target_count: 1, manifest_hash: "manifest_global_2".to_string(), staged_entry_count: 0, approval_ref: "approval_global_2".to_string(), audit_ref: "audit_global_2".to_string(), activation_epoch: 3, applied: false };
+ let second = canonical_changeset(second, &[global_v2.clone()]);
+ ledger.apply_transaction(&Transaction::sign(&admin, 6, CostsOperation::StageRateCardChangeSetV1 { change_set: second.clone(), entries: vec![global_v2.clone()] })).await.unwrap();
+ let updated = ledger.apply_transaction_with_outcomes(&Transaction::sign(&admin, 7, CostsOperation::ApplyRateCardChangeSetV1 { change_set: second, entries: vec![global_v2] })).await.unwrap();
+ assert!(updated.iter().any(|entry| entry.account_id == "account_global_b" && entry.rate.rate_version == "global_v2"));
+ assert!(!updated.iter().any(|entry| entry.account_id == "account_global_a" && entry.rate.rate_version == "global_v2"));
+ assert_eq!(ledger.quote("account_global_a", "sms.send", "", 21).await.unwrap().unwrap().credits_per_unit, 9);
+ assert_eq!(ledger.quote("account_global_b", "sms.send", "", 21).await.unwrap().unwrap().credits_per_unit, 4);
+
+ let inherited = ledger.apply_transaction_with_outcomes(&Transaction::sign(&admin, 8, CostsOperation::CreateAccount {
+ account_id: "account_global_future".to_string(), external_ref: "onboard_account_global_future".to_string(),
+ policy_ref: "policy_test_1".to_string(), cohort_ref: String::new(), created_at: 21,
+ })).await.unwrap();
+ assert!(inherited.iter().any(|entry| entry.kind == LedgerMutationKind::RateCardApplied && entry.account_id == "account_global_future" && entry.reason_code == "global_rate_inherited"));
+ assert_eq!(ledger.quote("account_global_future", "sms.send", "", 21).await.unwrap().unwrap().credits_per_unit, 4);
+ });
+}
+
+impl StateStore for MemoryState {
+ async fn get(&self, key: &Digest) -> Result>, StateError> {
+ Ok(self.values.get(key).cloned())
+ }
+
+ fn set(&mut self, key: Digest, value: Vec) {
+ self.values.insert(key, value);
+ }
+
+ fn remove(&mut self, key: Digest) {
+ self.values.remove(&key);
+ }
+}
+
+fn address(key: &PrivateKey) -> Address {
+ Address::external(&key.public_key())
+}
+
+fn onboard(account_id: &str) -> CostsOperation {
+ CostsOperation::CreateAccount {
+ account_id: account_id.to_string(),
+ external_ref: format!("onboard_{account_id}"),
+ policy_ref: "policy_test_1".to_string(),
+ cohort_ref: String::new(),
+ created_at: 1,
+ }
+}
+
+fn adjustment_metadata(reference: &str) -> AdjustmentMetadata {
+ AdjustmentMetadata {
+ reference: reference.to_string(),
+ reason_code: "manual_correction".to_string(),
+ period_ref: "period_2026_07".to_string(),
+ approval_ref: "approval_test_1".to_string(),
+ audit_ref: "audit_test_1".to_string(),
+ }
+}
+
+#[test]
+fn legacy_mutations_are_rejected_at_the_ledger_boundary() {
+ futures::executor::block_on(async {
+ let admin = PrivateKey::ed25519_from_seed(90);
+ let mut state = MemoryState::default();
+ state.set_writer(WriterRole::Admin, &address(&admin), true);
+ let mut ledger = CostsLedger::new(&mut state);
+ let error = ledger
+ .apply_transaction(&Transaction::sign(
+ &admin,
+ 0,
+ CostsOperation::RegisterAccount { account_id: "account_legacy".to_string() },
+ ))
+ .await
+ .unwrap_err();
+ assert!(matches!(error, CostsError::LegacyOperationRejected { operation: "RegisterAccount" }));
+ assert_eq!(ledger.db().nonce(&address(&admin)).await.unwrap(), 0);
+ let legacy_operations = vec![
+ CostsOperation::SetAccountStatus { account_id: "account_legacy".to_string(), status: AccountStatus::Suspended },
+ CostsOperation::ReserveCredits { reservation_id: "reservation_legacy".to_string(), account_id: "account_legacy".to_string(), credits: 1, expires_at: 2 },
+ CostsOperation::ReleaseReservation { reservation_id: "reservation_legacy".to_string() },
+ CostsOperation::ExpireReservation { reservation_id: "reservation_legacy".to_string(), expired_at: 2 },
+ CostsOperation::SettleSpend { reservation_id: "reservation_legacy".to_string(), event_id: "event_legacy".to_string(), event_category: "sms.send".to_string() },
+ CostsOperation::StageRateCardEntries { change_set_id: "changeset_legacy".to_string(), expected_entry_count: 1, manifest_hash: "manifest_legacy".to_string(), entries: Vec::new() },
+ CostsOperation::ApplyRateCardChangeSet { change_set_id: "changeset_legacy".to_string(), manifest_hash: "manifest_legacy".to_string(), entries: Vec::new() },
+ ];
+ for operation in legacy_operations {
+ assert!(matches!(ledger.apply_transaction(&Transaction::sign(&admin, 0, operation)).await, Err(CostsError::LegacyOperationRejected { .. })));
+ }
+ });
+}
+
+#[test]
+fn account_writer_scope_onboarding_collision_and_cohort_binding_are_enforced() {
+ futures::executor::block_on(async {
+ let admin = PrivateKey::ed25519_from_seed(91);
+ let billing = PrivateKey::ed25519_from_seed(92);
+ let ingest = PrivateKey::ed25519_from_seed(93);
+ let mut state = MemoryState::default();
+ state.set_writer(WriterRole::Admin, &address(&admin), true);
+ state.set_writer(WriterRole::Ingest, &address(&ingest), true);
+ let mut ledger = CostsLedger::new(&mut state);
+ ledger.apply_transaction(&Transaction::sign(&admin, 0, CostsOperation::CreateAccount {
+ account_id: "account_scope_a".to_string(), external_ref: "onboard_scope_1".to_string(),
+ policy_ref: "policy_1".to_string(), cohort_ref: "cohort_a".to_string(), created_at: 10,
+ })).await.unwrap();
+ let collision = ledger.apply_transaction(&Transaction::sign(&admin, 1, CostsOperation::CreateAccount {
+ account_id: "account_scope_b".to_string(), external_ref: "onboard_scope_1".to_string(),
+ policy_ref: "policy_1".to_string(), cohort_ref: "cohort_b".to_string(), created_at: 10,
+ })).await.unwrap_err();
+ assert!(matches!(collision, CostsError::IdempotencyConflict { .. }));
+ ledger.apply_transaction(&Transaction::sign(&admin, 1, CostsOperation::SetAccountWriter {
+ account_id: "account_scope_a".to_string(), role: WriterRole::Billing,
+ writer: address(&billing), enabled: true,
+ })).await.unwrap();
+ ledger.apply_transaction(&Transaction::sign(&billing, 0, CostsOperation::CreditTopup {
+ account_id: "account_scope_a".to_string(), credits: 20, rail_ref: "rail_scope_a".to_string(),
+ })).await.unwrap();
+ let wrong_account = ledger.apply_transaction(&Transaction::sign(&billing, 1, CostsOperation::CreditTopup {
+ account_id: "account_scope_b".to_string(), credits: 20, rail_ref: "rail_scope_b".to_string(),
+ })).await.unwrap_err();
+ assert!(matches!(wrong_account, CostsError::Unauthorized { role: WriterRole::Billing, .. }));
+ let rate = RateCardEntry { account_id: String::new(), event_category: "creative.action".to_string(), task_key: String::new(), credits: 2, effective_at: 10, expires_at: 0, policy_version: "policy_a".to_string(), rate_version: "rate_a".to_string() };
+ let changeset = crate::RateCardChangeSetV1 { change_set_id: "scope_rates".to_string(), expected_entry_count: 1, target_account_ids: vec!["account_scope_a".to_string()], expected_target_count: 1, manifest_hash: "scope_manifest".to_string(), staged_entry_count: 0, approval_ref: "approval_scope".to_string(), audit_ref: "audit_scope".to_string(), activation_epoch: 1, applied: false };
+ let changeset = canonical_changeset(changeset, &[rate.clone()]);
+ ledger.apply_transaction(&Transaction::sign(&admin, 2, CostsOperation::StageRateCardChangeSetV1 { change_set: changeset.clone(), entries: vec![rate.clone()] })).await.unwrap();
+ ledger.apply_transaction(&Transaction::sign(&admin, 3, CostsOperation::ApplyRateCardChangeSetV1 { change_set: changeset, entries: vec![rate] })).await.unwrap();
+ let mismatch = ledger.apply_transaction(&Transaction::sign(&ingest, 0, CostsOperation::RecordSpendBatch { records: vec![SpendRecordV1 {
+ event_id: "evt_cohort_mismatch".to_string(), account_id: "account_scope_a".to_string(), event_category: "creative.action".to_string(), task_key: String::new(), quantity: 1, credits: 2, observed_at: 10, policy_version: "policy_a".to_string(), rate_version: "rate_a".to_string(), source_ref: "source_a".to_string(), lineage_ref: "lineage_a".to_string(), cohort_ref: "cohort_b".to_string(),
+ }] })).await.unwrap_err();
+ assert!(matches!(mismatch, CostsError::PinnedRateMismatch { .. }));
+ });
+}
+
+#[test]
+fn untracked_sources_never_debit_and_journal_and_finality_envelopes_are_complete() {
+ futures::executor::block_on(async {
+ let admin = PrivateKey::ed25519_from_seed(94);
+ let billing = PrivateKey::ed25519_from_seed(95);
+ let mut state = MemoryState::default();
+ state.set_writer(WriterRole::Admin, &address(&admin), true);
+ state.set_writer(WriterRole::Billing, &address(&billing), true);
+ let mut ledger = CostsLedger::new(&mut state);
+ ledger.apply_transaction(&Transaction::sign(&admin, 0, onboard("account_journal"))).await.unwrap();
+ let topup = Transaction::sign(&billing, 0, CostsOperation::CreditTopup { account_id: "account_journal".to_string(), credits: 25, rail_ref: "rail_journal".to_string() });
+ let outcomes = ledger.apply_transaction_with_outcomes(&topup).await.unwrap();
+ assert_eq!(outcomes.len(), 1);
+ assert_eq!(outcomes[0].kind, LedgerMutationKind::BalanceChanged);
+ assert!(outcomes[0].has_account);
+ assert_eq!(outcomes[0].account.available_credits, 25);
+ assert_eq!(ledger.journal(0, 10).await.unwrap().len(), 2);
+ let source = UntrackedSourceV1 {
+ source_id: "dark_complete_1".to_string(), reason_code: "shared_cost".to_string(),
+ owner_ref: "owner_finance".to_string(), period_ref: "period_2026_07".to_string(),
+ provenance_ref: "provider_invoice_1".to_string(), coverage_code: "cost_dark".to_string(),
+ confidence_code: "unverified".to_string(), evidence_ref: "evidence_invoice_1".to_string(), cohort_ref: "cohort_dark_1".to_string(),
+ };
+ ledger.apply_transaction(&Transaction::sign(&admin, 1, CostsOperation::RegisterUntrackedSourceV1 { source })).await.unwrap();
+ assert_eq!(ledger.account("account_journal").await.unwrap().unwrap().available_credits, 25);
+ let journal = ledger.journal(0, 10).await.unwrap();
+ assert!(journal.iter().any(|entry| entry.kind == LedgerMutationKind::UntrackedSourceRegistered && entry.has_untracked_source && entry.untracked_source.owner_ref == "owner_finance"));
+ let finality = FinalizedOutcomeV1::from_finalized_mutation(&outcomes[0], topup.digest(), 1234);
+ assert_eq!(finality.schema_version, 1);
+ assert_eq!(finality.event_type, "ledger.balance_changed");
+ assert_eq!(finality.account_id.as_deref(), Some("account_journal"));
+ assert_eq!(finality.source_ref.as_deref(), Some("rail_journal"));
+ assert_eq!(finality.finalized_at, 1234);
+ assert!(finality.outbox_event_id.starts_with("ledger:"));
+ let replay = ledger.apply_transaction_with_outcomes(&Transaction::sign(&billing, 1, CostsOperation::CreditTopup { account_id: "account_journal".to_string(), credits: 25, rail_ref: "rail_journal".to_string() })).await.unwrap();
+ assert!(replay.is_empty(), "an idempotent replay has no journal mutation and no publishable finality event");
+ });
+}
+
+#[test]
+fn credits_are_idempotent_and_spend_is_deduplicated() {
+ futures::executor::block_on(async {
+ let admin = PrivateKey::ed25519_from_seed(1);
+ let billing = PrivateKey::ed25519_from_seed(2);
+ let ingest = PrivateKey::ed25519_from_seed(3);
+ let mut state = MemoryState::default();
+ state.set_writer(WriterRole::Admin, &address(&admin), true);
+ state.set_writer(WriterRole::Billing, &address(&billing), true);
+ state.set_writer(WriterRole::Ingest, &address(&ingest), true);
+ let mut ledger = CostsLedger::new(&mut state);
+
+ ledger
+ .apply_transaction(&Transaction::sign(
+ &admin,
+ 0,
+ onboard("account_demo"),
+ ))
+ .await
+ .unwrap();
+ let rate = RateCardEntry {
+ account_id: String::new(),
+ event_category: "creative.action".to_string(),
+ task_key: String::new(),
+ credits: 40,
+ effective_at: 1,
+ expires_at: 0,
+ policy_version: "policy_spend_1".to_string(),
+ rate_version: "rate_spend_1".to_string(),
+ };
+ let changeset = crate::RateCardChangeSetV1 { change_set_id: "changeset_spend_1".to_string(), expected_entry_count: 1, target_account_ids: vec!["account_demo".to_string()], expected_target_count: 1, manifest_hash: "manifest_spend_1".to_string(), staged_entry_count: 0, approval_ref: "approval_spend_1".to_string(), audit_ref: "audit_spend_1".to_string(), activation_epoch: 1, applied: false };
+ let changeset = canonical_changeset(changeset, &[rate.clone()]);
+ ledger
+ .apply_transaction(&Transaction::sign(
+ &admin,
+ 1,
+ CostsOperation::StageRateCardChangeSetV1 { change_set: changeset.clone(),
+ entries: vec![rate.clone()],
+ },
+ ))
+ .await
+ .unwrap();
+ ledger
+ .apply_transaction(&Transaction::sign(
+ &admin,
+ 2,
+ CostsOperation::ApplyRateCardChangeSetV1 { change_set: changeset,
+ entries: vec![rate],
+ },
+ ))
+ .await
+ .unwrap();
+ let topup = CostsOperation::CreditTopup {
+ account_id: "account_demo".to_string(),
+ credits: 100,
+ rail_ref: "pi_demo_1".to_string(),
+ };
+ ledger
+ .apply_transaction(&Transaction::sign(&billing, 0, topup.clone()))
+ .await
+ .unwrap();
+ ledger
+ .apply_transaction(&Transaction::sign(&billing, 1, topup))
+ .await
+ .unwrap();
+ assert_eq!(ledger.account("account_demo").await.unwrap().unwrap().available_credits, 100);
+
+ let spend = SpendRecordV1 {
+ event_id: "usage:evt_1".to_string(),
+ account_id: "account_demo".to_string(),
+ event_category: "creative.action".to_string(),
+ task_key: String::new(),
+ quantity: 1,
+ credits: 40,
+ observed_at: 1,
+ policy_version: "policy_spend_1".to_string(),
+ rate_version: "rate_spend_1".to_string(),
+ source_ref: "source_evt_1".to_string(),
+ lineage_ref: "lineage_evt_1".to_string(),
+ cohort_ref: String::new(),
+ };
+ ledger
+ .apply_transaction(&Transaction::sign(
+ &ingest,
+ 0,
+ CostsOperation::RecordSpendBatch {
+ records: vec![spend.clone(), spend],
+ },
+ ))
+ .await
+ .unwrap();
+ assert_eq!(ledger.account("account_demo").await.unwrap().unwrap().available_credits, 60);
+ });
+}
+
+#[test]
+fn suspended_account_rejects_spend_without_partial_debit() {
+ futures::executor::block_on(async {
+ let admin = PrivateKey::ed25519_from_seed(11);
+ let billing = PrivateKey::ed25519_from_seed(12);
+ let ingest = PrivateKey::ed25519_from_seed(13);
+ let mut state = MemoryState::default();
+ state.set_writer(WriterRole::Admin, &address(&admin), true);
+ state.set_writer(WriterRole::Billing, &address(&billing), true);
+ state.set_writer(WriterRole::Ingest, &address(&ingest), true);
+ let mut ledger = CostsLedger::new(&mut state);
+
+ ledger
+ .apply_transaction(&Transaction::sign(
+ &admin,
+ 0,
+ onboard("account_blocked"),
+ ))
+ .await
+ .unwrap();
+ let rate = RateCardEntry {
+ account_id: String::new(),
+ event_category: "creative.action".to_string(),
+ task_key: String::new(),
+ credits: 1,
+ effective_at: 1,
+ expires_at: 0,
+ policy_version: "policy_blocked_1".to_string(),
+ rate_version: "rate_blocked_1".to_string(),
+ };
+ let changeset = crate::RateCardChangeSetV1 { change_set_id: "changeset_blocked_1".to_string(), expected_entry_count: 1, target_account_ids: vec!["account_blocked".to_string()], expected_target_count: 1, manifest_hash: "manifest_blocked_1".to_string(), staged_entry_count: 0, approval_ref: "approval_blocked".to_string(), audit_ref: "audit_blocked".to_string(), activation_epoch: 1, applied: false };
+ let changeset = canonical_changeset(changeset, &[rate.clone()]);
+ ledger
+ .apply_transaction(&Transaction::sign(
+ &admin,
+ 1,
+ CostsOperation::StageRateCardChangeSetV1 { change_set: changeset.clone(),
+ entries: vec![rate.clone()],
+ },
+ ))
+ .await
+ .unwrap();
+ ledger
+ .apply_transaction(&Transaction::sign(
+ &admin,
+ 2,
+ CostsOperation::ApplyRateCardChangeSetV1 { change_set: changeset,
+ entries: vec![rate],
+ },
+ ))
+ .await
+ .unwrap();
+ ledger
+ .apply_transaction(&Transaction::sign(
+ &billing,
+ 0,
+ CostsOperation::CreditTopup {
+ account_id: "account_blocked".to_string(),
+ credits: 20,
+ rail_ref: "pi_demo_2".to_string(),
+ },
+ ))
+ .await
+ .unwrap();
+ ledger
+ .apply_transaction(&Transaction::sign(
+ &admin,
+ 3,
+ CostsOperation::SetAccountStatusV1 {
+ account_id: "account_blocked".to_string(),
+ status: AccountStatus::Suspended,
+ metadata: StatusChangeMetadataV1 { reason_code: "suspended".to_string(), changed_at: 2, approval_ref: "approval_blocked".to_string(), audit_ref: "audit_blocked".to_string() },
+ },
+ ))
+ .await
+ .unwrap();
+ let error = ledger
+ .apply_transaction(&Transaction::sign(
+ &ingest,
+ 0,
+ CostsOperation::RecordSpendBatch {
+ records: vec![SpendRecordV1 {
+ event_id: "usage:evt_2".to_string(),
+ account_id: "account_blocked".to_string(),
+ event_category: "creative.action".to_string(),
+ task_key: String::new(),
+ quantity: 1,
+ credits: 1,
+ observed_at: 1,
+ policy_version: "policy_blocked_1".to_string(),
+ rate_version: "rate_blocked_1".to_string(),
+ source_ref: "source_evt_2".to_string(),
+ lineage_ref: "lineage_evt_2".to_string(),
+ cohort_ref: String::new(),
+ }],
+ },
+ ))
+ .await
+ .unwrap_err();
+ assert!(matches!(error, CostsError::AccountSuspended { .. }));
+ assert_eq!(ledger.account("account_blocked").await.unwrap().unwrap().available_credits, 20);
+ });
+}
+
+#[test]
+fn reservations_adjustments_and_untracked_sources_preserve_balance_invariants() {
+ futures::executor::block_on(async {
+ let admin = PrivateKey::ed25519_from_seed(21);
+ let billing = PrivateKey::ed25519_from_seed(22);
+ let ingest = PrivateKey::ed25519_from_seed(23);
+ let adjustment = PrivateKey::ed25519_from_seed(24);
+ let mut state = MemoryState::default();
+ state.set_writer(WriterRole::Admin, &address(&admin), true);
+ state.set_writer(WriterRole::Billing, &address(&billing), true);
+ state.set_writer(WriterRole::Ingest, &address(&ingest), true);
+ state.set_writer(WriterRole::Adjustment, &address(&adjustment), true);
+ let mut ledger = CostsLedger::new(&mut state);
+
+ ledger
+ .apply_transaction(&Transaction::sign(
+ &admin,
+ 0,
+ onboard("account_reserve"),
+ ))
+ .await
+ .unwrap();
+ let rate = RateCardEntry { account_id: String::new(), event_category: "creative.render".to_string(), task_key: String::new(), credits: 1, effective_at: 1, expires_at: 0, policy_version: "policy_reserve".to_string(), rate_version: "rate_reserve".to_string() };
+ let changeset = crate::RateCardChangeSetV1 { change_set_id: "reserve_rates".to_string(), expected_entry_count: 1, target_account_ids: vec!["account_reserve".to_string()], expected_target_count: 1, manifest_hash: "reserve_manifest".to_string(), staged_entry_count: 0, approval_ref: "reserve_approval".to_string(), audit_ref: "reserve_audit".to_string(), activation_epoch: 1, applied: false };
+ let changeset = canonical_changeset(changeset, &[rate.clone()]);
+ ledger.apply_transaction(&Transaction::sign(&admin, 1, CostsOperation::StageRateCardChangeSetV1 { change_set: changeset.clone(), entries: vec![rate.clone()] })).await.unwrap();
+ ledger.apply_transaction(&Transaction::sign(&admin, 2, CostsOperation::ApplyRateCardChangeSetV1 { change_set: changeset, entries: vec![rate] })).await.unwrap();
+ let quote_one = ledger.quote_request(crate::QuoteRequestV1 { account_id: "account_reserve".to_string(), event_category: "creative.render".to_string(), task_key: String::new(), quantity: 30, quoted_at: 2, expires_at: 1_800_000_000 }).await.unwrap().unwrap();
+ ledger
+ .apply_transaction(&Transaction::sign(
+ &billing,
+ 0,
+ CostsOperation::CreditTopup {
+ account_id: "account_reserve".to_string(),
+ credits: 100,
+ rail_ref: "pi_reserve_1".to_string(),
+ },
+ ))
+ .await
+ .unwrap();
+ ledger
+ .apply_transaction(&Transaction::sign(
+ &ingest,
+ 0,
+ CostsOperation::ReserveCreditsV1 {
+ reservation_id: "reservation_1".to_string(),
+ quote: quote_one,
+ lineage_ref: "lineage_reserve_1".to_string(), reserved_at: 3,
+ },
+ ))
+ .await
+ .unwrap();
+ let account = ledger.account("account_reserve").await.unwrap().unwrap();
+ assert_eq!((account.available_credits, account.reserved_credits), (70, 30));
+
+ ledger
+ .apply_transaction(&Transaction::sign(
+ &ingest,
+ 1,
+ CostsOperation::ReleaseReservationV1 {
+ reservation_id: "reservation_1".to_string(),
+ metadata: ReservationActionMetadataV1 { reason_code: "released".to_string(), occurred_at: 4, approval_ref: "reserve_approval".to_string(), audit_ref: "reserve_audit".to_string() },
+ },
+ ))
+ .await
+ .unwrap();
+ let account = ledger.account("account_reserve").await.unwrap().unwrap();
+ assert_eq!((account.available_credits, account.reserved_credits), (100, 0));
+
+ let quote_two = ledger.quote_request(crate::QuoteRequestV1 { account_id: "account_reserve".to_string(), event_category: "creative.render".to_string(), task_key: String::new(), quantity: 25, quoted_at: 5, expires_at: 1_800_000_001 }).await.unwrap().unwrap();
+ ledger
+ .apply_transaction(&Transaction::sign(
+ &ingest,
+ 2,
+ CostsOperation::ReserveCreditsV1 {
+ reservation_id: "reservation_2".to_string(),
+ quote: quote_two.clone(), lineage_ref: "lineage_reserve_2".to_string(), reserved_at: 6,
+ },
+ ))
+ .await
+ .unwrap();
+ let settle = CostsOperation::SettleSpendV1 {
+ reservation_id: "reservation_2".to_string(),
+ event_id: "usage:reserved_event_1".to_string(),
+ event_category: "creative.render".to_string(),
+ task_key: String::new(), quote_id: quote_two.quote_id.clone(), snapshot_hash: quote_two.snapshot_hash.clone(), lineage_ref: "lineage_reserve_2".to_string(), metadata: ReservationActionMetadataV1 { reason_code: "settled".to_string(), occurred_at: 7, approval_ref: "reserve_approval".to_string(), audit_ref: "reserve_audit".to_string() },
+ };
+ ledger
+ .apply_transaction(&Transaction::sign(&ingest, 3, settle.clone()))
+ .await
+ .unwrap();
+ ledger
+ .apply_transaction(&Transaction::sign(&ingest, 4, settle))
+ .await
+ .unwrap();
+ let account = ledger.account("account_reserve").await.unwrap().unwrap();
+ assert_eq!((account.available_credits, account.reserved_credits), (75, 0));
+
+ let grant = CostsOperation::CreditAdjustmentV1 {
+ kind: AdjustmentKind::Grant,
+ account_id: "account_reserve".to_string(),
+ credits: 10,
+ metadata: adjustment_metadata("grant_welcome_1"),
+ };
+ ledger
+ .apply_transaction(&Transaction::sign(&adjustment, 0, grant.clone()))
+ .await
+ .unwrap();
+ ledger
+ .apply_transaction(&Transaction::sign(&adjustment, 1, grant))
+ .await
+ .unwrap();
+ ledger
+ .apply_transaction(&Transaction::sign(
+ &adjustment,
+ 2,
+ CostsOperation::CreditAdjustmentV1 {
+ kind: AdjustmentKind::Reversal,
+ account_id: "account_reserve".to_string(),
+ credits: 5,
+ metadata: adjustment_metadata("reversal_event_1"),
+ },
+ ))
+ .await
+ .unwrap();
+ assert_eq!(ledger.account("account_reserve").await.unwrap().unwrap().available_credits, 80);
+
+ ledger
+ .apply_transaction(&Transaction::sign(
+ &admin,
+ 3,
+ CostsOperation::RegisterUntrackedSourceV1 { source: UntrackedSourceV1 {
+ source_id: "source_dark_1".to_string(),
+ reason_code: "shared_cost".to_string(),
+ owner_ref: "owner_finance_1".to_string(), period_ref: "period_2026_07".to_string(),
+ provenance_ref: "source_shared_1".to_string(), coverage_code: "cost_dark".to_string(),
+ confidence_code: "unverified".to_string(), evidence_ref: "evidence_1".to_string(), cohort_ref: String::new(),
+ } },
+ ))
+ .await
+ .unwrap();
+ assert_eq!(ledger.account("account_reserve").await.unwrap().unwrap().available_credits, 80);
+ });
+}
+
+#[test]
+fn rate_change_sets_are_atomic_and_use_scoped_precedence() {
+ futures::executor::block_on(async {
+ let admin = PrivateKey::ed25519_from_seed(31);
+ let mut state = MemoryState::default();
+ state.set_writer(WriterRole::Admin, &address(&admin), true);
+ let mut ledger = CostsLedger::new(&mut state);
+
+ let global_category = RateCardEntry {
+ account_id: String::new(),
+ event_category: "creative.render".to_string(),
+ task_key: String::new(),
+ credits: 2,
+ effective_at: 100,
+ expires_at: 0,
+ policy_version: "policy_1".to_string(),
+ rate_version: "rate_1".to_string(),
+ };
+ let global_task = RateCardEntry {
+ task_key: "image_hd".to_string(),
+ credits: 5,
+ rate_version: "rate_2".to_string(),
+ ..global_category.clone()
+ };
+ let account_category = RateCardEntry {
+ account_id: "account_priority".to_string(),
+ credits: 7,
+ rate_version: "rate_3".to_string(),
+ ..global_category.clone()
+ };
+ let all_entries = vec![
+ global_category.clone(),
+ global_task.clone(),
+ account_category.clone(),
+ ];
+ let change_one = crate::RateCardChangeSetV1 { change_set_id: "changeset_1".to_string(), expected_entry_count: 3, target_account_ids: vec!["account_priority".to_string()], expected_target_count: 1, manifest_hash: "manifest_1".to_string(), staged_entry_count: 0, approval_ref: "approval_1".to_string(), audit_ref: "audit_1".to_string(), activation_epoch: 1, applied: false };
+ let change_one = canonical_changeset(change_one, &all_entries);
+ ledger
+ .apply_transaction(&Transaction::sign(
+ &admin,
+ 0,
+ CostsOperation::StageRateCardChangeSetV1 { change_set: change_one.clone(),
+ entries: vec![global_category.clone(), global_task.clone()],
+ },
+ ))
+ .await
+ .unwrap();
+ let error = ledger
+ .apply_transaction(&Transaction::sign(
+ &admin,
+ 1,
+ CostsOperation::ApplyRateCardChangeSetV1 { change_set: change_one.clone(),
+ entries: all_entries.clone(),
+ },
+ ))
+ .await
+ .unwrap_err();
+ assert!(matches!(error, CostsError::RateChangeSetIncomplete { .. }));
+
+ ledger
+ .apply_transaction(&Transaction::sign(
+ &admin,
+ 1,
+ CostsOperation::StageRateCardChangeSetV1 { change_set: change_one.clone(),
+ entries: vec![account_category.clone()],
+ },
+ ))
+ .await
+ .unwrap();
+ ledger
+ .apply_transaction(&Transaction::sign(
+ &admin,
+ 2,
+ CostsOperation::ApplyRateCardChangeSetV1 { change_set: change_one,
+ entries: all_entries,
+ },
+ ))
+ .await
+ .unwrap();
+
+ assert_eq!(
+ ledger
+ .active_rate("account_priority", "creative.render", "image_hd", 100)
+ .await
+ .unwrap()
+ .unwrap()
+ .credits,
+ 7
+ );
+ assert_eq!(
+ ledger
+ .active_rate("account_new", "creative.render", "image_hd", 100)
+ .await
+ .unwrap()
+ .unwrap()
+ .credits,
+ 5
+ );
+
+ let account_task = RateCardEntry {
+ task_key: "image_hd".to_string(),
+ credits: 9,
+ rate_version: "rate_4".to_string(),
+ ..account_category
+ };
+ let change_two = crate::RateCardChangeSetV1 { change_set_id: "changeset_2".to_string(), expected_entry_count: 1, target_account_ids: vec!["account_priority".to_string()], expected_target_count: 1, manifest_hash: "manifest_2".to_string(), staged_entry_count: 0, approval_ref: "approval_2".to_string(), audit_ref: "audit_2".to_string(), activation_epoch: 2, applied: false };
+ let change_two = canonical_changeset(change_two, &[account_task.clone()]);
+ ledger
+ .apply_transaction(&Transaction::sign(
+ &admin,
+ 3,
+ CostsOperation::StageRateCardChangeSetV1 { change_set: change_two.clone(),
+ entries: vec![account_task.clone()],
+ },
+ ))
+ .await
+ .unwrap();
+ ledger
+ .apply_transaction(&Transaction::sign(
+ &admin,
+ 4,
+ CostsOperation::ApplyRateCardChangeSetV1 { change_set: change_two,
+ entries: vec![account_task],
+ },
+ ))
+ .await
+ .unwrap();
+ assert_eq!(
+ ledger
+ .active_rate("account_priority", "creative.render", "image_hd", 100)
+ .await
+ .unwrap()
+ .unwrap()
+ .credits,
+ 9
+ );
+ });
+}
+
+#[test]
+fn suspension_blocks_a_preexisting_reservation_from_settling() {
+ futures::executor::block_on(async {
+ let admin = PrivateKey::ed25519_from_seed(51);
+ let billing = PrivateKey::ed25519_from_seed(52);
+ let ingest = PrivateKey::ed25519_from_seed(53);
+ let mut state = MemoryState::default();
+ state.set_writer(WriterRole::Admin, &address(&admin), true);
+ state.set_writer(WriterRole::Billing, &address(&billing), true);
+ state.set_writer(WriterRole::Ingest, &address(&ingest), true);
+ let mut ledger = CostsLedger::new(&mut state);
+ ledger
+ .apply_transaction(&Transaction::sign(
+ &admin,
+ 0,
+ onboard("account_hold"),
+ ))
+ .await
+ .unwrap();
+ let rate = RateCardEntry { account_id: String::new(), event_category: "creative.render".to_string(), task_key: String::new(), credits: 1, effective_at: 1, expires_at: 0, policy_version: "policy_hold".to_string(), rate_version: "rate_hold".to_string() };
+ let changeset = crate::RateCardChangeSetV1 { change_set_id: "hold_rates".to_string(), expected_entry_count: 1, target_account_ids: vec!["account_hold".to_string()], expected_target_count: 1, manifest_hash: "hold_manifest".to_string(), staged_entry_count: 0, approval_ref: "hold_approval".to_string(), audit_ref: "hold_audit".to_string(), activation_epoch: 1, applied: false };
+ let changeset = canonical_changeset(changeset, &[rate.clone()]);
+ ledger.apply_transaction(&Transaction::sign(&admin, 1, CostsOperation::StageRateCardChangeSetV1 { change_set: changeset.clone(), entries: vec![rate.clone()] })).await.unwrap();
+ ledger.apply_transaction(&Transaction::sign(&admin, 2, CostsOperation::ApplyRateCardChangeSetV1 { change_set: changeset, entries: vec![rate] })).await.unwrap();
+ let quote = ledger.quote_request(crate::QuoteRequestV1 { account_id: "account_hold".to_string(), event_category: "creative.render".to_string(), task_key: String::new(), quantity: 20, quoted_at: 2, expires_at: 1_900_000_000 }).await.unwrap().unwrap();
+ ledger
+ .apply_transaction(&Transaction::sign(
+ &billing,
+ 0,
+ CostsOperation::CreditTopup {
+ account_id: "account_hold".to_string(),
+ credits: 30,
+ rail_ref: "pi_hold_1".to_string(),
+ },
+ ))
+ .await
+ .unwrap();
+ ledger
+ .apply_transaction(&Transaction::sign(
+ &ingest,
+ 0,
+ CostsOperation::ReserveCreditsV1 {
+ reservation_id: "hold_1".to_string(),
+ quote: quote.clone(), lineage_ref: "lineage_hold".to_string(), reserved_at: 3,
+ },
+ ))
+ .await
+ .unwrap();
+ ledger
+ .apply_transaction(&Transaction::sign(
+ &admin,
+ 3,
+ CostsOperation::SetAccountStatusV1 {
+ account_id: "account_hold".to_string(),
+ status: AccountStatus::Suspended,
+ metadata: StatusChangeMetadataV1 { reason_code: "suspended".to_string(), changed_at: 4, approval_ref: "hold_approval".to_string(), audit_ref: "hold_audit".to_string() },
+ },
+ ))
+ .await
+ .unwrap();
+ let error = ledger
+ .apply_transaction(&Transaction::sign(
+ &ingest,
+ 1,
+ CostsOperation::SettleSpendV1 {
+ reservation_id: "hold_1".to_string(),
+ event_id: "usage:hold_1".to_string(),
+ event_category: "creative.render".to_string(),
+ task_key: String::new(), quote_id: quote.quote_id.clone(), snapshot_hash: quote.snapshot_hash.clone(), lineage_ref: "lineage_hold".to_string(), metadata: ReservationActionMetadataV1 { reason_code: "settled".to_string(), occurred_at: 5, approval_ref: "hold_approval".to_string(), audit_ref: "hold_audit".to_string() },
+ },
+ ))
+ .await
+ .unwrap_err();
+ assert!(matches!(error, CostsError::ReservationAccountSuspended { .. }));
+ let account = ledger.account("account_hold").await.unwrap().unwrap();
+ assert_eq!((account.available_credits, account.reserved_credits), (10, 20));
+ assert_eq!(
+ ledger.reservation("hold_1").await.unwrap().unwrap().status,
+ crate::ReservationStatus::Active
+ );
+ });
+}
+
+#[test]
+fn changed_idempotency_replay_and_bad_pinned_rate_are_rejected() {
+ futures::executor::block_on(async {
+ let admin = PrivateKey::ed25519_from_seed(61);
+ let billing = PrivateKey::ed25519_from_seed(62);
+ let ingest = PrivateKey::ed25519_from_seed(63);
+ let mut state = MemoryState::default();
+ state.set_writer(WriterRole::Admin, &address(&admin), true);
+ state.set_writer(WriterRole::Billing, &address(&billing), true);
+ state.set_writer(WriterRole::Ingest, &address(&ingest), true);
+ state.set_writer(WriterRole::Adjustment, &address(&admin), true);
+ let mut ledger = CostsLedger::new(&mut state);
+ ledger
+ .apply_transaction(&Transaction::sign(
+ &admin,
+ 0,
+ onboard("account_pin"),
+ ))
+ .await
+ .unwrap();
+ let rate = RateCardEntry {
+ account_id: String::new(), event_category: "creative.render".to_string(),
+ task_key: String::new(), credits: 3, effective_at: 10, expires_at: 0,
+ policy_version: "policy_pin_1".to_string(), rate_version: "rate_pin_1".to_string(),
+ };
+ let changeset = crate::RateCardChangeSetV1 { change_set_id: "cs_pin".to_string(), expected_entry_count: 1, target_account_ids: vec!["account_pin".to_string()], expected_target_count: 1, manifest_hash: "manifest_pin".to_string(), staged_entry_count: 0, approval_ref: "approval_pin".to_string(), audit_ref: "audit_pin".to_string(), activation_epoch: 1, applied: false };
+ let changeset = canonical_changeset(changeset, &[rate.clone()]);
+ for (nonce, operation) in [
+ (1, CostsOperation::StageRateCardChangeSetV1 { change_set: changeset.clone(), entries: vec![rate.clone()] }),
+ (2, CostsOperation::ApplyRateCardChangeSetV1 { change_set: changeset, entries: vec![rate] }),
+ ] {
+ ledger.apply_transaction(&Transaction::sign(&admin, nonce, operation)).await.unwrap();
+ }
+ ledger.apply_transaction(&Transaction::sign(&billing, 0, CostsOperation::CreditTopup { account_id: "account_pin".to_string(), credits: 10, rail_ref: "pi_pin".to_string() })).await.unwrap();
+ let changed_topup = ledger.apply_transaction(&Transaction::sign(&billing, 1, CostsOperation::CreditTopup { account_id: "account_pin".to_string(), credits: 9, rail_ref: "pi_pin".to_string() })).await.unwrap_err();
+ assert!(matches!(changed_topup, CostsError::IdempotencyConflict { .. }));
+ let bad = SpendRecordV1 { event_id: "usage:pin_1".to_string(), account_id: "account_pin".to_string(), event_category: "creative.render".to_string(), task_key: String::new(), quantity: 1, credits: 2, observed_at: 10, policy_version: "policy_pin_1".to_string(), rate_version: "rate_pin_1".to_string(), source_ref: "source_pin".to_string(), lineage_ref: "lineage_pin".to_string(), cohort_ref: "cohort_1".to_string() };
+ let error = ledger.apply_transaction(&Transaction::sign(&ingest, 0, CostsOperation::RecordSpendBatch { records: vec![bad] })).await.unwrap_err();
+ assert!(matches!(error, CostsError::PinnedRateMismatch { .. }));
+ let valid = SpendRecordV1 { event_id: "usage:pin_duplicate".to_string(), account_id: "account_pin".to_string(), event_category: "creative.render".to_string(), task_key: String::new(), quantity: 1, credits: 3, observed_at: 10, policy_version: "policy_pin_1".to_string(), rate_version: "rate_pin_1".to_string(), source_ref: "source_pin".to_string(), lineage_ref: "lineage_pin".to_string(), cohort_ref: String::new() };
+ let mut changed = valid.clone();
+ changed.credits = 4;
+ let error = ledger.apply_transaction(&Transaction::sign(&ingest, 0, CostsOperation::RecordSpendBatch { records: vec![valid, changed] })).await.unwrap_err();
+ assert!(matches!(error, CostsError::IdempotencyConflict { .. }));
+ let metadata = crate::AdjustmentMetadata { reference: "adjustment_pin_1".to_string(), reason_code: "correction".to_string(), period_ref: "period_202607".to_string(), approval_ref: "approval_1".to_string(), audit_ref: "audit_1".to_string() };
+ ledger.apply_transaction(&Transaction::sign(&admin, 3, CostsOperation::CreditAdjustmentV1 { kind: crate::AdjustmentKind::Grant, account_id: "account_pin".to_string(), credits: 2, metadata: metadata.clone() })).await.unwrap();
+ let error = ledger.apply_transaction(&Transaction::sign(&admin, 4, CostsOperation::CreditAdjustmentV1 { kind: crate::AdjustmentKind::Reversal, account_id: "account_pin".to_string(), credits: 2, metadata })).await.unwrap_err();
+ assert!(matches!(error, CostsError::IdempotencyConflict { .. }));
+ let correction = Transaction::sign(&admin, 4, CostsOperation::CreditAdjustmentV1 {
+ kind: crate::AdjustmentKind::Reversal,
+ account_id: "account_pin".to_string(),
+ credits: 1,
+ metadata: crate::AdjustmentMetadata {
+ reference: "adjustment_pin_2".to_string(),
+ reason_code: "correction".to_string(),
+ period_ref: "period_202607".to_string(),
+ approval_ref: "approval_2".to_string(),
+ audit_ref: "audit_2".to_string(),
+ },
+ });
+ ledger.apply_transaction(&correction).await.unwrap();
+ assert_eq!(ledger.account("account_pin").await.unwrap().unwrap().available_credits, 11);
+ let mutation = ledger.journal(0, 100).await.unwrap().pop().unwrap();
+ assert_eq!(
+ FinalizedOutcomeV1::from_finalized_mutation(&mutation, correction.digest(), 20).kind,
+ FinalizedOutcomeKind::CreditAdjustmentApplied
+ );
+ });
+}
+
+#[test]
+fn onboarding_rotation_private_reads_and_expiry_release_are_deterministic() {
+ futures::executor::block_on(async {
+ let admin = PrivateKey::ed25519_from_seed(71);
+ let replacement = PrivateKey::ed25519_from_seed(72);
+ let billing = PrivateKey::ed25519_from_seed(73);
+ let ingest = PrivateKey::ed25519_from_seed(74);
+ let mut state = MemoryState::default();
+ state.set_writer(WriterRole::Admin, &address(&admin), true);
+ state.set_writer(WriterRole::Billing, &address(&billing), true);
+ state.set_writer(WriterRole::Ingest, &address(&ingest), true);
+ let mut ledger = CostsLedger::new(&mut state);
+ let onboard = CostsOperation::CreateAccount {
+ account_id: "account_program".to_string(),
+ external_ref: "onboarding_program_1".to_string(),
+ policy_ref: "policy_fixture_1".to_string(),
+ cohort_ref: "cohort_fixture_1".to_string(),
+ created_at: 100,
+ };
+ ledger
+ .apply_transaction(&Transaction::sign(&admin, 0, onboard.clone()))
+ .await
+ .unwrap();
+ ledger
+ .apply_transaction(&Transaction::sign(&admin, 1, onboard))
+ .await
+ .unwrap();
+ ledger
+ .apply_transaction(&Transaction::sign(
+ &admin,
+ 2,
+ CostsOperation::RotateAdmin {
+ replacement: address(&replacement),
+ },
+ ))
+ .await
+ .unwrap();
+ let rejected = ledger
+ .apply_transaction(&Transaction::sign(
+ &admin,
+ 3,
+ CostsOperation::SetAccountStatusV1 {
+ account_id: "account_program".to_string(),
+ status: AccountStatus::Suspended,
+ metadata: StatusChangeMetadataV1 { reason_code: "suspended".to_string(), changed_at: 110, approval_ref: "approval_program".to_string(), audit_ref: "audit_program".to_string() },
+ },
+ ))
+ .await
+ .unwrap_err();
+ assert!(matches!(rejected, CostsError::Unauthorized { .. }));
+ let rate = RateCardEntry {
+ account_id: String::new(),
+ event_category: "creative.render".to_string(),
+ task_key: String::new(),
+ credits: 5,
+ effective_at: 100,
+ expires_at: 150,
+ policy_version: "policy_program_1".to_string(),
+ rate_version: "rate_program_1".to_string(),
+ };
+ let changeset = crate::RateCardChangeSetV1 { change_set_id: "changeset_program_1".to_string(), expected_entry_count: 1, target_account_ids: vec!["account_program".to_string()], expected_target_count: 1, manifest_hash: "manifest_program_1".to_string(), staged_entry_count: 0, approval_ref: "approval_program".to_string(), audit_ref: "audit_program".to_string(), activation_epoch: 1, applied: false };
+ let changeset = canonical_changeset(changeset, &[rate.clone()]);
+ ledger
+ .apply_transaction(&Transaction::sign(
+ &replacement,
+ 0,
+ CostsOperation::StageRateCardChangeSetV1 { change_set: changeset.clone(),
+ entries: vec![rate.clone()],
+ },
+ ))
+ .await
+ .unwrap();
+ ledger
+ .apply_transaction(&Transaction::sign(
+ &replacement,
+ 1,
+ CostsOperation::ApplyRateCardChangeSetV1 { change_set: changeset,
+ entries: vec![rate],
+ },
+ ))
+ .await
+ .unwrap();
+ let quote = ledger.quote_request(crate::QuoteRequestV1 { account_id: "account_program".to_string(), event_category: "creative.render".to_string(), task_key: String::new(), quantity: 1, quoted_at: 110, expires_at: 140 }).await.unwrap().unwrap();
+ ledger
+ .apply_transaction(&Transaction::sign(
+ &billing,
+ 0,
+ CostsOperation::CreditTopup {
+ account_id: "account_program".to_string(),
+ credits: 10,
+ rail_ref: "pi_program_1".to_string(),
+ },
+ ))
+ .await
+ .unwrap();
+ let read = ledger.account_read("account_program").await.unwrap().unwrap();
+ assert_eq!(read.profile.policy_ref, "policy_fixture_1");
+ assert_eq!(read.profile.cohort_ref, "cohort_fixture_1");
+ assert_eq!(ledger.status_history("account_program").await.unwrap().len(), 1);
+ assert_eq!(
+ ledger
+ .quote("account_program", "creative.render", "", 100)
+ .await
+ .unwrap()
+ .unwrap()
+ .credits_per_unit,
+ 5
+ );
+ assert!(
+ ledger
+ .quote("account_program", "creative.render", "", 150)
+ .await
+ .unwrap()
+ .is_none()
+ );
+ ledger
+ .apply_transaction(&Transaction::sign(
+ &ingest,
+ 0,
+ CostsOperation::ReserveCreditsV1 {
+ reservation_id: "reservation_program_1".to_string(),
+ quote: quote.clone(), lineage_ref: "lineage_program".to_string(), reserved_at: 111,
+ },
+ ))
+ .await
+ .unwrap();
+ ledger
+ .apply_transaction(&Transaction::sign(
+ &ingest,
+ 1,
+ CostsOperation::ExpireReservationV1 {
+ reservation_id: "reservation_program_1".to_string(),
+ metadata: ReservationActionMetadataV1 { reason_code: "expired".to_string(), occurred_at: 140, approval_ref: "approval_program".to_string(), audit_ref: "audit_program".to_string() },
+ },
+ ))
+ .await
+ .unwrap();
+ let account = ledger.account("account_program").await.unwrap().unwrap();
+ assert_eq!((account.available_credits, account.reserved_credits), (10, 0));
+ assert_eq!(
+ ledger
+ .reservation("reservation_program_1")
+ .await
+ .unwrap()
+ .unwrap()
+ .status,
+ crate::ReservationStatus::Expired
+ );
+ let error = ledger
+ .apply_transaction(&Transaction::sign(
+ &ingest,
+ 2,
+ CostsOperation::SettleSpendV1 {
+ reservation_id: "reservation_program_1".to_string(),
+ event_id: "usage:expired_program_1".to_string(),
+ event_category: "creative.render".to_string(),
+ task_key: String::new(), quote_id: quote.quote_id.clone(), snapshot_hash: quote.snapshot_hash.clone(), lineage_ref: "lineage_program".to_string(), metadata: ReservationActionMetadataV1 { reason_code: "settled".to_string(), occurred_at: 141, approval_ref: "approval_program".to_string(), audit_ref: "audit_program".to_string() },
+ },
+ ))
+ .await
+ .unwrap_err();
+ assert!(matches!(error, CostsError::ReservationNotActive { .. } | CostsError::ReservationExpired { .. }));
+ });
+}
+
+#[test]
+fn replay_is_a_nonce_consuming_noop_without_duplicate_journal_entries() {
+ futures::executor::block_on(async {
+ let admin = PrivateKey::ed25519_from_seed(111);
+ let billing = PrivateKey::ed25519_from_seed(112);
+ let ingest = PrivateKey::ed25519_from_seed(113);
+ let mut state = MemoryState::default();
+ state.set_writer(WriterRole::Admin, &address(&admin), true);
+ state.set_writer(WriterRole::Billing, &address(&billing), true);
+ state.set_writer(WriterRole::Ingest, &address(&ingest), true);
+ let mut ledger = CostsLedger::new(&mut state);
+ ledger.apply_transaction(&Transaction::sign(&admin, 0, onboard("account_replay"))).await.unwrap();
+ let rate = RateCardEntry { account_id: String::new(), event_category: "sms.send".to_string(), task_key: String::new(), credits: 2, effective_at: 1, expires_at: 0, policy_version: "policy_replay".to_string(), rate_version: "rate_replay".to_string() };
+ let changeset = crate::RateCardChangeSetV1 { change_set_id: "cs_replay".to_string(), expected_entry_count: 1, target_account_ids: vec!["account_replay".to_string()], expected_target_count: 1, manifest_hash: "manifest_replay".to_string(), staged_entry_count: 0, approval_ref: "approval_replay".to_string(), audit_ref: "audit_replay".to_string(), activation_epoch: 1, applied: false };
+ let changeset = canonical_changeset(changeset, &[rate.clone()]);
+ ledger.apply_transaction(&Transaction::sign(&admin, 1, CostsOperation::StageRateCardChangeSetV1 { change_set: changeset.clone(), entries: vec![rate.clone()] })).await.unwrap();
+ ledger.apply_transaction(&Transaction::sign(&admin, 2, CostsOperation::ApplyRateCardChangeSetV1 { change_set: changeset, entries: vec![rate] })).await.unwrap();
+ let first = CostsOperation::CreditTopup { account_id: "account_replay".to_string(), credits: 20, rail_ref: "rail_replay".to_string() };
+ ledger.apply_transaction(&Transaction::sign(&billing, 0, first.clone())).await.unwrap();
+ let before_topup_replay = ledger.journal(0, 100).await.unwrap().len();
+ assert!(ledger.apply_transaction_with_outcomes(&Transaction::sign(&billing, 1, first)).await.unwrap().is_empty());
+ assert_eq!(ledger.journal(0, 100).await.unwrap().len(), before_topup_replay);
+
+ let spend = SpendRecordV1 { event_id: "evt_replay".to_string(), account_id: "account_replay".to_string(), event_category: "sms.send".to_string(), task_key: String::new(), quantity: 1, credits: 2, observed_at: 2, policy_version: "policy_replay".to_string(), rate_version: "rate_replay".to_string(), source_ref: "source_replay".to_string(), lineage_ref: "lineage_replay".to_string(), cohort_ref: String::new() };
+ let outcomes = ledger.apply_transaction_with_outcomes(&Transaction::sign(&ingest, 0, CostsOperation::RecordSpendBatch { records: vec![spend.clone(), spend.clone()] })).await.unwrap();
+ assert_eq!(outcomes.len(), 1, "in-batch exact duplicate has one debit and one journal event");
+ let before_batch_replay = ledger.journal(0, 100).await.unwrap().len();
+ assert!(ledger.apply_transaction_with_outcomes(&Transaction::sign(&ingest, 1, CostsOperation::RecordSpendBatch { records: vec![spend] })).await.unwrap().is_empty());
+ assert_eq!(ledger.journal(0, 100).await.unwrap().len(), before_batch_replay);
+ let conflict = ledger.apply_transaction(&Transaction::sign(&ingest, 2, CostsOperation::RecordSpendBatch { records: vec![SpendRecordV1 { credits: 4, quantity: 2, ..SpendRecordV1 { event_id: "evt_replay".to_string(), account_id: "account_replay".to_string(), event_category: "sms.send".to_string(), task_key: String::new(), quantity: 1, credits: 2, observed_at: 2, policy_version: "policy_replay".to_string(), rate_version: "rate_replay".to_string(), source_ref: "source_replay".to_string(), lineage_ref: "lineage_replay".to_string(), cohort_ref: String::new() } }] })).await.unwrap_err();
+ assert!(matches!(conflict, CostsError::IdempotencyConflict { .. }));
+ });
+}
+
+#[test]
+fn rate_history_resolves_historical_revision_without_repricing() {
+ futures::executor::block_on(async {
+ let admin = PrivateKey::ed25519_from_seed(114);
+ let mut state = MemoryState::default();
+ state.set_writer(WriterRole::Admin, &address(&admin), true);
+ let mut ledger = CostsLedger::new(&mut state);
+ let early = RateCardEntry { account_id: String::new(), event_category: "creative.render".to_string(), task_key: String::new(), credits: 3, effective_at: 10, expires_at: 0, policy_version: "policy_a".to_string(), rate_version: "global_v1".to_string() };
+ let later = RateCardEntry { credits: 5, effective_at: 20, rate_version: "global_v2".to_string(), ..early.clone() };
+ let scoped = RateCardEntry { account_id: "account_history".to_string(), credits: 7, effective_at: 15, rate_version: "account_v1".to_string(), ..early.clone() };
+ for (nonce, id, entry) in [(0, "history_1", early.clone()), (2, "history_2", later), (4, "history_3", scoped)] {
+ let targets = if entry.account_id.is_empty() { Vec::new() } else { vec![entry.account_id.clone()] };
+ let change_set = crate::RateCardChangeSetV1 { change_set_id: id.to_string(), expected_entry_count: 1, expected_target_count: targets.len() as u16, target_account_ids: targets, manifest_hash: "placeholder".to_string(), staged_entry_count: 0, approval_ref: format!("approval_{id}"), audit_ref: format!("audit_{id}"), activation_epoch: nonce + 1, applied: false };
+ let change_set = canonical_changeset(change_set, &[entry.clone()]);
+ ledger.apply_transaction(&Transaction::sign(&admin, nonce, CostsOperation::StageRateCardChangeSetV1 { change_set: change_set.clone(), entries: vec![entry.clone()] })).await.unwrap();
+ ledger.apply_transaction(&Transaction::sign(&admin, nonce + 1, CostsOperation::ApplyRateCardChangeSetV1 { change_set, entries: vec![entry] })).await.unwrap();
+ }
+ assert_eq!(ledger.active_rate("account_other", "creative.render", "", 12).await.unwrap().unwrap().rate_version, "global_v1");
+ assert_eq!(ledger.active_rate("account_other", "creative.render", "", 22).await.unwrap().unwrap().rate_version, "global_v2");
+ assert_eq!(ledger.active_rate("account_history", "creative.render", "", 12).await.unwrap().unwrap().rate_version, "global_v1");
+ assert_eq!(ledger.active_rate("account_history", "creative.render", "", 16).await.unwrap().unwrap().rate_version, "account_v1");
+ });
+}
+
+#[test]
+fn v1_rate_manifest_binds_full_approval_payload_and_completion_is_once_only() {
+ futures::executor::block_on(async {
+ let admin = PrivateKey::ed25519_from_seed(141);
+ let mut state = MemoryState::default();
+ state.set_writer(WriterRole::Admin, &address(&admin), true);
+ let mut ledger = CostsLedger::new(&mut state);
+ ledger.apply_transaction(&Transaction::sign(&admin, 0, onboard("account_manifest"))).await.unwrap();
+ let rate = RateCardEntry { account_id: String::new(), event_category: "sms.send".to_string(), task_key: String::new(), credits: 3, effective_at: 10, expires_at: 0, policy_version: "policy_manifest".to_string(), rate_version: "rate_manifest".to_string() };
+ let raw = crate::RateCardChangeSetV1 { change_set_id: "manifest_v1".to_string(), expected_entry_count: 1, target_account_ids: vec!["account_manifest".to_string()], expected_target_count: 1, manifest_hash: "arbitrary".to_string(), staged_entry_count: 0, approval_ref: "approval_manifest".to_string(), audit_ref: "audit_manifest".to_string(), activation_epoch: 1, applied: false };
+ assert!(matches!(ledger.apply_transaction(&Transaction::sign(&admin, 1, CostsOperation::StageRateCardChangeSetV1 { change_set: raw.clone(), entries: vec![rate.clone()] })).await, Err(CostsError::RateChangeSetConflict { .. })));
+ let before_boundary = RateCardEntry { effective_at: 0, ..rate.clone() };
+ let before_boundary_envelope = canonical_changeset(raw.clone(), &[before_boundary.clone()]);
+ assert!(matches!(ledger.apply_transaction(&Transaction::sign(&admin, 1, CostsOperation::StageRateCardChangeSetV1 { change_set: before_boundary_envelope, entries: vec![before_boundary] })).await, Err(CostsError::InvalidField { field: "rate_effective_at_before_activation_epoch" })));
+ let envelope = canonical_changeset(raw, &[rate.clone()]);
+ ledger.apply_transaction(&Transaction::sign(&admin, 1, CostsOperation::StageRateCardChangeSetV1 { change_set: envelope.clone(), entries: vec![rate.clone()] })).await.unwrap();
+
+ let mut altered_targets = envelope.clone();
+ altered_targets.target_account_ids = Vec::new();
+ altered_targets.expected_target_count = 0;
+ altered_targets.manifest_hash = altered_targets.expected_manifest_hash(&[rate.clone()]);
+ assert!(matches!(ledger.apply_transaction(&Transaction::sign(&admin, 2, CostsOperation::ApplyRateCardChangeSetV1 { change_set: altered_targets, entries: vec![rate.clone()] })).await, Err(CostsError::RateChangeSetConflict { .. })));
+
+ let altered_entry = RateCardEntry { credits: 4, ..rate.clone() };
+ let altered_entry_envelope = canonical_changeset(envelope.clone(), &[altered_entry.clone()]);
+ assert!(matches!(ledger.apply_transaction(&Transaction::sign(&admin, 2, CostsOperation::ApplyRateCardChangeSetV1 { change_set: altered_entry_envelope, entries: vec![altered_entry] })).await, Err(CostsError::RateChangeSetConflict { .. })));
+
+ let outcomes = ledger.apply_transaction_with_outcomes(&Transaction::sign(&admin, 2, CostsOperation::ApplyRateCardChangeSetV1 { change_set: envelope.clone(), entries: vec![rate] })).await.unwrap();
+ let completion = outcomes.iter().filter(|outcome| outcome.kind == LedgerMutationKind::RateCardCompleted).collect::>();
+ assert_eq!(completion.len(), 1);
+ assert!(completion[0].has_rate_card_completion);
+ assert_eq!(completion[0].rate_card_completion.manifest_hash, envelope.manifest_hash);
+ assert_eq!(completion[0].rate_card_completion.target_count, 1);
+ assert_eq!(completion[0].rate_card_completion.affected_rates.len(), 2);
+ let finality = FinalizedOutcomeV1::from_finalized_mutation(completion[0], Transaction::sign(&admin, 99, CostsOperation::RotateAdmin { replacement: address(&PrivateKey::ed25519_from_seed(143)) }).digest(), 100);
+ assert_eq!(finality.event_type, "pricing.rate_card_updated");
+ let finality_completion = finality.rate_card_completion.unwrap();
+ assert_eq!(finality_completion.manifest_hash, envelope.manifest_hash);
+ assert!(finality_completion.affected_rates.iter().any(|rate| rate.account_id == "account_manifest" && rate.rate_version == "rate_manifest"));
+ assert!(ledger.apply_transaction_with_outcomes(&Transaction::sign(&admin, 3, CostsOperation::ApplyRateCardChangeSetV1 { change_set: envelope, entries: vec![RateCardEntry { account_id: String::new(), event_category: "sms.send".to_string(), task_key: String::new(), credits: 3, effective_at: 10, expires_at: 0, policy_version: "policy_manifest".to_string(), rate_version: "rate_manifest".to_string() }] })).await.unwrap().is_empty());
+ });
+}
+
+#[test]
+fn global_task_does_not_materialize_over_an_onboarded_account_category_and_account_task_wins() {
+ futures::executor::block_on(async {
+ let admin = PrivateKey::ed25519_from_seed(142);
+ let mut state = MemoryState::default();
+ state.set_writer(WriterRole::Admin, &address(&admin), true);
+ let mut ledger = CostsLedger::new(&mut state);
+ ledger.apply_transaction(&Transaction::sign(&admin, 0, onboard("account_scope_order"))).await.unwrap();
+ let account_category = RateCardEntry { account_id: "account_scope_order".to_string(), event_category: "creative.render".to_string(), task_key: String::new(), credits: 7, effective_at: 10, expires_at: 0, policy_version: "policy_scope".to_string(), rate_version: "account_category".to_string() };
+ let account_category_set = canonical_changeset(crate::RateCardChangeSetV1 { change_set_id: "account_category_set".to_string(), expected_entry_count: 1, target_account_ids: vec!["account_scope_order".to_string()], expected_target_count: 1, manifest_hash: "placeholder".to_string(), staged_entry_count: 0, approval_ref: "approval_scope".to_string(), audit_ref: "audit_scope".to_string(), activation_epoch: 1, applied: false }, &[account_category.clone()]);
+ ledger.apply_transaction(&Transaction::sign(&admin, 1, CostsOperation::StageRateCardChangeSetV1 { change_set: account_category_set.clone(), entries: vec![account_category.clone()] })).await.unwrap();
+ ledger.apply_transaction(&Transaction::sign(&admin, 2, CostsOperation::ApplyRateCardChangeSetV1 { change_set: account_category_set, entries: vec![account_category] })).await.unwrap();
+
+ let global_task = RateCardEntry { account_id: String::new(), event_category: "creative.render".to_string(), task_key: "image_hd".to_string(), credits: 5, effective_at: 20, expires_at: 0, policy_version: "policy_scope".to_string(), rate_version: "global_task".to_string() };
+ let global_set = canonical_changeset(crate::RateCardChangeSetV1 { change_set_id: "global_task_set".to_string(), expected_entry_count: 1, target_account_ids: Vec::new(), expected_target_count: 0, manifest_hash: "placeholder".to_string(), staged_entry_count: 0, approval_ref: "approval_scope".to_string(), audit_ref: "audit_scope".to_string(), activation_epoch: 3, applied: false }, &[global_task.clone()]);
+ ledger.apply_transaction(&Transaction::sign(&admin, 3, CostsOperation::StageRateCardChangeSetV1 { change_set: global_set.clone(), entries: vec![global_task.clone()] })).await.unwrap();
+ let outcomes = ledger.apply_transaction_with_outcomes(&Transaction::sign(&admin, 4, CostsOperation::ApplyRateCardChangeSetV1 { change_set: global_set, entries: vec![global_task] })).await.unwrap();
+ assert!(!outcomes.iter().any(|outcome| outcome.kind == LedgerMutationKind::RateCardApplied && outcome.account_id == "account_scope_order"));
+ assert_eq!(ledger.active_rate("account_scope_order", "creative.render", "image_hd", 20).await.unwrap().unwrap().rate_version, "account_category");
+
+ let account_task = RateCardEntry { account_id: "account_scope_order".to_string(), event_category: "creative.render".to_string(), task_key: "image_hd".to_string(), credits: 9, effective_at: 30, expires_at: 0, policy_version: "policy_scope".to_string(), rate_version: "account_task".to_string() };
+ let account_task_set = canonical_changeset(crate::RateCardChangeSetV1 { change_set_id: "account_task_set".to_string(), expected_entry_count: 1, target_account_ids: vec!["account_scope_order".to_string()], expected_target_count: 1, manifest_hash: "placeholder".to_string(), staged_entry_count: 0, approval_ref: "approval_scope".to_string(), audit_ref: "audit_scope".to_string(), activation_epoch: 5, applied: false }, &[account_task.clone()]);
+ ledger.apply_transaction(&Transaction::sign(&admin, 5, CostsOperation::StageRateCardChangeSetV1 { change_set: account_task_set.clone(), entries: vec![account_task.clone()] })).await.unwrap();
+ ledger.apply_transaction(&Transaction::sign(&admin, 6, CostsOperation::ApplyRateCardChangeSetV1 { change_set: account_task_set, entries: vec![account_task] })).await.unwrap();
+ assert_eq!(ledger.active_rate("account_scope_order", "creative.render", "image_hd", 30).await.unwrap().unwrap().rate_version, "account_task");
+ });
+}
+
+#[test]
+fn grant_taxonomy_e2e_periodic_campaign_and_topup() {
+ use crate::grant::{
+ credit_grant_op, periodic_grant_metadata, campaign_grant_metadata, REASON_INCLUDED_CREDITS,
+ REASON_PROMOTION, REASON_TOPUP,
+ };
+
+ futures::executor::block_on(async {
+ let admin = PrivateKey::ed25519_from_seed(200);
+ let billing = PrivateKey::ed25519_from_seed(201);
+ let adjustment = PrivateKey::ed25519_from_seed(202);
+ let mut state = MemoryState::default();
+ state.set_writer(WriterRole::Admin, &address(&admin), true);
+ state.set_writer(WriterRole::Billing, &address(&billing), true);
+ state.set_writer(WriterRole::Adjustment, &address(&adjustment), true);
+ let mut ledger = CostsLedger::new(&mut state);
+
+ ledger
+ .apply_transaction(&Transaction::sign(
+ &admin,
+ 0,
+ onboard("account_grants"),
+ ))
+ .await
+ .unwrap();
+
+ // periodic program monthly grant (idempotent)
+ let periodic = credit_grant_op(
+ "account_grants".to_string(),
+ 3_000,
+ periodic_grant_metadata("account_grants", "2026-07", REASON_INCLUDED_CREDITS, "policy_auto"),
+ );
+ ledger
+ .apply_transaction(&Transaction::sign(&adjustment, 0, periodic.clone()))
+ .await
+ .unwrap();
+ ledger
+ .apply_transaction(&Transaction::sign(&adjustment, 1, periodic))
+ .await
+ .unwrap();
+
+ // Campaign campaign grant
+ let campaign = credit_grant_op(
+ "account_grants".to_string(),
+ 500,
+ campaign_grant_metadata(
+ "launch_2026q3",
+ "account_grants",
+ REASON_PROMOTION,
+ "csm_approval_1",
+ "audit_promo_1",
+ ),
+ );
+ ledger
+ .apply_transaction(&Transaction::sign(&adjustment, 2, campaign))
+ .await
+ .unwrap();
+
+ // payment rail top-up with bonus baked into total credits
+ ledger
+ .apply_transaction(&Transaction::sign(
+ &billing,
+ 0,
+ CostsOperation::CreditTopup {
+ account_id: "account_grants".to_string(),
+ credits: 8_888,
+ rail_ref: "pi_showcase_bonus".to_string(),
+ },
+ ))
+ .await
+ .unwrap();
+
+ let account = ledger.account("account_grants").await.unwrap().unwrap();
+ assert_eq!(account.available_credits, 3_000 + 500 + 8_888);
+
+ let journal = ledger.journal(0, u16::MAX).await.unwrap();
+ let grant_entries: Vec<_> = journal
+ .iter()
+ .filter(|e| e.kind == LedgerMutationKind::BalanceChanged && e.reason_code != REASON_TOPUP)
+ .collect();
+ assert_eq!(grant_entries.len(), 2);
+ assert_eq!(grant_entries[0].reason_code, REASON_INCLUDED_CREDITS);
+ assert_eq!(grant_entries[1].reason_code, REASON_PROMOTION);
+
+ let topup_entries: Vec<_> = journal
+ .iter()
+ .filter(|e| e.reason_code == REASON_TOPUP)
+ .collect();
+ assert_eq!(topup_entries.len(), 1);
+ assert_eq!(topup_entries[0].credit_delta, 8_888);
+ });
+}
diff --git a/costs/src/transaction.rs b/costs/src/transaction.rs
new file mode 100644
index 0000000..f057b37
--- /dev/null
+++ b/costs/src/transaction.rs
@@ -0,0 +1,565 @@
+use commonware_codec::{EncodeSize, Error, Read, ReadExt, Write};
+use nunchi_common::Operation;
+
+use crate::{
+ types::{identifier_encode_size, read_identifier, write_identifier},
+ AdjustmentKind, AdjustmentMetadata, CreditGrantV2, CreditTopupV2, QuoteV1,
+ RateCardChangeSetV1, RateCardEntry, RefundPaidLotV1, ReservationActionMetadataV1,
+ SpendRecordV1, StatusChangeMetadataV1, StoredValueSpendV2, UntrackedSourceV1, WriterRole,
+ COSTS_NAMESPACE,
+};
+
+const OP_REGISTER_SITE: u8 = 0;
+const OP_SET_WRITER: u8 = 1;
+const OP_CREDIT_TOPUP: u8 = 2;
+const OP_RECORD_SPEND_BATCH: u8 = 3;
+const OP_SET_SITE_STATUS: u8 = 4;
+const OP_CREDIT_GRANT: u8 = 5;
+const OP_CREDIT_REVERSAL: u8 = 6;
+const OP_RESERVE_CREDITS: u8 = 7;
+const OP_RELEASE_RESERVATION: u8 = 8;
+const OP_SETTLE_SPEND: u8 = 9;
+const OP_REGISTER_UNTRACKED_SOURCE: u8 = 10;
+const OP_STAGE_RATE_CARD_ENTRIES: u8 = 11;
+const OP_APPLY_RATE_CARD_CHANGE_SET: u8 = 12;
+const OP_ONBOARD_SITE: u8 = 13;
+const OP_ROTATE_ADMIN: u8 = 14;
+const OP_EXPIRE_RESERVATION: u8 = 15;
+const OP_CREDIT_ADJUSTMENT_V1: u8 = 16;
+const OP_REGISTER_UNTRACKED_SOURCE_V1: u8 = 17;
+const OP_SET_SITE_WRITER: u8 = 18;
+const OP_SET_SITE_STATUS_V1: u8 = 19;
+const OP_STAGE_RATE_CARD_CHANGE_SET_V1: u8 = 20;
+const OP_APPLY_RATE_CARD_CHANGE_SET_V1: u8 = 21;
+const OP_RESERVE_CREDITS_V1: u8 = 22;
+const OP_SETTLE_SPEND_V1: u8 = 23;
+const OP_RELEASE_RESERVATION_V1: u8 = 24;
+const OP_EXPIRE_RESERVATION_V1: u8 = 25;
+const OP_STORED_VALUE_TOPUP_V2: u8 = 26;
+const OP_STORED_VALUE_GRANT_V2: u8 = 27;
+const OP_STORED_VALUE_SPEND_V2: u8 = 28;
+const OP_REFUND_PAID_LOT_V1: u8 = 29;
+const OP_RESERVE_STORED_VALUE_V2: u8 = 30;
+const OP_RELEASE_STORED_VALUE_RESERVATION_V2: u8 = 31;
+const OP_EXPIRE_STORED_VALUE_RESERVATION_V2: u8 = 32;
+const OP_SETTLE_STORED_VALUE_RESERVATION_V2: u8 = 33;
+
+/// Signed state transitions supported by the costs module.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum CostsOperation {
+ RegisterAccount { account_id: String },
+ /// Idempotent programmatic onboarding. All fields are opaque service
+ /// references and must be authorized by an administrator signer.
+ CreateAccount {
+ account_id: String,
+ external_ref: String,
+ policy_ref: String,
+ cohort_ref: String,
+ created_at: u64,
+ },
+ SetWriter { role: WriterRole, writer: nunchi_common::Address, enabled: bool },
+ /// Grants/revokes a backend capability for precisely one account. Global
+ /// `SetWriter` remains the break-glass system capability.
+ SetAccountWriter { account_id: String, role: WriterRole, writer: nunchi_common::Address, enabled: bool },
+ /// Safe capability handoff: only the current administrator can remove its
+ /// own capability after enabling the replacement.
+ RotateAdmin { replacement: nunchi_common::Address },
+ CreditTopup { account_id: String, credits: u64, rail_ref: String },
+ /// Provenance-preserving paid credit ingress. This is the clean-state
+ /// stored-value command; legacy aggregate `CreditTopup` remains metering-only.
+ StoredValueTopupV2 { topup: CreditTopupV2 },
+ /// Immutable non-refundable grant lot with operator approval evidence.
+ StoredValueGrantV2 { grant: CreditGrantV2 },
+ /// Deterministic grant-first lot allocation for a finalized spend event.
+ StoredValueSpendV2 { spend: StoredValueSpendV2 },
+ /// Support-mediated refund of unused paid credit to its originating rail.
+ RefundPaidLotV1 { refund: RefundPaidLotV1 },
+ /// Grant-first lot reservation for a long-running action. The reservation
+ /// is private ledger state, never a public client-side balance mutation.
+ ReserveStoredValueV2 {
+ reservation_id: String,
+ account_id: String,
+ credits: u64,
+ expires_at: u64,
+ reserved_at: u64,
+ },
+ ReleaseStoredValueReservationV2 { reservation_id: String, released_at: u64 },
+ ExpireStoredValueReservationV2 { reservation_id: String, expired_at: u64 },
+ SettleStoredValueReservationV2 { reservation_id: String, spend: StoredValueSpendV2 },
+ RecordSpendBatch { records: Vec },
+ SetAccountStatus { account_id: String, status: crate::AccountStatus },
+ SetAccountStatusV1 { account_id: String, status: crate::AccountStatus, metadata: StatusChangeMetadataV1 },
+ CreditGrant { account_id: String, credits: u64, grant_ref: String },
+ CreditReversal { account_id: String, credits: u64, reversal_ref: String },
+ ReserveCredits {
+ reservation_id: String,
+ account_id: String,
+ credits: u64,
+ expires_at: u64,
+ },
+ /// Quote-bound fixed-price hold. Quotes are immutable snapshots and do
+ /// not reprice when a later card is activated.
+ ReserveCreditsV1 { reservation_id: String, quote: QuoteV1, lineage_ref: String, reserved_at: u64 },
+ ReleaseReservation { reservation_id: String },
+ /// Authorization-layer clock input. Releases only after the reservation's
+ /// immutable expiry and is idempotent after expiry.
+ ExpireReservation { reservation_id: String, expired_at: u64 },
+ ReleaseReservationV1 { reservation_id: String, metadata: ReservationActionMetadataV1 },
+ ExpireReservationV1 { reservation_id: String, metadata: ReservationActionMetadataV1 },
+ SettleSpend {
+ reservation_id: String,
+ event_id: String,
+ event_category: String,
+ },
+ SettleSpendV1 { reservation_id: String, event_id: String, event_category: String, task_key: String, quote_id: String, snapshot_hash: String, lineage_ref: String, metadata: ReservationActionMetadataV1 },
+ RegisterUntrackedSource { source_id: String, reason_code: String },
+ RegisterUntrackedSourceV1 { source: UntrackedSourceV1 },
+ CreditAdjustmentV1 {
+ kind: AdjustmentKind,
+ account_id: String,
+ credits: u64,
+ metadata: AdjustmentMetadata,
+ },
+ StageRateCardEntries {
+ change_set_id: String,
+ expected_entry_count: u16,
+ manifest_hash: String,
+ entries: Vec,
+ },
+ ApplyRateCardChangeSet {
+ change_set_id: String,
+ manifest_hash: String,
+ entries: Vec,
+ },
+ StageRateCardChangeSetV1 { change_set: RateCardChangeSetV1, entries: Vec },
+ ApplyRateCardChangeSetV1 { change_set: RateCardChangeSetV1, entries: Vec },
+}
+
+impl Write for CostsOperation {
+ fn write(&self, buf: &mut impl bytes::BufMut) {
+ match self {
+ Self::RegisterAccount { account_id } => {
+ OP_REGISTER_SITE.write(buf);
+ write_identifier(account_id, buf);
+ }
+ Self::CreateAccount { account_id, external_ref, policy_ref, cohort_ref, created_at } => {
+ OP_ONBOARD_SITE.write(buf);
+ write_identifier(account_id, buf);
+ write_identifier(external_ref, buf);
+ write_identifier(policy_ref, buf);
+ write_identifier(cohort_ref, buf);
+ created_at.write(buf);
+ }
+ Self::SetWriter { role, writer, enabled } => {
+ OP_SET_WRITER.write(buf);
+ role.write(buf);
+ writer.write(buf);
+ enabled.write(buf);
+ }
+ Self::SetAccountWriter { account_id, role, writer, enabled } => {
+ OP_SET_SITE_WRITER.write(buf);
+ write_identifier(account_id, buf);
+ role.write(buf);
+ writer.write(buf);
+ enabled.write(buf);
+ }
+ Self::RotateAdmin { replacement } => {
+ OP_ROTATE_ADMIN.write(buf);
+ replacement.write(buf);
+ }
+ Self::CreditTopup { account_id, credits, rail_ref } => {
+ OP_CREDIT_TOPUP.write(buf);
+ write_identifier(account_id, buf);
+ credits.write(buf);
+ write_identifier(rail_ref, buf);
+ }
+ Self::StoredValueTopupV2 { topup } => {
+ OP_STORED_VALUE_TOPUP_V2.write(buf);
+ topup.write(buf);
+ }
+ Self::StoredValueGrantV2 { grant } => {
+ OP_STORED_VALUE_GRANT_V2.write(buf);
+ grant.write(buf);
+ }
+ Self::StoredValueSpendV2 { spend } => {
+ OP_STORED_VALUE_SPEND_V2.write(buf);
+ spend.write(buf);
+ }
+ Self::RefundPaidLotV1 { refund } => {
+ OP_REFUND_PAID_LOT_V1.write(buf);
+ refund.write(buf);
+ }
+ Self::ReserveStoredValueV2 { reservation_id, account_id, credits, expires_at, reserved_at } => {
+ OP_RESERVE_STORED_VALUE_V2.write(buf);
+ write_identifier(reservation_id, buf);
+ write_identifier(account_id, buf);
+ credits.write(buf);
+ expires_at.write(buf);
+ reserved_at.write(buf);
+ }
+ Self::ReleaseStoredValueReservationV2 { reservation_id, released_at } => {
+ OP_RELEASE_STORED_VALUE_RESERVATION_V2.write(buf);
+ write_identifier(reservation_id, buf);
+ released_at.write(buf);
+ }
+ Self::ExpireStoredValueReservationV2 { reservation_id, expired_at } => {
+ OP_EXPIRE_STORED_VALUE_RESERVATION_V2.write(buf);
+ write_identifier(reservation_id, buf);
+ expired_at.write(buf);
+ }
+ Self::SettleStoredValueReservationV2 { reservation_id, spend } => {
+ OP_SETTLE_STORED_VALUE_RESERVATION_V2.write(buf);
+ write_identifier(reservation_id, buf);
+ spend.write(buf);
+ }
+ Self::RecordSpendBatch { records } => {
+ OP_RECORD_SPEND_BATCH.write(buf);
+ (records.len() as u16).write(buf);
+ for record in records {
+ record.write(buf);
+ }
+ }
+ Self::SetAccountStatus { account_id, status } => {
+ OP_SET_SITE_STATUS.write(buf);
+ write_identifier(account_id, buf);
+ status.write(buf);
+ }
+ Self::SetAccountStatusV1 { account_id, status, metadata } => { OP_SET_SITE_STATUS_V1.write(buf); write_identifier(account_id, buf); status.write(buf); metadata.write(buf); }
+ Self::CreditGrant { account_id, credits, grant_ref } => {
+ OP_CREDIT_GRANT.write(buf);
+ write_identifier(account_id, buf);
+ credits.write(buf);
+ write_identifier(grant_ref, buf);
+ }
+ Self::CreditReversal { account_id, credits, reversal_ref } => {
+ OP_CREDIT_REVERSAL.write(buf);
+ write_identifier(account_id, buf);
+ credits.write(buf);
+ write_identifier(reversal_ref, buf);
+ }
+ Self::ReserveCredits { reservation_id, account_id, credits, expires_at } => {
+ OP_RESERVE_CREDITS.write(buf);
+ write_identifier(reservation_id, buf);
+ write_identifier(account_id, buf);
+ credits.write(buf);
+ expires_at.write(buf);
+ }
+ Self::ReserveCreditsV1 { reservation_id, quote, lineage_ref, reserved_at } => { OP_RESERVE_CREDITS_V1.write(buf); write_identifier(reservation_id, buf); write_quote(quote, buf); write_identifier(lineage_ref, buf); reserved_at.write(buf); }
+ Self::ReleaseReservation { reservation_id } => {
+ OP_RELEASE_RESERVATION.write(buf);
+ write_identifier(reservation_id, buf);
+ }
+ Self::ExpireReservation { reservation_id, expired_at } => {
+ OP_EXPIRE_RESERVATION.write(buf);
+ write_identifier(reservation_id, buf);
+ expired_at.write(buf);
+ }
+ Self::ReleaseReservationV1 { reservation_id, metadata } => { OP_RELEASE_RESERVATION_V1.write(buf); write_identifier(reservation_id, buf); metadata.write(buf); }
+ Self::ExpireReservationV1 { reservation_id, metadata } => { OP_EXPIRE_RESERVATION_V1.write(buf); write_identifier(reservation_id, buf); metadata.write(buf); }
+ Self::SettleSpend { reservation_id, event_id, event_category } => {
+ OP_SETTLE_SPEND.write(buf);
+ write_identifier(reservation_id, buf);
+ write_identifier(event_id, buf);
+ write_identifier(event_category, buf);
+ }
+ Self::SettleSpendV1 { reservation_id, event_id, event_category, task_key, quote_id, snapshot_hash, lineage_ref, metadata } => { OP_SETTLE_SPEND_V1.write(buf); write_identifier(reservation_id, buf); write_identifier(event_id, buf); write_identifier(event_category, buf); write_identifier(task_key, buf); write_identifier(quote_id, buf); write_identifier(snapshot_hash, buf); write_identifier(lineage_ref, buf); metadata.write(buf); }
+ Self::RegisterUntrackedSource { source_id, reason_code } => {
+ OP_REGISTER_UNTRACKED_SOURCE.write(buf);
+ write_identifier(source_id, buf);
+ write_identifier(reason_code, buf);
+ }
+ Self::RegisterUntrackedSourceV1 { source } => {
+ OP_REGISTER_UNTRACKED_SOURCE_V1.write(buf);
+ source.write(buf);
+ }
+ Self::CreditAdjustmentV1 { kind, account_id, credits, metadata } => {
+ OP_CREDIT_ADJUSTMENT_V1.write(buf);
+ kind.write(buf);
+ write_identifier(account_id, buf);
+ credits.write(buf);
+ metadata.write(buf);
+ }
+ Self::StageRateCardEntries { change_set_id, expected_entry_count, manifest_hash, entries } => {
+ OP_STAGE_RATE_CARD_ENTRIES.write(buf);
+ write_identifier(change_set_id, buf);
+ expected_entry_count.write(buf);
+ write_identifier(manifest_hash, buf);
+ (entries.len() as u16).write(buf);
+ for entry in entries {
+ entry.write(buf);
+ }
+ }
+ Self::ApplyRateCardChangeSet { change_set_id, manifest_hash, entries } => {
+ OP_APPLY_RATE_CARD_CHANGE_SET.write(buf);
+ write_identifier(change_set_id, buf);
+ write_identifier(manifest_hash, buf);
+ (entries.len() as u16).write(buf);
+ for entry in entries {
+ entry.write(buf);
+ }
+ }
+ Self::StageRateCardChangeSetV1 { change_set, entries } => { OP_STAGE_RATE_CARD_CHANGE_SET_V1.write(buf); change_set.write(buf); write_entries(entries, buf); }
+ Self::ApplyRateCardChangeSetV1 { change_set, entries } => { OP_APPLY_RATE_CARD_CHANGE_SET_V1.write(buf); change_set.write(buf); write_entries(entries, buf); }
+ }
+ }
+}
+
+impl Read for CostsOperation {
+ type Cfg = ();
+
+ fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result {
+ match u8::read(buf)? {
+ OP_REGISTER_SITE => Ok(Self::RegisterAccount {
+ account_id: read_identifier(buf)?,
+ }),
+ OP_ONBOARD_SITE => Ok(Self::CreateAccount {
+ account_id: read_identifier(buf)?,
+ external_ref: read_identifier(buf)?,
+ policy_ref: read_identifier(buf)?,
+ cohort_ref: read_identifier(buf)?,
+ created_at: u64::read(buf)?,
+ }),
+ OP_SET_WRITER => Ok(Self::SetWriter {
+ role: WriterRole::read(buf)?,
+ writer: nunchi_common::Address::read(buf)?,
+ enabled: bool::read(buf)?,
+ }),
+ OP_SET_SITE_WRITER => Ok(Self::SetAccountWriter {
+ account_id: read_identifier(buf)?,
+ role: WriterRole::read(buf)?,
+ writer: nunchi_common::Address::read(buf)?,
+ enabled: bool::read(buf)?,
+ }),
+ OP_ROTATE_ADMIN => Ok(Self::RotateAdmin { replacement: nunchi_common::Address::read(buf)? }),
+ OP_CREDIT_TOPUP => Ok(Self::CreditTopup {
+ account_id: read_identifier(buf)?,
+ credits: u64::read(buf)?,
+ rail_ref: read_identifier(buf)?,
+ }),
+ OP_STORED_VALUE_TOPUP_V2 => Ok(Self::StoredValueTopupV2 {
+ topup: CreditTopupV2::read(buf)?,
+ }),
+ OP_STORED_VALUE_GRANT_V2 => Ok(Self::StoredValueGrantV2 {
+ grant: CreditGrantV2::read(buf)?,
+ }),
+ OP_STORED_VALUE_SPEND_V2 => Ok(Self::StoredValueSpendV2 {
+ spend: StoredValueSpendV2::read(buf)?,
+ }),
+ OP_REFUND_PAID_LOT_V1 => Ok(Self::RefundPaidLotV1 {
+ refund: RefundPaidLotV1::read(buf)?,
+ }),
+ OP_RESERVE_STORED_VALUE_V2 => Ok(Self::ReserveStoredValueV2 {
+ reservation_id: read_identifier(buf)?,
+ account_id: read_identifier(buf)?,
+ credits: u64::read(buf)?,
+ expires_at: u64::read(buf)?,
+ reserved_at: u64::read(buf)?,
+ }),
+ OP_RELEASE_STORED_VALUE_RESERVATION_V2 => Ok(Self::ReleaseStoredValueReservationV2 {
+ reservation_id: read_identifier(buf)?,
+ released_at: u64::read(buf)?,
+ }),
+ OP_EXPIRE_STORED_VALUE_RESERVATION_V2 => Ok(Self::ExpireStoredValueReservationV2 {
+ reservation_id: read_identifier(buf)?,
+ expired_at: u64::read(buf)?,
+ }),
+ OP_SETTLE_STORED_VALUE_RESERVATION_V2 => Ok(Self::SettleStoredValueReservationV2 {
+ reservation_id: read_identifier(buf)?,
+ spend: StoredValueSpendV2::read(buf)?,
+ }),
+ OP_RECORD_SPEND_BATCH => {
+ let len = u16::read(buf)? as usize;
+ let mut records = Vec::with_capacity(len);
+ for _ in 0..len {
+ records.push(SpendRecordV1::read(buf)?);
+ }
+ Ok(Self::RecordSpendBatch { records })
+ }
+ OP_SET_SITE_STATUS => Ok(Self::SetAccountStatus {
+ account_id: read_identifier(buf)?,
+ status: crate::AccountStatus::read(buf)?,
+ }),
+ OP_SET_SITE_STATUS_V1 => Ok(Self::SetAccountStatusV1 { account_id: read_identifier(buf)?, status: crate::AccountStatus::read(buf)?, metadata: StatusChangeMetadataV1::read(buf)? }),
+ OP_CREDIT_GRANT => Ok(Self::CreditGrant {
+ account_id: read_identifier(buf)?,
+ credits: u64::read(buf)?,
+ grant_ref: read_identifier(buf)?,
+ }),
+ OP_CREDIT_REVERSAL => Ok(Self::CreditReversal {
+ account_id: read_identifier(buf)?,
+ credits: u64::read(buf)?,
+ reversal_ref: read_identifier(buf)?,
+ }),
+ OP_RESERVE_CREDITS => Ok(Self::ReserveCredits {
+ reservation_id: read_identifier(buf)?,
+ account_id: read_identifier(buf)?,
+ credits: u64::read(buf)?,
+ expires_at: u64::read(buf)?,
+ }),
+ OP_RESERVE_CREDITS_V1 => Ok(Self::ReserveCreditsV1 { reservation_id: read_identifier(buf)?, quote: read_quote(buf)?, lineage_ref: read_identifier(buf)?, reserved_at: u64::read(buf)? }),
+ OP_RELEASE_RESERVATION => Ok(Self::ReleaseReservation {
+ reservation_id: read_identifier(buf)?,
+ }),
+ OP_EXPIRE_RESERVATION => Ok(Self::ExpireReservation {
+ reservation_id: read_identifier(buf)?,
+ expired_at: u64::read(buf)?,
+ }),
+ OP_RELEASE_RESERVATION_V1 => Ok(Self::ReleaseReservationV1 { reservation_id: read_identifier(buf)?, metadata: ReservationActionMetadataV1::read(buf)? }),
+ OP_EXPIRE_RESERVATION_V1 => Ok(Self::ExpireReservationV1 { reservation_id: read_identifier(buf)?, metadata: ReservationActionMetadataV1::read(buf)? }),
+ OP_SETTLE_SPEND => Ok(Self::SettleSpend {
+ reservation_id: read_identifier(buf)?,
+ event_id: read_identifier(buf)?,
+ event_category: read_identifier(buf)?,
+ }),
+ OP_SETTLE_SPEND_V1 => Ok(Self::SettleSpendV1 { reservation_id: read_identifier(buf)?, event_id: read_identifier(buf)?, event_category: read_identifier(buf)?, task_key: read_identifier(buf)?, quote_id: read_identifier(buf)?, snapshot_hash: read_identifier(buf)?, lineage_ref: read_identifier(buf)?, metadata: ReservationActionMetadataV1::read(buf)? }),
+ OP_REGISTER_UNTRACKED_SOURCE => Ok(Self::RegisterUntrackedSource {
+ source_id: read_identifier(buf)?,
+ reason_code: read_identifier(buf)?,
+ }),
+ OP_REGISTER_UNTRACKED_SOURCE_V1 => Ok(Self::RegisterUntrackedSourceV1 {
+ source: UntrackedSourceV1::read(buf)?,
+ }),
+ OP_CREDIT_ADJUSTMENT_V1 => Ok(Self::CreditAdjustmentV1 {
+ kind: AdjustmentKind::read(buf)?,
+ account_id: read_identifier(buf)?,
+ credits: u64::read(buf)?,
+ metadata: AdjustmentMetadata::read(buf)?,
+ }),
+ OP_STAGE_RATE_CARD_ENTRIES => {
+ let change_set_id = read_identifier(buf)?;
+ let expected_entry_count = u16::read(buf)?;
+ let manifest_hash = read_identifier(buf)?;
+ let len = u16::read(buf)? as usize;
+ let mut entries = Vec::with_capacity(len);
+ for _ in 0..len {
+ entries.push(RateCardEntry::read(buf)?);
+ }
+ Ok(Self::StageRateCardEntries {
+ change_set_id,
+ expected_entry_count,
+ manifest_hash,
+ entries,
+ })
+ }
+ OP_APPLY_RATE_CARD_CHANGE_SET => {
+ let change_set_id = read_identifier(buf)?;
+ let manifest_hash = read_identifier(buf)?;
+ let len = u16::read(buf)? as usize;
+ let mut entries = Vec::with_capacity(len);
+ for _ in 0..len {
+ entries.push(RateCardEntry::read(buf)?);
+ }
+ Ok(Self::ApplyRateCardChangeSet {
+ change_set_id,
+ manifest_hash,
+ entries,
+ })
+ }
+ OP_STAGE_RATE_CARD_CHANGE_SET_V1 => Ok(Self::StageRateCardChangeSetV1 { change_set: RateCardChangeSetV1::read(buf)?, entries: read_entries(buf)? }),
+ OP_APPLY_RATE_CARD_CHANGE_SET_V1 => Ok(Self::ApplyRateCardChangeSetV1 { change_set: RateCardChangeSetV1::read(buf)?, entries: read_entries(buf)? }),
+ tag => Err(Error::InvalidEnum(tag)),
+ }
+ }
+}
+
+impl EncodeSize for CostsOperation {
+ fn encode_size(&self) -> usize {
+ 1 + match self {
+ Self::RegisterAccount { account_id } => identifier_encode_size(account_id),
+ Self::CreateAccount { account_id, external_ref, policy_ref, cohort_ref, created_at } => {
+ identifier_encode_size(account_id) + identifier_encode_size(external_ref)
+ + identifier_encode_size(policy_ref) + identifier_encode_size(cohort_ref)
+ + created_at.encode_size()
+ }
+ Self::SetWriter { role, writer, enabled } => {
+ role.encode_size() + writer.encode_size() + enabled.encode_size()
+ }
+ Self::SetAccountWriter { account_id, role, writer, enabled } => {
+ identifier_encode_size(account_id) + role.encode_size() + writer.encode_size() + enabled.encode_size()
+ }
+ Self::RotateAdmin { replacement } => replacement.encode_size(),
+ Self::CreditTopup { account_id, credits, rail_ref } => {
+ identifier_encode_size(account_id) + credits.encode_size() + identifier_encode_size(rail_ref)
+ }
+ Self::StoredValueTopupV2 { topup } => topup.encode_size(),
+ Self::StoredValueGrantV2 { grant } => grant.encode_size(),
+ Self::StoredValueSpendV2 { spend } => spend.encode_size(),
+ Self::RefundPaidLotV1 { refund } => refund.encode_size(),
+ Self::ReserveStoredValueV2 { reservation_id, account_id, credits, expires_at, reserved_at } => {
+ identifier_encode_size(reservation_id) + identifier_encode_size(account_id)
+ + credits.encode_size() + expires_at.encode_size() + reserved_at.encode_size()
+ }
+ Self::ReleaseStoredValueReservationV2 { reservation_id, released_at }
+ | Self::ExpireStoredValueReservationV2 { reservation_id, expired_at: released_at } => {
+ identifier_encode_size(reservation_id) + released_at.encode_size()
+ }
+ Self::SettleStoredValueReservationV2 { reservation_id, spend } => {
+ identifier_encode_size(reservation_id) + spend.encode_size()
+ }
+ Self::RecordSpendBatch { records } => {
+ 2 + records.iter().map(EncodeSize::encode_size).sum::()
+ }
+ Self::SetAccountStatus { account_id, status } => {
+ identifier_encode_size(account_id) + status.encode_size()
+ }
+ Self::SetAccountStatusV1 { account_id, status, metadata } => identifier_encode_size(account_id) + status.encode_size() + metadata.encode_size(),
+ Self::CreditGrant { account_id, credits, grant_ref }
+ | Self::CreditReversal { account_id, credits, reversal_ref: grant_ref } => {
+ identifier_encode_size(account_id) + credits.encode_size() + identifier_encode_size(grant_ref)
+ }
+ Self::ReserveCredits { reservation_id, account_id, credits, expires_at } => {
+ identifier_encode_size(reservation_id)
+ + identifier_encode_size(account_id)
+ + credits.encode_size()
+ + expires_at.encode_size()
+ }
+ Self::ReserveCreditsV1 { reservation_id, quote, lineage_ref, reserved_at } => identifier_encode_size(reservation_id) + quote_encode_size(quote) + identifier_encode_size(lineage_ref) + reserved_at.encode_size(),
+ Self::ReleaseReservation { reservation_id } => identifier_encode_size(reservation_id),
+ Self::ExpireReservation { reservation_id, expired_at } => {
+ identifier_encode_size(reservation_id) + expired_at.encode_size()
+ }
+ Self::ReleaseReservationV1 { reservation_id, metadata } | Self::ExpireReservationV1 { reservation_id, metadata } => identifier_encode_size(reservation_id) + metadata.encode_size(),
+ Self::SettleSpend { reservation_id, event_id, event_category } => {
+ identifier_encode_size(reservation_id)
+ + identifier_encode_size(event_id)
+ + identifier_encode_size(event_category)
+ }
+ Self::SettleSpendV1 { reservation_id, event_id, event_category, task_key, quote_id, snapshot_hash, lineage_ref, metadata } => identifier_encode_size(reservation_id) + identifier_encode_size(event_id) + identifier_encode_size(event_category) + identifier_encode_size(task_key) + identifier_encode_size(quote_id) + identifier_encode_size(snapshot_hash) + identifier_encode_size(lineage_ref) + metadata.encode_size(),
+ Self::RegisterUntrackedSource { source_id, reason_code } => {
+ identifier_encode_size(source_id) + identifier_encode_size(reason_code)
+ }
+ Self::RegisterUntrackedSourceV1 { source } => source.encode_size(),
+ Self::CreditAdjustmentV1 { kind, account_id, credits, metadata } => {
+ kind.encode_size() + identifier_encode_size(account_id) + credits.encode_size()
+ + metadata.encode_size()
+ }
+ Self::StageRateCardEntries { change_set_id, expected_entry_count, manifest_hash, entries } => {
+ identifier_encode_size(change_set_id)
+ + expected_entry_count.encode_size()
+ + identifier_encode_size(manifest_hash)
+ + 2
+ + entries.iter().map(EncodeSize::encode_size).sum::()
+ }
+ Self::ApplyRateCardChangeSet { change_set_id, manifest_hash, entries } => {
+ identifier_encode_size(change_set_id)
+ + identifier_encode_size(manifest_hash)
+ + 2
+ + entries.iter().map(EncodeSize::encode_size).sum::()
+ }
+ Self::StageRateCardChangeSetV1 { change_set, entries } | Self::ApplyRateCardChangeSetV1 { change_set, entries } => change_set.encode_size() + 2 + entries.iter().map(EncodeSize::encode_size).sum::(),
+ }
+ }
+}
+
+fn write_entries(entries: &[RateCardEntry], buf: &mut impl bytes::BufMut) { (entries.len() as u16).write(buf); for entry in entries { entry.write(buf); } }
+fn read_entries(buf: &mut impl bytes::Buf) -> Result, Error> { let len = u16::read(buf)? as usize; let mut entries = Vec::with_capacity(len); for _ in 0..len { entries.push(RateCardEntry::read(buf)?); } Ok(entries) }
+fn write_quote(quote: &QuoteV1, buf: &mut impl bytes::BufMut) { write_identifier("e.quote_id, buf); write_identifier("e.snapshot_hash, buf); write_identifier("e.account_id, buf); write_identifier("e.event_category, buf); write_identifier("e.task_key, buf); quote.quoted_at.write(buf); quote.credits_per_unit.write(buf); quote.quantity.write(buf); quote.total_credits.write(buf); write_identifier("e.policy_version, buf); write_identifier("e.rate_version, buf); quote.expires_at.write(buf); }
+fn read_quote(buf: &mut impl bytes::Buf) -> Result { Ok(QuoteV1 { quote_id: read_identifier(buf)?, snapshot_hash: read_identifier(buf)?, account_id: read_identifier(buf)?, event_category: read_identifier(buf)?, task_key: read_identifier(buf)?, quoted_at: u64::read(buf)?, credits_per_unit: u64::read(buf)?, quantity: u64::read(buf)?, total_credits: u64::read(buf)?, policy_version: read_identifier(buf)?, rate_version: read_identifier(buf)?, expires_at: u64::read(buf)? }) }
+fn quote_encode_size(quote: &QuoteV1) -> usize { identifier_encode_size("e.quote_id) + identifier_encode_size("e.snapshot_hash) + identifier_encode_size("e.account_id) + identifier_encode_size("e.event_category) + identifier_encode_size("e.task_key) + quote.quoted_at.encode_size() + quote.credits_per_unit.encode_size() + quote.quantity.encode_size() + quote.total_credits.encode_size() + identifier_encode_size("e.policy_version) + identifier_encode_size("e.rate_version) + quote.expires_at.encode_size() }
+
+impl Operation for CostsOperation {
+ const NAMESPACE: &'static [u8] = COSTS_NAMESPACE;
+}
+
+pub type TransactionPayload = nunchi_common::TransactionPayload;
+pub type Transaction = nunchi_common::Transaction;
diff --git a/costs/src/types.rs b/costs/src/types.rs
new file mode 100644
index 0000000..f8f3c52
--- /dev/null
+++ b/costs/src/types.rs
@@ -0,0 +1,1121 @@
+use bytes::Buf;
+use commonware_codec::{EncodeSize, Error, Read, ReadExt, Write};
+use commonware_cryptography::{Hasher, Sha256};
+
+const STATUS_ACTIVE: u8 = 0;
+const STATUS_SUSPENDED: u8 = 1;
+const ROLE_ADMIN: u8 = 0;
+const ROLE_INGEST: u8 = 1;
+const ROLE_BILLING: u8 = 2;
+const ROLE_ADJUSTMENT: u8 = 3;
+const RESERVATION_ACTIVE: u8 = 0;
+const RESERVATION_RELEASED: u8 = 1;
+const RESERVATION_SETTLED: u8 = 2;
+const RESERVATION_EXPIRED: u8 = 3;
+const ADJUSTMENT_GRANT: u8 = 0;
+const ADJUSTMENT_REVERSAL: u8 = 1;
+const JOURNAL_ONBOARDED: u8 = 0;
+const JOURNAL_BALANCE_CHANGED: u8 = 1;
+const JOURNAL_SPEND_RECORDED: u8 = 2;
+const JOURNAL_STATUS_CHANGED: u8 = 3;
+const JOURNAL_RESERVATION_CHANGED: u8 = 4;
+const JOURNAL_RATE_STAGED: u8 = 5;
+const JOURNAL_RATE_APPLIED: u8 = 6;
+const JOURNAL_UNTRACKED_SOURCE_REGISTERED: u8 = 7;
+const JOURNAL_RATE_GLOBAL_APPLIED: u8 = 8;
+const JOURNAL_RATE_CARD_COMPLETED: u8 = 9;
+const BALANCE_DIRECTION_NONE: u8 = 0;
+const BALANCE_DIRECTION_CREDIT: u8 = 1;
+const BALANCE_DIRECTION_DEBIT: u8 = 2;
+
+/// The largest wire value accepted for an identifier before ledger-level policy
+/// applies its tighter domain-specific limits.
+const MAX_WIRE_IDENTIFIER_LEN: usize = 1024;
+
+pub(crate) fn write_identifier(value: &str, buf: &mut impl bytes::BufMut) {
+ let length = u16::try_from(value.len())
+ .expect("costs identifier exceeds the u16 transaction encoding limit");
+ length.write(buf);
+ buf.put_slice(value.as_bytes());
+}
+
+pub(crate) fn read_identifier(buf: &mut impl bytes::Buf) -> Result {
+ let length = u16::read(buf)? as usize;
+ if length > MAX_WIRE_IDENTIFIER_LEN {
+ return Err(Error::InvalidLength(length));
+ }
+ if buf.remaining() < length {
+ return Err(Error::EndOfBuffer);
+ }
+ String::from_utf8(buf.copy_to_bytes(length).to_vec())
+ .map_err(|_| Error::Invalid("costs identifier", "must be valid UTF-8"))
+}
+
+pub(crate) fn identifier_encode_size(value: &str) -> usize {
+ u16::default().encode_size() + value.len()
+}
+
+/// Whether a account account may settle new spend.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum AccountStatus {
+ Active,
+ Suspended,
+}
+
+impl Write for AccountStatus {
+ fn write(&self, buf: &mut impl bytes::BufMut) {
+ match self {
+ Self::Active => STATUS_ACTIVE.write(buf),
+ Self::Suspended => STATUS_SUSPENDED.write(buf),
+ }
+ }
+}
+
+impl Read for AccountStatus {
+ type Cfg = ();
+
+ fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result {
+ match u8::read(buf)? {
+ STATUS_ACTIVE => Ok(Self::Active),
+ STATUS_SUSPENDED => Ok(Self::Suspended),
+ tag => Err(Error::InvalidEnum(tag)),
+ }
+ }
+}
+
+impl EncodeSize for AccountStatus {
+ fn encode_size(&self) -> usize {
+ 1
+ }
+}
+
+/// Backend capability required for a costs operation.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum WriterRole {
+ Admin,
+ Ingest,
+ Billing,
+ /// Authorized finance service for grants and post-settlement corrections.
+ Adjustment,
+}
+
+impl WriterRole {
+ pub(crate) const fn tag(self) -> u8 {
+ match self {
+ Self::Admin => ROLE_ADMIN,
+ Self::Ingest => ROLE_INGEST,
+ Self::Billing => ROLE_BILLING,
+ Self::Adjustment => ROLE_ADJUSTMENT,
+ }
+ }
+}
+
+impl Write for WriterRole {
+ fn write(&self, buf: &mut impl bytes::BufMut) {
+ self.tag().write(buf);
+ }
+}
+
+impl Read for WriterRole {
+ type Cfg = ();
+
+ fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result {
+ match u8::read(buf)? {
+ ROLE_ADMIN => Ok(Self::Admin),
+ ROLE_INGEST => Ok(Self::Ingest),
+ ROLE_BILLING => Ok(Self::Billing),
+ ROLE_ADJUSTMENT => Ok(Self::Adjustment),
+ tag => Err(Error::InvalidEnum(tag)),
+ }
+ }
+}
+
+impl EncodeSize for WriterRole {
+ fn encode_size(&self) -> usize {
+ 1
+ }
+}
+
+/// The on-chain credit state for one opaque client account.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct CreditAccount {
+ pub status: AccountStatus,
+ pub available_credits: u64,
+ pub reserved_credits: u64,
+}
+
+impl CreditAccount {
+ pub const fn active() -> Self {
+ Self {
+ status: AccountStatus::Active,
+ available_credits: 0,
+ reserved_credits: 0,
+ }
+ }
+}
+
+impl Write for CreditAccount {
+ fn write(&self, buf: &mut impl bytes::BufMut) {
+ self.status.write(buf);
+ self.available_credits.write(buf);
+ self.reserved_credits.write(buf);
+ }
+}
+
+impl Read for CreditAccount {
+ type Cfg = ();
+
+ fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result {
+ Ok(Self {
+ status: AccountStatus::read(buf)?,
+ available_credits: u64::read(buf)?,
+ reserved_credits: u64::read(buf)?,
+ })
+ }
+}
+
+impl EncodeSize for CreditAccount {
+ fn encode_size(&self) -> usize {
+ self.status.encode_size()
+ + self.available_credits.encode_size()
+ + self.reserved_credits.encode_size()
+ }
+}
+
+/// Lifecycle state for a hold against a account's available credit balance.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum ReservationStatus {
+ Active,
+ Released,
+ Settled,
+ /// Released by an authorized expiry sweep after `expires_at`.
+ Expired,
+}
+
+impl Write for ReservationStatus {
+ fn write(&self, buf: &mut impl bytes::BufMut) {
+ match self {
+ Self::Active => RESERVATION_ACTIVE.write(buf),
+ Self::Released => RESERVATION_RELEASED.write(buf),
+ Self::Settled => RESERVATION_SETTLED.write(buf),
+ Self::Expired => RESERVATION_EXPIRED.write(buf),
+ }
+ }
+}
+
+impl Read for ReservationStatus {
+ type Cfg = ();
+
+ fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result {
+ match u8::read(buf)? {
+ RESERVATION_ACTIVE => Ok(Self::Active),
+ RESERVATION_RELEASED => Ok(Self::Released),
+ RESERVATION_SETTLED => Ok(Self::Settled),
+ RESERVATION_EXPIRED => Ok(Self::Expired),
+ tag => Err(Error::InvalidEnum(tag)),
+ }
+ }
+}
+
+impl EncodeSize for ReservationStatus {
+ fn encode_size(&self) -> usize {
+ 1
+ }
+}
+
+/// A deterministic hold for a long-running priced action.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct Reservation {
+ pub reservation_id: String,
+ pub account_id: String,
+ /// Full immutable V1 price snapshot. A later rate activation must never
+ /// alter a hold that has already been authorized.
+ pub quote: QuoteV1,
+ /// Opaque source lineage bound at authorization time. Settlement must
+ /// present the identical lineage, preventing a quote from being reused
+ /// for a different measured action.
+ pub lineage_ref: String,
+ pub credits: u64,
+ /// Unix seconds supplied by the authorization layer; expiration is released
+ /// only by an explicit, authorized transaction.
+ pub expires_at: u64,
+ pub status: ReservationStatus,
+}
+
+/// Immutable account-account metadata. This is deliberately separate from the
+/// balance record so that a future account-profile read model cannot mutate
+/// custodial credits.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct AccountProfile {
+ pub account_id: String,
+ pub external_ref: String,
+ /// Opaque internal program identifier; this is not an end-user identity.
+ pub policy_ref: String,
+ pub created_at: u64,
+ pub cohort_ref: String,
+ pub status_reason: String,
+ pub status_changed_at: u64,
+}
+
+impl Write for AccountProfile {
+ fn write(&self, buf: &mut impl bytes::BufMut) {
+ write_identifier(&self.account_id, buf);
+ write_identifier(&self.external_ref, buf);
+ write_identifier(&self.policy_ref, buf);
+ self.created_at.write(buf);
+ write_identifier(&self.cohort_ref, buf);
+ write_identifier(&self.status_reason, buf);
+ self.status_changed_at.write(buf);
+ }
+}
+
+impl Read for AccountProfile {
+ type Cfg = ();
+
+ fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result {
+ Ok(Self {
+ account_id: read_identifier(buf)?,
+ external_ref: read_identifier(buf)?,
+ policy_ref: read_identifier(buf)?,
+ created_at: u64::read(buf)?,
+ cohort_ref: read_identifier(buf)?,
+ status_reason: read_identifier(buf)?,
+ status_changed_at: u64::read(buf)?,
+ })
+ }
+}
+
+impl EncodeSize for AccountProfile {
+ fn encode_size(&self) -> usize {
+ identifier_encode_size(&self.account_id)
+ + identifier_encode_size(&self.external_ref)
+ + identifier_encode_size(&self.policy_ref)
+ + self.created_at.encode_size()
+ + identifier_encode_size(&self.cohort_ref)
+ + identifier_encode_size(&self.status_reason)
+ + self.status_changed_at.encode_size()
+ }
+}
+
+/// Append-only account-status history item. Reasons are opaque policy codes,
+/// never employee names or free-form client data.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct StatusHistoryEntry {
+ pub sequence: u64,
+ pub status: AccountStatus,
+ pub reason_code: String,
+ pub changed_at: u64,
+ pub approval_ref: String,
+ pub audit_ref: String,
+}
+
+/// PII-safe control-plane evidence required for a V1 account-status change.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct StatusChangeMetadataV1 {
+ pub reason_code: String,
+ pub changed_at: u64,
+ pub approval_ref: String,
+ pub audit_ref: String,
+}
+
+/// Evidence for a V1 reservation lifecycle mutation. The chain stores only
+/// opaque control-plane references; approval detail remains off-chain.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ReservationActionMetadataV1 {
+ pub reason_code: String,
+ pub occurred_at: u64,
+ pub approval_ref: String,
+ pub audit_ref: String,
+}
+
+impl Write for ReservationActionMetadataV1 {
+ fn write(&self, buf: &mut impl bytes::BufMut) {
+ write_identifier(&self.reason_code, buf);
+ self.occurred_at.write(buf);
+ write_identifier(&self.approval_ref, buf);
+ write_identifier(&self.audit_ref, buf);
+ }
+}
+impl Read for ReservationActionMetadataV1 {
+ type Cfg = ();
+ fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result {
+ Ok(Self { reason_code: read_identifier(buf)?, occurred_at: u64::read(buf)?, approval_ref: read_identifier(buf)?, audit_ref: read_identifier(buf)? })
+ }
+}
+impl EncodeSize for ReservationActionMetadataV1 {
+ fn encode_size(&self) -> usize {
+ identifier_encode_size(&self.reason_code) + self.occurred_at.encode_size()
+ + identifier_encode_size(&self.approval_ref) + identifier_encode_size(&self.audit_ref)
+ }
+}
+
+impl Write for StatusChangeMetadataV1 {
+ fn write(&self, buf: &mut impl bytes::BufMut) {
+ write_identifier(&self.reason_code, buf);
+ self.changed_at.write(buf);
+ write_identifier(&self.approval_ref, buf);
+ write_identifier(&self.audit_ref, buf);
+ }
+}
+impl Read for StatusChangeMetadataV1 {
+ type Cfg = ();
+ fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result {
+ Ok(Self { reason_code: read_identifier(buf)?, changed_at: u64::read(buf)?, approval_ref: read_identifier(buf)?, audit_ref: read_identifier(buf)? })
+ }
+}
+impl EncodeSize for StatusChangeMetadataV1 {
+ fn encode_size(&self) -> usize { identifier_encode_size(&self.reason_code) + self.changed_at.encode_size() + identifier_encode_size(&self.approval_ref) + identifier_encode_size(&self.audit_ref) }
+}
+
+/// Credit adjustment direction. Grants add credits; reversals debit only
+/// available credits and can never consume a separate reservation.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum AdjustmentKind {
+ Grant,
+ Reversal,
+}
+
+impl Write for AdjustmentKind {
+ fn write(&self, buf: &mut impl bytes::BufMut) {
+ match self {
+ Self::Grant => ADJUSTMENT_GRANT.write(buf),
+ Self::Reversal => ADJUSTMENT_REVERSAL.write(buf),
+ }
+ }
+}
+
+impl Read for AdjustmentKind {
+ type Cfg = ();
+
+ fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result {
+ match u8::read(buf)? {
+ ADJUSTMENT_GRANT => Ok(Self::Grant),
+ ADJUSTMENT_REVERSAL => Ok(Self::Reversal),
+ tag => Err(Error::InvalidEnum(tag)),
+ }
+ }
+}
+
+impl EncodeSize for AdjustmentKind {
+ fn encode_size(&self) -> usize { 1 }
+}
+
+/// PII-safe, auditable metadata attached to an idempotent credit adjustment.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct AdjustmentMetadata {
+ pub reference: String,
+ pub reason_code: String,
+ pub period_ref: String,
+ /// Off-chain approval and audit correlation identifiers. They are opaque
+ /// references only; employee approval state remains outside the chain.
+ pub approval_ref: String,
+ pub audit_ref: String,
+}
+
+impl Write for AdjustmentMetadata {
+ fn write(&self, buf: &mut impl bytes::BufMut) {
+ write_identifier(&self.reference, buf);
+ write_identifier(&self.reason_code, buf);
+ write_identifier(&self.period_ref, buf);
+ write_identifier(&self.approval_ref, buf);
+ write_identifier(&self.audit_ref, buf);
+ }
+}
+
+impl Read for AdjustmentMetadata {
+ type Cfg = ();
+
+ fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result {
+ Ok(Self {
+ reference: read_identifier(buf)?,
+ reason_code: read_identifier(buf)?,
+ period_ref: read_identifier(buf)?,
+ approval_ref: read_identifier(buf)?,
+ audit_ref: read_identifier(buf)?,
+ })
+ }
+}
+
+impl EncodeSize for AdjustmentMetadata {
+ fn encode_size(&self) -> usize {
+ identifier_encode_size(&self.reference)
+ + identifier_encode_size(&self.reason_code)
+ + identifier_encode_size(&self.period_ref)
+ + identifier_encode_size(&self.approval_ref)
+ + identifier_encode_size(&self.audit_ref)
+ }
+}
+
+/// A coverage-only record for shared or dark cost. It cannot debit a client.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct UntrackedSourceV1 {
+ pub source_id: String,
+ pub reason_code: String,
+ pub owner_ref: String,
+ pub period_ref: String,
+ pub provenance_ref: String,
+ /// Coverage and confidence are controlled opaque taxonomy codes such as
+ /// `cost_dark` and `unverified`; evidence remains an opaque reference.
+ pub coverage_code: String,
+ pub confidence_code: String,
+ pub evidence_ref: String,
+ pub cohort_ref: String,
+}
+
+/// Client-safe, private account read returned to an authorized backend/BFF.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct AccountReadV1 {
+ pub account: CreditAccount,
+ pub profile: AccountProfile,
+}
+
+/// A credit-only quote resolved from an applied rate card. It contains no
+/// provider COGS, margin, confidence, or approval metadata.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct QuoteV1 {
+ pub quote_id: String,
+ pub snapshot_hash: String,
+ pub account_id: String,
+ pub event_category: String,
+ pub task_key: String,
+ pub quoted_at: u64,
+ pub credits_per_unit: u64,
+ pub quantity: u64,
+ pub total_credits: u64,
+ pub policy_version: String,
+ pub rate_version: String,
+ pub expires_at: u64,
+}
+
+pub(crate) fn write_quote_snapshot(quote: &QuoteV1, buf: &mut impl bytes::BufMut) {
+ write_identifier("e.quote_id, buf);
+ write_identifier("e.snapshot_hash, buf);
+ write_identifier("e.account_id, buf);
+ write_identifier("e.event_category, buf);
+ write_identifier("e.task_key, buf);
+ quote.quoted_at.write(buf);
+ quote.credits_per_unit.write(buf);
+ quote.quantity.write(buf);
+ quote.total_credits.write(buf);
+ write_identifier("e.policy_version, buf);
+ write_identifier("e.rate_version, buf);
+ quote.expires_at.write(buf);
+}
+
+pub(crate) fn read_quote_snapshot(buf: &mut impl bytes::Buf) -> Result {
+ Ok(QuoteV1 {
+ quote_id: read_identifier(buf)?, snapshot_hash: read_identifier(buf)?,
+ account_id: read_identifier(buf)?, event_category: read_identifier(buf)?,
+ task_key: read_identifier(buf)?, quoted_at: u64::read(buf)?,
+ credits_per_unit: u64::read(buf)?, quantity: u64::read(buf)?,
+ total_credits: u64::read(buf)?, policy_version: read_identifier(buf)?,
+ rate_version: read_identifier(buf)?, expires_at: u64::read(buf)?,
+ })
+}
+
+pub(crate) fn quote_snapshot_encode_size(quote: &QuoteV1) -> usize {
+ identifier_encode_size("e.quote_id) + identifier_encode_size("e.snapshot_hash)
+ + identifier_encode_size("e.account_id) + identifier_encode_size("e.event_category)
+ + identifier_encode_size("e.task_key) + quote.quoted_at.encode_size()
+ + quote.credits_per_unit.encode_size() + quote.quantity.encode_size()
+ + quote.total_credits.encode_size() + identifier_encode_size("e.policy_version)
+ + identifier_encode_size("e.rate_version) + quote.expires_at.encode_size()
+}
+
+/// A private request to price a specific quantity. The client receives only
+/// the resulting credit quote, not COGS or policy approval detail.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct QuoteRequestV1 {
+ pub account_id: String,
+ pub event_category: String,
+ pub task_key: String,
+ pub quantity: u64,
+ pub quoted_at: u64,
+ pub expires_at: u64,
+}
+
+/// The typed category of a persisted, post-state ledger mutation. This is a
+/// private read contract for the finality sink and operational projections;
+/// it is never an input transaction and cannot authorize a debit.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum LedgerMutationKind {
+ AccountOnboarded,
+ BalanceChanged,
+ SpendRecorded,
+ AccountStatusChanged,
+ ReservationChanged,
+ RateCardStaged,
+ RateCardApplied,
+ RateCardGlobalApplied,
+ RateCardCompleted,
+ UntrackedSourceRegistered,
+}
+
+/// Explicit financial direction for a persisted balance mutation. A finality
+/// sink must consume this field rather than infer a debit/credit from the
+/// transaction that happened to produce the mutation.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum BalanceMutationDirection { None, Credit, Debit }
+
+impl Write for BalanceMutationDirection { fn write(&self, buf: &mut impl bytes::BufMut) { match self { Self::None => BALANCE_DIRECTION_NONE, Self::Credit => BALANCE_DIRECTION_CREDIT, Self::Debit => BALANCE_DIRECTION_DEBIT }.write(buf); } }
+impl Read for BalanceMutationDirection { type Cfg = (); fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { match u8::read(buf)? { BALANCE_DIRECTION_NONE => Ok(Self::None), BALANCE_DIRECTION_CREDIT => Ok(Self::Credit), BALANCE_DIRECTION_DEBIT => Ok(Self::Debit), tag => Err(Error::InvalidEnum(tag)) } } }
+impl EncodeSize for BalanceMutationDirection { fn encode_size(&self) -> usize { 1 } }
+
+impl Write for LedgerMutationKind {
+ fn write(&self, buf: &mut impl bytes::BufMut) {
+ match self {
+ Self::AccountOnboarded => JOURNAL_ONBOARDED.write(buf),
+ Self::BalanceChanged => JOURNAL_BALANCE_CHANGED.write(buf),
+ Self::SpendRecorded => JOURNAL_SPEND_RECORDED.write(buf),
+ Self::AccountStatusChanged => JOURNAL_STATUS_CHANGED.write(buf),
+ Self::ReservationChanged => JOURNAL_RESERVATION_CHANGED.write(buf),
+ Self::RateCardStaged => JOURNAL_RATE_STAGED.write(buf),
+ Self::RateCardApplied => JOURNAL_RATE_APPLIED.write(buf),
+ Self::RateCardGlobalApplied => JOURNAL_RATE_GLOBAL_APPLIED.write(buf),
+ Self::RateCardCompleted => JOURNAL_RATE_CARD_COMPLETED.write(buf),
+ Self::UntrackedSourceRegistered => JOURNAL_UNTRACKED_SOURCE_REGISTERED.write(buf),
+ }
+ }
+}
+
+impl Read for LedgerMutationKind {
+ type Cfg = ();
+
+ fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result {
+ match u8::read(buf)? {
+ JOURNAL_ONBOARDED => Ok(Self::AccountOnboarded),
+ JOURNAL_BALANCE_CHANGED => Ok(Self::BalanceChanged),
+ JOURNAL_SPEND_RECORDED => Ok(Self::SpendRecorded),
+ JOURNAL_STATUS_CHANGED => Ok(Self::AccountStatusChanged),
+ JOURNAL_RESERVATION_CHANGED => Ok(Self::ReservationChanged),
+ JOURNAL_RATE_STAGED => Ok(Self::RateCardStaged),
+ JOURNAL_RATE_APPLIED => Ok(Self::RateCardApplied),
+ JOURNAL_RATE_GLOBAL_APPLIED => Ok(Self::RateCardGlobalApplied),
+ JOURNAL_RATE_CARD_COMPLETED => Ok(Self::RateCardCompleted),
+ JOURNAL_UNTRACKED_SOURCE_REGISTERED => Ok(Self::UntrackedSourceRegistered),
+ tag => Err(Error::InvalidEnum(tag)),
+ }
+ }
+}
+
+impl EncodeSize for LedgerMutationKind {
+ fn encode_size(&self) -> usize { 1 }
+}
+
+/// Append-only, ledger-generated post-state record. Empty values mean that a
+/// field does not apply to this mutation; `has_*` flags distinguish absence
+/// from a valid zero balance. The record contains only opaque references.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct LedgerMutationV1 {
+ pub sequence: u64,
+ pub transaction_id: String,
+ pub kind: LedgerMutationKind,
+ pub account_id: String,
+ pub has_account: bool,
+ pub account: CreditAccount,
+ pub cohort_ref: String,
+ pub source_ref: String,
+ pub balance_direction: BalanceMutationDirection,
+ /// Absolute credit movement for a balance mutation; zero when this journal
+ /// entry is not a balance-changing operation.
+ pub credit_delta: u64,
+ /// Finance-close period associated with a grant or reversal.
+ pub period_ref: String,
+ /// Control-plane evidence for status, reservation, and rate lifecycle
+ /// records. Empty only when the mutation category does not require it.
+ pub reason_code: String,
+ pub occurred_at: u64,
+ pub approval_ref: String,
+ pub audit_ref: String,
+ pub has_reservation: bool,
+ pub reservation: Reservation,
+ pub rate_change_set_id: String,
+ pub has_rate: bool,
+ pub rate: RateCardEntry,
+ pub has_untracked_source: bool,
+ pub untracked_source: UntrackedSourceV1,
+ pub has_rate_card_completion: bool,
+ pub rate_card_completion: RateCardCompletionV1,
+}
+
+impl Write for LedgerMutationV1 {
+ fn write(&self, buf: &mut impl bytes::BufMut) {
+ self.sequence.write(buf);
+ write_identifier(&self.transaction_id, buf);
+ self.kind.write(buf);
+ write_identifier(&self.account_id, buf);
+ self.has_account.write(buf);
+ self.account.write(buf);
+ write_identifier(&self.cohort_ref, buf);
+ write_identifier(&self.source_ref, buf);
+ self.balance_direction.write(buf);
+ self.credit_delta.write(buf);
+ write_identifier(&self.period_ref, buf);
+ write_identifier(&self.reason_code, buf);
+ self.occurred_at.write(buf);
+ write_identifier(&self.approval_ref, buf);
+ write_identifier(&self.audit_ref, buf);
+ self.has_reservation.write(buf);
+ self.reservation.write(buf);
+ write_identifier(&self.rate_change_set_id, buf);
+ self.has_rate.write(buf);
+ self.rate.write(buf);
+ self.has_untracked_source.write(buf);
+ self.untracked_source.write(buf);
+ self.has_rate_card_completion.write(buf);
+ self.rate_card_completion.write(buf);
+ }
+}
+
+impl Read for LedgerMutationV1 {
+ type Cfg = ();
+
+ fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result {
+ Ok(Self {
+ sequence: u64::read(buf)?, transaction_id: read_identifier(buf)?,
+ kind: LedgerMutationKind::read(buf)?, account_id: read_identifier(buf)?,
+ has_account: bool::read(buf)?, account: CreditAccount::read(buf)?,
+ cohort_ref: read_identifier(buf)?, source_ref: read_identifier(buf)?,
+ balance_direction: BalanceMutationDirection::read(buf)?, credit_delta: u64::read(buf)?, period_ref: read_identifier(buf)?,
+ reason_code: read_identifier(buf)?, occurred_at: u64::read(buf)?,
+ approval_ref: read_identifier(buf)?, audit_ref: read_identifier(buf)?,
+ has_reservation: bool::read(buf)?, reservation: Reservation::read(buf)?,
+ rate_change_set_id: read_identifier(buf)?, has_rate: bool::read(buf)?,
+ rate: RateCardEntry::read(buf)?, has_untracked_source: bool::read(buf)?,
+ untracked_source: UntrackedSourceV1::read(buf)?,
+ has_rate_card_completion: bool::read(buf)?, rate_card_completion: RateCardCompletionV1::read(buf)?,
+ })
+ }
+}
+
+impl EncodeSize for LedgerMutationV1 {
+ fn encode_size(&self) -> usize {
+ self.sequence.encode_size() + identifier_encode_size(&self.transaction_id)
+ + self.kind.encode_size() + identifier_encode_size(&self.account_id)
+ + self.has_account.encode_size() + self.account.encode_size()
+ + identifier_encode_size(&self.cohort_ref) + identifier_encode_size(&self.source_ref)
+ + self.balance_direction.encode_size() + self.credit_delta.encode_size() + identifier_encode_size(&self.period_ref)
+ + identifier_encode_size(&self.reason_code) + self.occurred_at.encode_size()
+ + identifier_encode_size(&self.approval_ref) + identifier_encode_size(&self.audit_ref)
+ + self.has_reservation.encode_size() + self.reservation.encode_size()
+ + identifier_encode_size(&self.rate_change_set_id) + self.has_rate.encode_size()
+ + self.rate.encode_size() + self.has_untracked_source.encode_size()
+ + self.untracked_source.encode_size() + self.has_rate_card_completion.encode_size()
+ + self.rate_card_completion.encode_size()
+ }
+}
+
+impl Write for UntrackedSourceV1 {
+ fn write(&self, buf: &mut impl bytes::BufMut) {
+ write_identifier(&self.source_id, buf);
+ write_identifier(&self.reason_code, buf);
+ write_identifier(&self.owner_ref, buf);
+ write_identifier(&self.period_ref, buf);
+ write_identifier(&self.provenance_ref, buf);
+ write_identifier(&self.coverage_code, buf);
+ write_identifier(&self.confidence_code, buf);
+ write_identifier(&self.evidence_ref, buf);
+ write_identifier(&self.cohort_ref, buf);
+ }
+}
+
+impl Read for UntrackedSourceV1 {
+ type Cfg = ();
+
+ fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result {
+ Ok(Self {
+ source_id: read_identifier(buf)?,
+ reason_code: read_identifier(buf)?,
+ owner_ref: read_identifier(buf)?,
+ period_ref: read_identifier(buf)?,
+ provenance_ref: read_identifier(buf)?,
+ coverage_code: read_identifier(buf)?,
+ confidence_code: read_identifier(buf)?,
+ evidence_ref: read_identifier(buf)?,
+ cohort_ref: read_identifier(buf)?,
+ })
+ }
+}
+
+impl EncodeSize for UntrackedSourceV1 {
+ fn encode_size(&self) -> usize {
+ identifier_encode_size(&self.source_id)
+ + identifier_encode_size(&self.reason_code)
+ + identifier_encode_size(&self.owner_ref)
+ + identifier_encode_size(&self.period_ref)
+ + identifier_encode_size(&self.provenance_ref)
+ + identifier_encode_size(&self.coverage_code)
+ + identifier_encode_size(&self.confidence_code)
+ + identifier_encode_size(&self.evidence_ref)
+ + identifier_encode_size(&self.cohort_ref)
+ }
+}
+
+impl Write for Reservation {
+ fn write(&self, buf: &mut impl bytes::BufMut) {
+ write_identifier(&self.reservation_id, buf);
+ write_identifier(&self.account_id, buf);
+ write_quote_snapshot(&self.quote, buf);
+ write_identifier(&self.lineage_ref, buf);
+ self.credits.write(buf);
+ self.expires_at.write(buf);
+ self.status.write(buf);
+ }
+}
+
+impl Read for Reservation {
+ type Cfg = ();
+
+ fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result {
+ Ok(Self {
+ reservation_id: read_identifier(buf)?,
+ account_id: read_identifier(buf)?,
+ quote: read_quote_snapshot(buf)?,
+ lineage_ref: read_identifier(buf)?,
+ credits: u64::read(buf)?,
+ expires_at: u64::read(buf)?,
+ status: ReservationStatus::read(buf)?,
+ })
+ }
+}
+
+impl EncodeSize for Reservation {
+ fn encode_size(&self) -> usize {
+ identifier_encode_size(&self.reservation_id)
+ + identifier_encode_size(&self.account_id)
+ + quote_snapshot_encode_size(&self.quote)
+ + identifier_encode_size(&self.lineage_ref)
+ + self.credits.encode_size()
+ + self.expires_at.encode_size()
+ + self.status.encode_size()
+ }
+}
+
+impl Write for StatusHistoryEntry {
+ fn write(&self, buf: &mut impl bytes::BufMut) {
+ self.sequence.write(buf); self.status.write(buf); write_identifier(&self.reason_code, buf);
+ self.changed_at.write(buf); write_identifier(&self.approval_ref, buf); write_identifier(&self.audit_ref, buf);
+ }
+}
+impl Read for StatusHistoryEntry {
+ type Cfg = ();
+ fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result {
+ Ok(Self { sequence: u64::read(buf)?, status: AccountStatus::read(buf)?, reason_code: read_identifier(buf)?, changed_at: u64::read(buf)?, approval_ref: read_identifier(buf)?, audit_ref: read_identifier(buf)? })
+ }
+}
+impl EncodeSize for StatusHistoryEntry {
+ fn encode_size(&self) -> usize { self.sequence.encode_size() + self.status.encode_size() + identifier_encode_size(&self.reason_code) + self.changed_at.encode_size() + identifier_encode_size(&self.approval_ref) + identifier_encode_size(&self.audit_ref) }
+}
+
+/// One effective-at credit rate. Empty `account_id` denotes a global default and
+/// empty `task_key` denotes a category-level default.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct RateCardEntry {
+ pub account_id: String,
+ pub event_category: String,
+ pub task_key: String,
+ pub credits: u64,
+ pub effective_at: u64,
+ /// Zero denotes no expiry.
+ pub expires_at: u64,
+ pub policy_version: String,
+ pub rate_version: String,
+}
+
+impl Write for RateCardEntry {
+ fn write(&self, buf: &mut impl bytes::BufMut) {
+ write_identifier(&self.account_id, buf);
+ write_identifier(&self.event_category, buf);
+ write_identifier(&self.task_key, buf);
+ self.credits.write(buf);
+ self.effective_at.write(buf);
+ self.expires_at.write(buf);
+ write_identifier(&self.policy_version, buf);
+ write_identifier(&self.rate_version, buf);
+ }
+}
+
+impl Read for RateCardEntry {
+ type Cfg = ();
+
+ fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result {
+ Ok(Self {
+ account_id: read_identifier(buf)?,
+ event_category: read_identifier(buf)?,
+ task_key: read_identifier(buf)?,
+ credits: u64::read(buf)?,
+ effective_at: u64::read(buf)?,
+ expires_at: u64::read(buf)?,
+ policy_version: read_identifier(buf)?,
+ rate_version: read_identifier(buf)?,
+ })
+ }
+}
+
+impl EncodeSize for RateCardEntry {
+ fn encode_size(&self) -> usize {
+ identifier_encode_size(&self.account_id)
+ + identifier_encode_size(&self.event_category)
+ + identifier_encode_size(&self.task_key)
+ + self.credits.encode_size()
+ + self.effective_at.encode_size()
+ + self.expires_at.encode_size()
+ + identifier_encode_size(&self.policy_version)
+ + identifier_encode_size(&self.rate_version)
+ }
+}
+
+/// Metadata for a staged, finance-approved atomic rate change set.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct RateCardChangeSet {
+ pub change_set_id: String,
+ pub expected_entry_count: u16,
+ pub target_account_ids: Vec,
+ pub expected_target_count: u16,
+ pub manifest_hash: String,
+ pub staged_entry_count: u16,
+ /// Opaque finance approval correlation. This is required before a staged
+ /// card may be activated; the detailed approval record stays off-chain.
+ pub approval_ref: String,
+ /// Required append-only audit correlation for both staging and apply.
+ pub audit_ref: String,
+ /// Monotonic activation epoch supplied by the control plane.
+ pub activation_epoch: u64,
+ /// Makes exact activation replay a no-op and prevents historical rewrite.
+ pub applied: bool,
+}
+
+/// Explicit V1 name for the finance-approved, atomic rate-card envelope.
+pub type RateCardChangeSetV1 = RateCardChangeSet;
+
+impl RateCardChangeSet {
+ /// Compute the only valid V1 manifest hash. This hashes finance evidence
+ /// and the complete approved payload, not a caller-provided label.
+ #[allow(clippy::too_many_arguments)] // public, explicit approval-envelope inputs
+ pub fn canonical_manifest_hash(
+ change_set_id: &str,
+ approval_ref: &str,
+ audit_ref: &str,
+ activation_epoch: u64,
+ expected_entry_count: u16,
+ target_account_ids: &[String],
+ expected_target_count: u16,
+ entries: &[RateCardEntry],
+ ) -> String {
+ const DOMAIN: &[u8] = b"_NUNCHI_COSTS_RATE_CARD_CHANGE_SET_V1_MANIFEST\0";
+ let mut bytes = DOMAIN.to_vec();
+ write_manifest_string(&mut bytes, change_set_id);
+ write_manifest_string(&mut bytes, approval_ref);
+ write_manifest_string(&mut bytes, audit_ref);
+ bytes.extend_from_slice(&activation_epoch.to_be_bytes());
+ bytes.extend_from_slice(&expected_entry_count.to_be_bytes());
+
+ let mut targets = target_account_ids.to_vec();
+ targets.sort();
+ bytes.extend_from_slice(&(targets.len() as u32).to_be_bytes());
+ for target in targets { write_manifest_string(&mut bytes, &target); }
+ bytes.extend_from_slice(&expected_target_count.to_be_bytes());
+
+ let mut entries = entries.to_vec();
+ entries.sort_by(|left, right| rate_card_sort_key(left).cmp(&rate_card_sort_key(right)));
+ bytes.extend_from_slice(&(entries.len() as u32).to_be_bytes());
+ for entry in entries {
+ write_manifest_string(&mut bytes, &entry.account_id);
+ write_manifest_string(&mut bytes, &entry.event_category);
+ write_manifest_string(&mut bytes, &entry.task_key);
+ bytes.extend_from_slice(&entry.credits.to_be_bytes());
+ bytes.extend_from_slice(&entry.effective_at.to_be_bytes());
+ bytes.extend_from_slice(&entry.expires_at.to_be_bytes());
+ write_manifest_string(&mut bytes, &entry.policy_version);
+ write_manifest_string(&mut bytes, &entry.rate_version);
+ }
+ Sha256::hash(&bytes).to_string()
+ }
+
+ pub fn expected_manifest_hash(&self, entries: &[RateCardEntry]) -> String {
+ Self::canonical_manifest_hash(
+ &self.change_set_id, &self.approval_ref, &self.audit_ref, self.activation_epoch,
+ self.expected_entry_count, &self.target_account_ids,
+ self.expected_target_count, entries,
+ )
+ }
+}
+
+fn write_manifest_string(bytes: &mut Vec, value: &str) {
+ let length = u32::try_from(value.len()).expect("manifest field exceeds u32 encoding limit");
+ bytes.extend_from_slice(&length.to_be_bytes());
+ bytes.extend_from_slice(value.as_bytes());
+}
+
+fn rate_card_sort_key(entry: &RateCardEntry) -> (&str, &str, &str, u64, u64, u64, &str, &str) {
+ (&entry.account_id, &entry.event_category, &entry.task_key, entry.credits, entry.effective_at,
+ entry.expires_at, &entry.policy_version, &entry.rate_version)
+}
+
+/// One durable completion record for a successfully activated approved card.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct RateCardCompletionV1 {
+ pub change_set_id: String,
+ pub manifest_hash: String,
+ pub entry_count: u16,
+ pub target_count: u16,
+ pub activation_epoch: u64,
+ pub approval_ref: String,
+ pub audit_ref: String,
+ /// Exact base and materialized target revisions activated by this card.
+ /// These carry only credits and opaque policy/rate versions, never COGS.
+ pub affected_rates: Vec,
+}
+
+impl Write for RateCardCompletionV1 {
+ fn write(&self, buf: &mut impl bytes::BufMut) {
+ write_identifier(&self.change_set_id, buf); write_identifier(&self.manifest_hash, buf);
+ self.entry_count.write(buf); self.target_count.write(buf); self.activation_epoch.write(buf);
+ write_identifier(&self.approval_ref, buf); write_identifier(&self.audit_ref, buf);
+ (self.affected_rates.len() as u16).write(buf);
+ for rate in &self.affected_rates { rate.write(buf); }
+ }
+}
+impl Read for RateCardCompletionV1 {
+ type Cfg = ();
+ fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result {
+ Ok(Self { change_set_id: read_identifier(buf)?, manifest_hash: read_identifier(buf)?,
+ entry_count: u16::read(buf)?, target_count: u16::read(buf)?, activation_epoch: u64::read(buf)?,
+ approval_ref: read_identifier(buf)?, audit_ref: read_identifier(buf)?,
+ affected_rates: { let count = u16::read(buf)? as usize; let mut rates = Vec::with_capacity(count); for _ in 0..count { rates.push(RateCardEntry::read(buf)?); } rates }, })
+ }
+}
+impl EncodeSize for RateCardCompletionV1 {
+ fn encode_size(&self) -> usize {
+ identifier_encode_size(&self.change_set_id) + identifier_encode_size(&self.manifest_hash)
+ + self.entry_count.encode_size() + self.target_count.encode_size() + self.activation_epoch.encode_size()
+ + identifier_encode_size(&self.approval_ref) + identifier_encode_size(&self.audit_ref)
+ + u16::default().encode_size() + self.affected_rates.iter().map(EncodeSize::encode_size).sum::()
+ }
+}
+
+impl Write for RateCardChangeSet {
+ fn write(&self, buf: &mut impl bytes::BufMut) {
+ write_identifier(&self.change_set_id, buf);
+ self.expected_entry_count.write(buf);
+ (self.target_account_ids.len() as u16).write(buf);
+ for account_id in &self.target_account_ids { write_identifier(account_id, buf); }
+ self.expected_target_count.write(buf);
+ write_identifier(&self.manifest_hash, buf);
+ self.staged_entry_count.write(buf);
+ write_identifier(&self.approval_ref, buf);
+ write_identifier(&self.audit_ref, buf);
+ self.activation_epoch.write(buf);
+ self.applied.write(buf);
+ }
+}
+
+impl Read for RateCardChangeSet {
+ type Cfg = ();
+
+ fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result {
+ Ok(Self {
+ change_set_id: read_identifier(buf)?,
+ expected_entry_count: u16::read(buf)?,
+ target_account_ids: { let count = u16::read(buf)? as usize; let mut accounts = Vec::with_capacity(count); for _ in 0..count { accounts.push(read_identifier(buf)?); } accounts },
+ expected_target_count: u16::read(buf)?,
+ manifest_hash: read_identifier(buf)?,
+ staged_entry_count: u16::read(buf)?,
+ approval_ref: read_identifier(buf)?,
+ audit_ref: read_identifier(buf)?,
+ activation_epoch: u64::read(buf)?,
+ applied: bool::read(buf)?,
+ })
+ }
+}
+
+impl EncodeSize for RateCardChangeSet {
+ fn encode_size(&self) -> usize {
+ identifier_encode_size(&self.change_set_id)
+ + self.expected_entry_count.encode_size()
+ + u16::default().encode_size() + self.target_account_ids.iter().map(|account_id| identifier_encode_size(account_id)).sum::() + self.expected_target_count.encode_size()
+ + identifier_encode_size(&self.manifest_hash)
+ + self.staged_entry_count.encode_size()
+ + identifier_encode_size(&self.approval_ref)
+ + identifier_encode_size(&self.audit_ref)
+ + self.activation_epoch.encode_size()
+ + self.applied.encode_size()
+ }
+}
+
+/// A normalized, PII-free metered debit input.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct SpendRecordV1 {
+ pub event_id: String,
+ pub account_id: String,
+ pub event_category: String,
+ /// Empty only for legacy fixture records. Real records use the exact
+ /// rate-card task key and are validated against the pinned snapshot.
+ pub task_key: String,
+ pub quantity: u64,
+ pub credits: u64,
+ pub observed_at: u64,
+ pub policy_version: String,
+ pub rate_version: String,
+ /// Opaque, non-PII source and lineage identifiers for reconciliation.
+ pub source_ref: String,
+ pub lineage_ref: String,
+ /// Optional application cohort used by finality consumers to isolate
+ /// exports; it does not change the account's debit semantics.
+ pub cohort_ref: String,
+}
+
+impl Write for SpendRecordV1 {
+ fn write(&self, buf: &mut impl bytes::BufMut) {
+ write_identifier(&self.event_id, buf);
+ write_identifier(&self.account_id, buf);
+ write_identifier(&self.event_category, buf);
+ write_identifier(&self.task_key, buf);
+ self.quantity.write(buf);
+ self.credits.write(buf);
+ self.observed_at.write(buf);
+ write_identifier(&self.policy_version, buf);
+ write_identifier(&self.rate_version, buf);
+ write_identifier(&self.source_ref, buf);
+ write_identifier(&self.lineage_ref, buf);
+ write_identifier(&self.cohort_ref, buf);
+ }
+}
+
+impl Read for SpendRecordV1 {
+ type Cfg = ();
+
+ fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result {
+ Ok(Self {
+ event_id: read_identifier(buf)?,
+ account_id: read_identifier(buf)?,
+ event_category: read_identifier(buf)?,
+ task_key: read_identifier(buf)?,
+ quantity: u64::read(buf)?,
+ credits: u64::read(buf)?,
+ observed_at: u64::read(buf)?,
+ policy_version: read_identifier(buf)?,
+ rate_version: read_identifier(buf)?,
+ source_ref: read_identifier(buf)?,
+ lineage_ref: read_identifier(buf)?,
+ cohort_ref: read_identifier(buf)?,
+ })
+ }
+}
+
+impl EncodeSize for SpendRecordV1 {
+ fn encode_size(&self) -> usize {
+ identifier_encode_size(&self.event_id)
+ + identifier_encode_size(&self.account_id)
+ + identifier_encode_size(&self.event_category)
+ + identifier_encode_size(&self.task_key)
+ + self.quantity.encode_size()
+ + self.credits.encode_size()
+ + self.observed_at.encode_size()
+ + identifier_encode_size(&self.policy_version)
+ + identifier_encode_size(&self.rate_version)
+ + identifier_encode_size(&self.source_ref)
+ + identifier_encode_size(&self.lineage_ref)
+ + identifier_encode_size(&self.cohort_ref)
+ }
+}