v3/docs/adr/ADR-110-production-spend-reporter.md
ADR-097 Phase 3 upstream shipped:
SpendReporter interface (storage-agnostic strategy)coordinator.reportSpend() that fans out to a SpendReporter + breaker buffer in parallelInMemorySpendReporter reference impl (in-memory buffer, fine for tests, NOT for production)The cost-tracker plugin's federation consumer (plugins/ruflo-cost-tracker/scripts/federation.mjs) reads from a specific contract:
federation-spendfed-spend-<peerId>-<ts>memory store --namespace federation-spend --key ...)Today, no SpendReporter actually writes to that namespace. The consumer runs against an empty namespace and reports zero spend. Federation's breaker correctly trips on its own in-memory buffer, but the cost-tracker dashboard sees nothing.
Ship a MemorySpendReporter in the federation plugin that satisfies the cost-tracker consumer contract.
Implementation lives in v3/@claude-flow/plugin-agent-federation/src/application/spend-reporter.ts alongside the existing InMemorySpendReporter:
export interface MemoryStore {
store(args: { namespace: string; key: string; value: string; ttl?: number }): Promise<void>;
}
export interface MemorySpendReporterConfig {
/** Memory store impl. Integrators inject the ruflo memory CLI / MCP tool / direct memory client. */
readonly memoryStore: MemoryStore;
/** Namespace per the cost-tracker consumer contract. Default: 'federation-spend' */
readonly namespace?: string;
/** Optional TTL in seconds. Default: 7 days (matches consumer's rolling-window upper bound) */
readonly ttlSeconds?: number;
}
export class MemorySpendReporter implements SpendReporter {
constructor(private readonly config: MemorySpendReporterConfig) {}
async reportSpend(event: FederationSpendEvent): Promise<void> {
const namespace = this.config.namespace ?? 'federation-spend';
const key = `fed-spend-${event.peerId}-${event.ts}`;
await this.config.memoryStore.store({
namespace,
key,
value: JSON.stringify({
peerId: event.peerId,
taskId: event.taskId ?? null,
tokensUsed: event.tokensUsed,
usdSpent: event.usdSpent,
success: event.success,
ts: event.ts,
}),
ttl: this.config.ttlSeconds ?? 7 * 24 * 60 * 60,
});
}
}
The federation plugin shouldn't pull @claude-flow/memory (or any specific memory backend) as a hard dep. Reasons:
MemoryStore.store(...)) is small enough that ANY KV store can satisfy it with a 5-line adapterThe integrator wires whatever memory backend they want:
// With ruflo memory MCP tool
import { MemorySpendReporter } from '@claude-flow/plugin-agent-federation';
const reporter = new MemorySpendReporter({
memoryStore: {
store: async ({ namespace, key, value, ttl }) => {
await mcpClient.call('memory_store', { namespace, key, value, ttl });
},
},
});
// Then construct the coordinator with this reporter:
const coordinator = new FederationCoordinator(
config, discovery, handshake, routing, audit, pii, trust, policy,
{ spendReporter: reporter, breakerService: breaker },
);
The cost-tracker consumer expects keys matching fed-spend-<peerId>-<ts>. The MemorySpendReporter produces exactly that. The consumer's read path:
memory list --namespace federation-spend → all keysmemory retrieve --namespace federation-spend --key fed-spend-X-Y → single eventts from the valueWe pin the key shape with a unit test so any future drift on the consumer side is caught immediately.
Cost-tracker's rolling windows are 1h / 24h / 7d. Anything older than 7d is irrelevant to the consumer's aggregations. 7-day TTL bounds memory growth without sacrificing reportable history.
If integrators want longer retention, they can override ttlSeconds (e.g. for monthly/quarterly accounting reports).
MemorySpendReporter classsrc/application/spend-reporter.ts:
MemoryStore interface (just store({namespace, key, value, ttl}))MemorySpendReporterConfig interfaceMemorySpendReporter class implementing SpendReporter// src/index.ts
export {
InMemorySpendReporter,
MemorySpendReporter, // NEW
type SpendReporter,
type MemoryStore, // NEW
type MemorySpendReporterConfig, // NEW
type FederationSpendEvent,
} from './application/spend-reporter.js';
__tests__/unit/memory-spend-reporter.test.ts:
fed-spend-<peerId>-<ts> literallyfederation-spend, override acceptedFederationSpendEvent is preservedAdd example wire-up showing both:
InMemorySpendReporterMemorySpendReporter + ruflo memory MCP toolreportSpend is one memory.store call. If memory backend latency is a concern, the integrator can wrap MemorySpendReporter in a batcher.fed-spend-<peerId>-<ts> — drift here would silently break the cost-tracker consumertokensUsed / usdSpent are persisted as-is (NOT clamped — clamping is the breaker's responsibility, not the audit log's). The reporter is an honest mirror; the breaker is the policy.ts is always RFC3339-ish ISO 8601 (auto-filled by coordinator if caller omits)| Step | Status |
|---|---|
MemoryStore + MemorySpendReporter class | Implemented |
Exports in src/index.ts | Implemented |
| Tests (5 specs pinning key/namespace/TTL/round-trip/throw-bubble) | Implemented |
| Operator runbook example | Implemented (in this ADR) |
Cross-OS validation: write event, read via memory retrieve, parse | Implemented in alpha.10 smoke |
Re-open when:
DatadogSpendReporter) — at that point the interface might need refinement