Export Billing Records
Turn verified tool-call charges into accounting rows you can trace back to their signed receipts.
On this page
A billing export gives each allowed charge one accounting row keyed by its signed receipt's ID, so every invoice line reconciles with the kernel's record before it goes out.
Three records stay distinct. Signed receipts are the kernel's statements. An evidence package preserves selected receipts with a verification manifest, from chio evidence export. A BillingExport holds the accounting rows derived from verified receipts, from the chio-metering library.
1. Export the charges from your metered service
Enter the run directory your metered service printed and run the collector that ships with it.
Before you begin
Run the complete pricing-metered project. Its default run makes two 20-unit charges and one budget refusal, and the download includes the host and the collector used here.
cargo run --release --locked --bin export-billing -- . kernel-public-key.txtverified records: 3; non-allow records excluded from billing: 1 billing records: 2 total: 40 USD minor units
You should see three verified records, one non-allow record excluded, and two billing rows totaling 40 USD minor units. The host wrote kernel-public-key.txt from its chosen identity before it requested any call, and the collector checks every receipt's signer, body ID, parameter hash, and signature against that key. For another operator's receipts, take the expected key from an authenticated channel.
2. Read one accounting row
Open billing.json for the complete envelope, or billing.jsonl for one row per charge. The first row:
{
"schema": "chio.billing-export.v1",
"receipt_id": "a066add3a537a49f122d30c94fa1370f0ecca1aa2a8f7d9db5a9e8b0eef0d019",
"timestamp": 1791791808,
"timestamp_iso": "2026-10-12T07:56:48Z",
"agent_id": "95c23b02f487c365d4b338add408bfe09968c5f5524d8859ac35563ea529c3e5",
"tool_server": "pricing",
"tool_name": "discount",
"compute_time_ms": 0,
"data_bytes": 0,
"cost_units": 20,
"currency": "USD"
}You should see the row's receipt_id, which is how every row walks back to its signed receipt. The refusal stays in receipts.ndjson and adds no row. The amounts are USD-denominated local credits from the application's operator ledger; external settlement and customer invoicing run in separate systems. The zero compute_time_ms and data_bytes mean this example measured neither.
The complete BillingExport envelope
{
"schema": "chio.billing-export.v1",
"exported_at": 1791791857,
"record_count": 2,
"total_cost": {
"units": 40,
"currency": "USD"
},
"records": [
{
"schema": "chio.billing-export.v1",
"receipt_id": "a066add3a537a49f122d30c94fa1370f0ecca1aa2a8f7d9db5a9e8b0eef0d019",
"timestamp": 1791791808,
"timestamp_iso": "2026-10-12T07:56:48Z",
"agent_id": "95c23b02f487c365d4b338add408bfe09968c5f5524d8859ac35563ea529c3e5",
"tool_server": "pricing",
"tool_name": "discount",
"compute_time_ms": 0,
"data_bytes": 0,
"cost_units": 20,
"currency": "USD"
},
{
"schema": "chio.billing-export.v1",
"receipt_id": "91a83e8210fca72d59ebccfb1138f9da8089ad8a2753b2c88ab6c8910287abb6",
"timestamp": 1791791808,
"timestamp_iso": "2026-10-12T07:56:48Z",
"agent_id": "95c23b02f487c365d4b338add408bfe09968c5f5524d8859ac35563ea529c3e5",
"tool_server": "pricing",
"tool_name": "discount",
"compute_time_ms": 0,
"data_bytes": 0,
"cost_units": 20,
"currency": "USD"
}
]
}3. Check that altered receipts never reach the export
The collector refuses an untrusted signer, an altered body, a duplicate receipt ID, a missing monetary amount, and an allowed record that is not settled. It checks currency sums for overflow before it writes any output file. Edit one charge and duplicate another, then run step 1 again:
receipt ID mismatch on a066add3a537a49f122d30c94fa1370f0ecca1aa2a8f7d9db5a9e8b0eef0d019
duplicate receipt a066add3a537a49f122d30c94fa1370f0ecca1aa2a8f7d9db5a9e8b0eef0d019You should see each run refuse and name the offending receipt, with no rows written. The checks cover the records you supply: a valid subset still verifies, so fix your collection boundary (the store, tenant, window, and every result page) and reconcile it with the authoritative store in step 5.
4. Package the same receipts as signed evidence
The evidence package goes to an auditor and the rows go to the accounting system, and a monthly close produces both from the same receipts. Export a whole store first; the reference below adds a window and a capability filter.
$ chio --receipt-db ./receipts.db evidence export --admin-all --output ./package
$ ls -1 packageREADME.txt capability-lineage.ndjson checkpoint-consistency-proofs.ndjson checkpoint-equivocations.ndjson checkpoint-publications.ndjson checkpoint-witnesses.ndjson checkpoints.ndjson child-receipts.ndjson inclusion-proofs.ndjson manifest.json query.json receipts.ndjson retention.json
You should see thirteen files. Twelve appear in the manifest's files array with a sha256 and a byte count; manifest.json is the thirteenth and does not hash itself. Verify the package:
$ chio evidence verify --input ./packageevidence package verified tool_receipts: 3 child_receipts: 0 checkpoints: 0 checkpoint_publications: 0 checkpoint_witnesses: 0 checkpoint_consistency_proofs: 0 checkpoint_equivocations: 0 capability_lineage: 3 inclusion_proofs: 0 uncheckpointed_receipts: 3 authorized_receipts: 1 trace_observations: 0 advisory_evaluations: 0 verified_files: 12 child_receipt_scope: FullQueryWindow transparency_preview_logs: 0 publication_state: transparency_preview
You should see verified_files: 12 and a count per record class. This capture covers three receipts, one allowed and two denied: tool_receipts counts every receipt in the window, authorized_receipts the allowed ones that pass the package checks. Those checks use the keys inside the package, so keep the collector's expected-signer check from step 1 before you derive rows.
5. Reconcile every row with its signed receipt
The project includes a dependency-free Node script that pages through the run's receipt store with your installed chio. Pass the run directory the application printed.
node reconcile-billing.mjs runs/<run-id>Reconciled 2 billing rows against 3 stored receipts across 1 page(s). Content comparison complete; use the Rust verifier for signer and signature checks.
You should see two rows reconciled against three stored receipts. The script compares row identity, amount, currency, and attribution; the Rust collector stays responsible for signatures and the expected signer. Three checks cover the common drift:
- Envelope against rows. In a single-currency export the per-currency sum of
records[].cost_unitsequalstotal_cost.units; in a mixed-currency exporttotal_costMUST be null (METERING.md §4.2). - Row against receipt. Each
receipt_idresolves to a signed receipt.cost_unitscomes from your cost metadata andmetadata.financial.cost_chargedfrom the budget path, and your projection contract decides how they compare. This application copies the signed charge exactly, so amount and currency both match; different currencies stay different quantities even in integer minor units. - Row count. The envelope's
record_countequalsrecords.length; a mismatch means a truncated or corrupted export.
The script passes arguments directly, rejects non-advancing cursors and duplicate IDs, and refuses numbers outside JavaScript's safe-integer range. Paging a store while it changes takes no snapshot, so close against a stable collection boundary.
The reconciliation script
// Content reconciliation for this application's billing projection.
// Run export-billing first to verify the signed input and expected signer.
// This script compares stored content; it does not verify signatures itself.
import { readFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { resolve } from 'node:path';
const directory = process.argv[2];
if (!directory || process.argv.length !== 3) {
throw new Error('Usage: node reconcile-billing.mjs RUN_DIRECTORY');
}
function ensure(ok, message) { if (!ok) throw new Error(message); }
function integer(value, label) {
ensure(Number.isSafeInteger(value) && value >= 0,
`${label} must be a nonnegative safe integer`);
return value;
}
function text(value, label) {
ensure(typeof value === 'string' && value.length > 0, `${label} is required`);
return value;
}
const bundle = JSON.parse(readFileSync(resolve(directory, 'billing.json'), 'utf8'));
ensure(bundle.schema === 'chio.billing-export.v1', 'unexpected billing schema');
ensure(Array.isArray(bundle.records), 'records must be an array');
ensure(integer(bundle.record_count, 'record_count') === bundle.records.length,
'record count mismatch');
const rows = new Set();
const totals = new Map();
for (const row of bundle.records) {
const id = text(row.receipt_id, 'receipt_id');
ensure(!rows.has(id), `duplicate billing row ${id}`);
rows.add(id);
integer(row.timestamp, 'timestamp');
const currency = text(row.currency, 'currency');
const units = integer(row.cost_units, 'cost_units');
totals.set(currency, integer((totals.get(currency) ?? 0) + units, 'currency sum'));
}
if (totals.size === 1) {
const [currency, units] = [...totals][0];
ensure(bundle.total_cost?.currency === currency && bundle.total_cost?.units === units,
'envelope total disagrees with record sum');
} else {
ensure(bundle.total_cost == null,
'empty or mixed-currency export must omit the scalar total');
}
const pageSize = Number(process.env.CHIO_RECEIPT_PAGE_SIZE ?? '200');
ensure(Number.isInteger(pageSize) && pageSize >= 1 && pageSize <= 200,
'page size must be 1 through 200');
const receipts = new Map();
let cursor;
let pages = 0;
for (;;) {
const args = ['--receipt-db', resolve(directory, 'receipts.db'),
'receipt', 'list', '--admin-all', '--limit', String(pageSize)];
if (cursor !== undefined) args.push('--cursor', cursor);
// Argument array: paths never become shell program text.
const result = spawnSync(process.env.CHIO_CLI ?? 'chio', args,
{ encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 });
if (result.error) throw result.error;
ensure(result.status === 0, result.stderr || 'receipt query failed');
for (const line of result.stdout.split('\n').filter(line => line.trim())) {
const receipt = JSON.parse(line);
const id = text(receipt.id, 'receipt ID');
ensure(!receipts.has(id), `duplicate receipt across pages: ${id}`);
receipts.set(id, receipt);
}
ensure(++pages <= 10000, 'query exceeded page limit; stop and inspect the store');
const next = /next_cursor=(\d+)/.exec(result.stderr)?.[1];
if (next === undefined) break;
ensure(cursor === undefined || BigInt(next) > BigInt(cursor),
'receipt cursor did not advance');
cursor = next;
}
for (const row of bundle.records) {
const receipt = receipts.get(row.receipt_id);
ensure(receipt, `no receipt for ${row.receipt_id}`);
const financial = receipt.metadata?.financial;
ensure(receipt.decision?.verdict === 'allow'
&& financial?.settlement_status === 'settled',
`receipt is not an allowed settled charge: ${row.receipt_id}`);
ensure(integer(financial.cost_charged, 'signed cost') === row.cost_units
&& financial.currency === row.currency,
`amount or currency mismatch: ${row.receipt_id}`);
ensure(receipt.timestamp === row.timestamp && receipt.tool_server === row.tool_server
&& receipt.tool_name === row.tool_name
&& receipt.metadata?.attribution?.subject_key === row.agent_id,
`attribution mismatch: ${row.receipt_id}`);
}
console.log(`Reconciled ${rows.size} billing rows against ${receipts.size}`
+ ` stored receipts across ${pages} page(s).`);
console.log('Content comparison complete; use the Rust verifier'
+ ' for signer and signature checks.');Billing schema, filters, and failures
Try cost_units, --admin-all, MAX_COST_QUERY_LIMIT, or total_cost.
What a billing export answers
A budget check answers whether a call can proceed. A billing export answers the questions after it:
- Invoicing. One record per tool call, ready for QuickBooks, NetSuite, Stripe Billing, or an internal invoicing pipeline.
- Chargeback. Compute, data, and monetary costs attributed to the agent, session, or tenant that caused them.
- Reconciliation. The flat export compared with the signed receipt log, to catch drift, missing costs, or ingestion errors.
- Compliance. A flat, timestamped record set with a schema version and a one-to-one mapping to signed receipts.
Collection boundary
The downloadable collector reads the application's complete receipts.ndjson. A shared deployment also needs an authorized tenant read, a defined time window, every result page, and your mapping from agent identities to accounting owners. The kernel's financial metadata and the library's CostMetadata are separate structures, and the example converts between them.
BillingRecord
create_billing_export in chio-metering::export emits each record. The schema constant BILLING_EXPORT_SCHEMA equals "chio.billing-export.v1".
pub struct BillingRecord {
/// Schema version.
pub schema: String,
/// Receipt ID.
pub receipt_id: String,
/// Unix timestamp (seconds) of the invocation.
pub timestamp: u64,
/// ISO 8601 timestamp for human-readable export.
pub timestamp_iso: String,
/// Session ID.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
/// Agent that triggered the cost.
pub agent_id: String,
/// Tool server.
pub tool_server: String,
/// Tool name.
pub tool_name: String,
/// Compute time in milliseconds.
pub compute_time_ms: u64,
/// Total data transferred in bytes.
pub data_bytes: u64,
/// Monetary cost amount (minor units).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cost_units: Option<u64>,
/// Currency code for cost_units.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub currency: Option<String>,
/// Upstream provider that charged the cost.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
}Four values are derived. compute_time_ms is the saturating sum of every ComputeTime dimension; data_bytes is bytes read plus bytes written across every DataVolume dimension; provider is the first provider among the ApiCost dimensions; timestamp_iso is computed from timestamp. Recheck consistency if another system edits a row.
cost_units is in the currency's minor units, copied from the record's stored total_monetary_cost. A CostMetadata with ApiCost dimensions that never passed through compute_total_monetary_cost exports with a provider and no cost_units.
Optional fields (session_id, cost_units, currency, provider, total_cost) carry skip_serializing_if, so an empty one is absent from the JSON. Treat an absent field as null, never zero: a record with no stored total_monetary_cost omits its monetary fields. The collector sets that total without inventing an ApiCost provider dimension.
BillingExport
Records collect into one envelope:
pub struct BillingExport {
/// Schema version.
pub schema: String,
/// Export timestamp (Unix seconds).
pub exported_at: u64,
/// Total number of records in this export.
pub record_count: u64,
/// Total monetary cost across all records.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_cost: Option<MonetaryAmount>,
/// The billing records.
pub records: Vec<BillingRecord>,
}This library capture calls create_billing_export over two cost records that name two stored receipts, with amounts supplied as CostMetadata. Both use USD, so 100 and 200 units total 300 minor units, and 1775078745 renders as 2026-04-01T21:25:45Z because the export computes the string from the integer. The second record has no session_id line.
$ cargo run -- single ids.txt{
"schema": "chio.billing-export.v1",
"exported_at": 1777593600,
"record_count": 2,
"total_cost": {
"units": 300,
"currency": "USD"
},
"records": [
{
"schema": "chio.billing-export.v1",
"receipt_id": "084e222b3a438c3893e580da73c01c2f3af56bab6fa899ad54396adb3b3e23ec",
"timestamp": 1775078745,
"timestamp_iso": "2026-04-01T21:25:45Z",
"session_id": "sess-42",
"agent_id": "agent-main-001",
"tool_server": "srv-ai-inference",
"tool_name": "generate_text",
"compute_time_ms": 200,
"data_bytes": 1536,
"cost_units": 100,
"currency": "USD",
"provider": "openai"
},
{
"schema": "chio.billing-export.v1",
"receipt_id": "11bcbb02affa85aa3bf5b3a4bb6a568169308b07fb9d0308ec83b27329eade64",
"timestamp": 1775081400,
"timestamp_iso": "2026-04-01T22:10:00Z",
"agent_id": "agent-main-001",
"tool_server": "srv-ai-inference",
"tool_name": "generate_text",
"compute_time_ms": 180,
"data_bytes": 1024,
"cost_units": 200,
"currency": "USD",
"provider": "anthropic"
}
]
}Evidence export flags
chio evidence export writes a verifiable evidence directory for a time window and an optional capability filter; chio evidence verify checks it. The export takes exactly one backend, --receipt-db <path> or --control-url <url>, and a receipt read boundary: --tenant <id> for one tenant or --admin-all for an explicit cross-tenant operator read. Remote reads derive the tenant boundary from the trust-control token.
# Monthly export for a single capability, scoped to one tenant.
# Inclusive bounds: 2026-04-01T00:00:00Z through 2026-04-30T23:59:59Z.
# Midnight on May 1 belongs to the next export.
chio evidence export \
--receipt-db ./receipts.sqlite \
--tenant ops \
--output ./evidence-2026-04 \
--since 1775001600 \
--until 1777593599 \
--capability cap-budget-001
# Verify the package
chio evidence verify --input ./evidence-2026-04Receipt list for a window
chio receipt list emits the raw receipts behind a reconciliation as JSON Lines, one receipt per line, with the same window and tenant scope; the read boundary is required.
# Emit the receipts for one capability inside the billing window
chio receipt list \
--receipt-db ./receipts.sqlite \
--tenant ops \
--capability cap-budget-001 \
--since 1775001600 \
--until 1777593599 \
--limit 200--min-cost and --max-cost each require --cost-currency, and the command refuses to parse without it. --limit clamps to 200 with no warning and a zero exit, so a larger window needs the cursor loop from Inspect Your Receipts.
$ chio --receipt-db ./receipts.db receipt list --admin-all --min-cost 0error: the following required arguments were not provided: --cost-currency <COST_CURRENCY> Usage: chio receipt list --cost-currency <COST_CURRENCY> --admin-all --min-cost <MIN_COST> For more information, try '--help'.
create_billing_export
create_billing_export(records: &[CostMetadata], exported_at: u64) -> BillingExport takes a slice and returns an envelope, with no I/O and no filtering; call it from nightly jobs or billing webhooks. The download supplies the collection step in src/billing.rs and this entry point, which checks the receipts, copies allowed settled charges into CostMetadata, and calls the exporter after validating the whole input.
#[path = "../billing.rs"]
mod billing;
fn main() -> anyhow::Result<()> {
let mut args = std::env::args().skip(1);
let directory = args.next().ok_or_else(|| {
anyhow::anyhow!("usage: export-billing RUN_DIRECTORY TRUSTED_KERNEL_KEY_FILE")
})?;
let key = args
.next()
.ok_or_else(|| anyhow::anyhow!("supply the public key from trusted configuration"))?;
anyhow::ensure!(args.next().is_none(), "expected exactly two arguments");
billing::collect_and_export(std::path::Path::new(&directory), std::path::Path::new(&key))?;
Ok(())
}Read the full collector to adapt the mapping. It sets total_monetary_cost from the signed financial amount without inventing provider or resource measurements; keep that contract when you add cost dimensions. CSV export, where supported, MUST use the JSON schema's field names with one record per row and a header line (METERING.md §4.3); no field nests, so any standard flattener converts the JSON Lines output.
CostQuery
The exporter serializes every record it receives, so filtering happens upstream: through chio receipt list and chio evidence export flags, or with CostQuery from chio-metering::query over the CostMetadata slice.
pub struct CostQuery {
/// Filter by session ID.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
/// Filter by agent ID.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_id: Option<String>,
/// Filter by tool server.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_server: Option<String>,
/// Filter by tool name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_name: Option<String>,
/// Start of time range (inclusive, Unix seconds).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub since: Option<u64>,
/// End of time range (exclusive, Unix seconds).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub until: Option<u64>,
/// Currency filter -- only include costs in this currency.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub currency: Option<String>,
/// Maximum number of detailed records to return.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<usize>,
/// Aggregation group-by dimension.
#[serde(default)]
pub group_by: GroupBy,
}Here since is inclusive and until exclusive, the opposite of the CLI, whose --until is inclusive. One month boundary passed to both selects a receipt landing on midnight in the CLI and drops it in CostQuery; pick one convention and subtract a second where needed. limit caps at MAX_COST_QUERY_LIMIT, 500. CostQuery has no tenant field: scope the input through an authorized tenant read and a trusted mapping of agents or capabilities to owners.
use chio_metering::query::{CostQuery, GroupBy, execute_cost_query};
use chio_metering::export::create_billing_export;
// 1. Filter with CostQuery.
let query = CostQuery {
since: Some(month_start),
until: Some(month_end),
agent_id: Some("agent-ops-01".to_string()),
currency: Some("USD".to_string()),
group_by: GroupBy::None,
..Default::default()
};
let query_result = execute_cost_query(&all_records, &query);
if query_result.truncated {
// A bounded summary, not a cursor over the complete input.
// More than 500 rows can share one timestamp.
log::warn!("cost query truncated; do not use its totals as a complete close");
}
// 2. Feed the filtered set into create_billing_export. CostQuery returns no
// CostMetadata, so re-filter the source slice by the same predicates,
// or use your receipt store's range loader.
let filtered: Vec<_> = all_records.iter()
.filter(|r| r.timestamp >= month_start && r.timestamp < month_end)
.filter(|r| r.agent_id == "agent-ops-01")
.filter(|r| r.total_monetary_cost.as_ref()
.is_some_and(|cost| cost.currency == "USD"))
.cloned()
.collect();
let export = create_billing_export(&filtered, now_unix);When CostQueryResult.truncated is true, the summary excludes matches beyond the cap and returns no cursor, and narrower windows cannot recover more than 500 records sharing a timestamp. Collect the complete input through the receipt store's cursor, then apply the full predicates.
Cross-currency rules
One invocation can accrue ApiCost dimensions in several currencies, as when a tool chains USD and EUR providers. create_billing_export applies three rules:
- Per record.
cost_unitsandcurrencycome from the storedtotal_monetary_cost.compute_total_monetary_costsums only the dimensions that share the first monetary dimension's currency, without converting or keeping the others, so inspect the original dimensions before using it. - Export total. The envelope's
total_costsums records only while every record uses one currency; a second currency sets it to null. - Conversion. The export converts no currency. Cross-currency amounts need a documented conversion policy; the kernel's budget path can use
chio-link, and a downstream FX conversion attaches its own evidence outside the export.
$ cargo run -- mixed ids.txt{
"schema": "chio.billing-export.v1",
"exported_at": 1777593600,
"record_count": 2,
"records": [
{
"schema": "chio.billing-export.v1",
"receipt_id": "084e222b3a438c3893e580da73c01c2f3af56bab6fa899ad54396adb3b3e23ec",
"timestamp": 1775078745,
"timestamp_iso": "2026-04-01T21:25:45Z",
"agent_id": "agent-main-001",
"tool_server": "srv-ai-inference",
"tool_name": "generate_text",
"compute_time_ms": 100,
"data_bytes": 256,
"cost_units": 75,
"currency": "USD",
"provider": "openai"
},
{
"schema": "chio.billing-export.v1",
"receipt_id": "11bcbb02affa85aa3bf5b3a4bb6a568169308b07fb9d0308ec83b27329eade64",
"timestamp": 1775081400,
"timestamp_iso": "2026-04-01T22:10:00Z",
"agent_id": "agent-main-001",
"tool_server": "srv-ai-inference",
"tool_name": "generate_text",
"compute_time_ms": 80,
"data_bytes": 128,
"cost_units": 50,
"currency": "EUR",
"provider": "mistral"
}
]
}There is no total_cost key at all: the field is None and carries skip_serializing_if, so the spec's "MUST be null" appears on the wire as an absent key. A JavaScript reconciler comparing with != is fine, since undefined != null is false; one comparing with !== is not. For single-currency journals, emit one export per currency by setting CostQuery.currency before filtering.
Monthly close
On the first of each month, package the previous UTC month's evidence, then collect and reconcile the rows for the same boundary before they go to the accounting system. The script works with GNU and macOS date. Use a fresh output directory per package, review the verification report's coverage, and keep the evidence directory apart from BillingExport JSON.
#!/usr/bin/env bash
set -euo pipefail
RECEIPT_DB="./receipts.sqlite"
CURRENT_MONTH=$(date -u +%Y-%m-01)
CURRENT_START=$(date -u -d "$CURRENT_MONTH 00:00:00" +%s 2>/dev/null \
|| date -u -j -f "%Y-%m-%d %H:%M:%S" "$CURRENT_MONTH 00:00:00" +%s)
PREVIOUS_DAY=$((CURRENT_START - 1))
LABEL=$(date -u -d "@$PREVIOUS_DAY" +%Y-%m 2>/dev/null \
|| date -u -r "$PREVIOUS_DAY" +%Y-%m)
MONTH_START=$(date -u -d "$LABEL-01 00:00:00" +%s 2>/dev/null \
|| date -u -j -f "%Y-%m-%d %H:%M:%S" "$LABEL-01 00:00:00" +%s)
MONTH_END=$PREVIOUS_DAY
OUT="./evidence-$LABEL"
# An inclusive final second keeps midnight out of adjacent monthly exports.
chio evidence export \
--receipt-db "$RECEIPT_DB" \
--admin-all \
--output "$OUT" \
--since "$MONTH_START" \
--until "$MONTH_END"
chio evidence verify --input "$OUT"Per-agent chargeback
Split one kernel's cost across internal teams with one export per agent_id. Apply an authenticated mapping from agent to team and review the selected metadata before sharing it; grouping rows by an agent string gives no privacy or tenant isolation.
use std::collections::BTreeMap;
use chio_metering::{cost::CostMetadata, export::create_billing_export};
fn exports_by_agent(
records: Vec<CostMetadata>,
now: u64,
) -> BTreeMap<String, chio_metering::BillingExport> {
let mut bucketed: BTreeMap<String, Vec<CostMetadata>> = BTreeMap::new();
for r in records {
bucketed.entry(r.agent_id.clone()).or_default().push(r);
}
bucketed
.into_iter()
.map(|(agent_id, rs)| (agent_id, create_billing_export(&rs, now)))
.collect()
}Per-tenant evidence
When each tenant holds a distinct capability token, chio evidence export --capability narrows evidence to it. Keep the tenant boundary and your accounting contract as separate inputs: a matching token establishes neither tenant ownership nor an amount owed.
# After the monthly window variables above are set, an authorized operator
# can select capabilities from a reviewed inventory. These are evidence packages.
while IFS= read -r TENANT_CAP; do
[ -n "$TENANT_CAP" ] || continue
chio evidence export \
--receipt-db ./receipts.sqlite \
--admin-all \
--output "./evidence/$TENANT_CAP/$LABEL" \
--since "$MONTH_START" \
--until "$MONTH_END" \
--capability "$TENANT_CAP"
done < ./tenant-caps.txtDownstream systems
- Verify the selected receipts against your expected-signer policy and collect costs under a documented mapping.
- Call
create_billing_exportand reconcile amounts, currencies, counts, and receipt IDs. Keep an evidence package alongside when you need portable supporting records. - Transform the JSON Lines into the target's format: CSV for QuickBooks, line items for Stripe Billing, a row per record for BigQuery.
receipt_id is the stable cross-system identifier and a deduplication key; re-running an export is safe when the downstream write enforces that key's uniqueness. If the pipeline assigns its own IDs, keep receipt_id as a column so reconciliation still reaches the signed receipt, and keep any extra dimensions when one receipt yields several accounting lines.
Export and reconciliation errors
| What you see | Cause | Action |
|---|---|---|
evidence export requires an explicit receipt read boundary | No --tenant or --admin-all | Name the boundary; the export never defaults to every tenant |
use either --receipt-db or --control-url for evidence export, not both | Two backends named | Drop one |
output directory must be empty | A previous export is in the target | Export to a fresh path for each close |
the following required arguments were not provided: --cost-currency | A cost band on receipt list with no currency | Add --cost-currency USD |
no receipt for <id> from a reconciler | The selected input lacks that record | Check store, tenant, window, and every cursor page against total_count |
envelope total disagrees with record sum | A row sum, currency, or envelope changed | Recompute totals by currency; inspect each stored total_ |
cost query truncated, or Cost | Matches exceeded MAX_ | Collect the input through a store cursor |
A provider with no cost_units | total_ never computed | Populate the total from your verified mapping; check mixed currencies first |