Task — engineering-spec@1

"Metering ai_tokens emit at cost_reconcile — WalQueue push from ai-gateway"

doneTASK-TEN-204
module ten · class product · priority p0 · created 2026-07-26 · shipped null
depends on TASK-TEN-004 · blocks none

TASK-TEN-204: ai_tokens metering emit at cost_reconcile

Summary

Add the first live metering emit path: when ai-gateway cost_reconcile finalises a hold with token usage (Success or Cancelled with partial usage), push one MeteringEvent with axis = ai_tokens onto a process-local WalQueue (cyberos-metering), idempotent on hold_id.

Problem

TASK-TEN-004 shipped axes / recorder / WalQueue / SQL migration with no callers. Spec §1 #7 names the cost-ledger postcall hook; as-built that hook is services/ai-gateway/src/cost_reconcile.rs (not cost_ledger.rs). Without an emit path, period aggregates stay empty.

Proposed Solution

  1. cyberos-ai-gateway depends on cyberos-metering.
  2. Module metering_emit.rs: process-local OnceLock<Mutex<WalQueue>> (or test-injectable Recorder); emit_ai_tokens(tenant_id, hold_id, provider, model, prompt, completion).
  3. Call from reconcile on CallOutcome::Success and Cancelled { partial_usage: Some(_) } after usage is known; do not emit on ProviderError or Cancelled(None).
  4. Event shape:
  1. WAL overflow: log + metric/counter; reconcile still commits (metering must not fail the call).
  2. Unit test: push Success path → queue depth 1; duplicate hold_id → still depth 1 if using InMemoryRecorder for the test surface, or WalQueue accepts duplicates until Pg drain (document: WAL may carry dupes; Pg UNIQUE is the idempotency floor — for this slice test InMemoryRecorder via a thin emit_to helper).

Alternatives Considered

Success Metrics

Scope

In scope

Out of scope / Non-Goals

Dependencies

AI Authorship Disclosure

Generated then reviewed against as-built metering crate + cost_reconcile (2026-07-26).

Acceptance Criteria

  1. Success emit — reconcile Success with prompt=10 completion=5 → event quantity 15, axis ai_tokens.
  2. Cancelled partial — Cancelled(Some(usage)) emits; Cancelled(None) does not.
  3. ProviderError — no metering event.
  4. Idempotency key — equals hold_id UUID string.
  5. extra fields — provider, model_alias, input_tokens, output_tokens present.
  6. Non-blocking — WalQueue overflow / emit error does not change ReconcileOutcome success.
  7. Zero tokens — quantity 0 skipped (no invalid quantity push).
  8. DepCargo.toml lists cyberos-metering.

Verification

cd services
cargo test -p cyberos-ai-gateway --test metering_ai_tokens_emit_test -- --test-threads=1
cargo test -p cyberos-metering -- --test-threads=1
bash .cyberos/cuo/gates/run-gates.sh

Failure Modes

FailureDetectionOutcomeRecovery
WAL overflowWalError::Overflowlog; call OKDrain / raise capacity later
qty out of rangevalidate_quantityskip + logCap at provider
Missing hold fieldsN/A (locked row)
Double reconcileAlreadyFinalisedno second emitIdempotent hold
Mutex poisonlock errlog; call OKRestart process
Zero tokensqty checkno emitOK
Test isolationreset_for_testsclean queuecfg(test) reset
Dep version skewcargobuild failWorkspace path
Extra JSON missing keystest assertfail CIFix emit
Emit after TX commit vs beforecode reviewprefer after successful apply, before commit OK if non-blockingDocument

End of TASK-TEN-204.

Audit

§1 — Verdict summary

Smallest metering emit residual: cost_reconcile → ai_tokens WalQueue. 8 ACs, 10 failure modes, honest Out of scope for api_calls and Pg drain.

§2 — Findings (all resolved)

ISS-001 — Spec cited cost_ledger.rs

As-built is cost_reconcile.rs. Resolved: Problem + Proposed Solution cite real path.

ISS-002 — Emit must not fail AI calls

TEN-004 latency doctrine. Resolved: AC #6 + Alternatives + failure WAL overflow.

ISS-003 — Idempotency unclear

hold_id vs hold.idempotency_key. Resolved: AC #4 hold_id string.

ISS-004 — Zero-token Success

validate_quantity rejects 0 for ai_tokens. Resolved: AC #7 skip.

ISS-005 — ProviderError false billing

Resolved: AC #3 no emit.

ISS-006 — WAL vs Recorder for tests

Duplicates in WAL until Pg. Resolved: Proposed Solution #6 test via InMemoryRecorder helper.

§3 — Resolution

All 6 mechanical concerns addressed. Score = 10/10.


End of TASK-TEN-204 audit.