Plan — Emit usage.recorded events (billing backstop)
Issue: BacMR #178 (RabbitMQ gaps), rag half of the usage.recorded billing backstop.
Companion: gateway PR #180 consumes usage.recorded → wallet_charge_outbox.
Problem
The gateway meters chat/quiz by reading tokens_used from the sync HTTP/SSE response. If a
chat client disconnects before the SSE usage event flushes, the gateway never sees the
tokens and the charge is lost — rag did the work for free.
Approach
rag publishes usage.recorded { user_id, tokens, request_id, reason } out-of-band on the
shared bacmr.events topic exchange after generation. The gateway's wallet_charge_outbox
dedupes on request_id, so the sync path and this event can't double-charge — whichever
lands first wins. Correctness depends on rag emitting with the same request_id the
gateway forwards as X-Request-ID.
Changes
app/events/publisher.py(new) — a lazyUsagePublisher(aio-pika) over the broker rag already consumes from; declaresbacmr.events(topic, durable) and publishes persistentusage.recorded.emit_usage_recorded(...)is best-effort: no-ops whenRABBITMQ_URLis unset, whentokens <= 0, or whenrequest_idis missing, and never raises into the caller.close_usage_publisher()for clean shutdown.app/api/routers/chat.py— adopt the forwarded id:request_id = get_request_id() or uuid4()(was alwaysuuid4()). Inagent_streaming_generator, finalize + publish in aexcept (GeneratorExit, asyncio.CancelledError)branch so a mid-stream client disconnect still bills for what was generated. Normal completion finalizes + publishes once (guarded); internal errors still do not finalize or bill (unchanged).app/api/routers/quiz.py— publishusage.recorded {reason:'quiz'}after a successful generate (already usesget_request_id()).app/main.py— close the publisher on shutdown.
Non-goals
Ingestion-side token costs (operational, not per-user). Removing the sync metering (kept).
Repo-policy note
AGENTS.md §1.5(2) cautions against designs that "need a message broker." rag already ships
an aio-pika consumer; this adds a publisher on the same broker (no new dependency),
consistent with the cross-repo TODO.md mandate. Flagged and approved in Phase 1.
Tests
pytest: publisher no-op/guard/publish/error-swallow paths; chat generator emits once on
normal completion and once on aclose() (disconnect); internal error still doesn't finalize;
quiz emits with reason:'quiz'. Plus request-id adoption.