Files
ruflo/v3/docs/adr/ADR-097-federation-budget-circuit-breaker.md
rUvandReuven 19a569b1a7 feat(federation): ADR-097 phases 2.a/2.b/3-up/4 + ADR-104 transport (#1876)
* feat(federation): ADR-097 Phase 2.a — peer state machine entity layer

FederationNode entity gains a state field (ACTIVE/SUSPENDED/EVICTED) plus
suspend()/evict()/reactivate() methods. All transitions flow through a
canTransition() table in the new federation-node-state.ts value object —
self-loops are rejected (so a duplicate suspend can't reset the
auto-eviction grace timer), EVICTED is terminal under breaker flow,
operator reactivate is the only escape.

Also ships pure helpers isCooldownElapsed + shouldAutoEvict so the Phase
2.b breaker service (separate file, deferred) can drive transitions
deterministically without owning the clock.

27 new tests pin: legal/illegal edges, no-op invariant on duplicate
suspend, EVICTED terminality, props round-trip preservation, cooldown
boundaries, auto-evict grace > cooldown.

Phase 2.b (breaker service wiring cost-tracker → suspend; outbound
short-circuit on !isActive) and Phase 4 (federation_breaker_status MCP +
doctor surface) remain deferred per ADR-097 status table.

Co-Authored-By: RuFlo <ruv@ruv.net>

* feat(federation): ADR-097 Phase 2.b — breaker service + outbound short-circuit

Two-layer breaker shipping the active-defense half of the circuit breaker:

1. **evaluatePolicy** (pure) — given rolling samples + node + policy + now,
   returns SUSPEND/EVICT/REACTIVATE_ELIGIBLE/NONE. Cost-takes-priority
   over failure-ratio when both trip (cost is the harder-to-fake signal).
   Auto-evict supersedes reactivate-eligible from SUSPENDED.

2. **FederationBreakerService** — owns per-peer rolling buffer (bounded,
   default 100k samples), wraps evaluatePolicy with entity mutation,
   exposes recordOutcome/evaluate/tick/snapshot/forget. Crucially does
   NOT auto-reactivate — the breaker only signals eligibility; the
   integrator's health probe owns the reactivate() call. This avoids the
   "false-positive recovery" failure mode where a peer flaps SUSPENDED↔
   ACTIVE on transient telemetry.

Coordinator wiring: sendMessage now short-circuits on !peer.isActive
BEFORE session lookup, with PEER_SUSPENDED / PEER_EVICTED constant-string
errors (no remaining-budget echo per the Phase 1 anti-oracle posture).

Security invariants pinned by 25 new tests:
- Negative tokens/usd clamped to 0 (no credit-inflation attack)
- Failure ratio gated on minSamples=10 (anti-noise)
- Strict > on threshold comparisons (boundary correctness)
- EVICTED peers always return NONE (terminal under breaker flow)
- apply=false dry-run preserves entity state

Phase 4 (federation_breaker_status MCP + ruflo doctor surface) is now
unblocked: snapshot() + node.stateRecord give it everything it needs.
Phase 3 upstream (federation_spend bus emission) remains separate work
— when it lands, the bus subscriber calls recordOutcome() and the
in-memory buffer becomes a transparent cache. No breaker code changes.

Witness manifest: 87 fixes (was 85), Phase2b + Phase2b-coordinator
markers signed.

Co-Authored-By: RuFlo <ruv@ruv.net>

* chore(release): @claude-flow/plugin-agent-federation@1.0.0-alpha.6

Ships ADR-097 Phase 2.a (peer state machine) + Phase 2.b (breaker service
+ outbound short-circuit). Published to npm dist-tag alpha; latest is
held at alpha.5 until the breaker bakes for one /loop cycle.

Verified: tarball contains dist/application/federation-breaker-service.js,
dist/domain/value-objects/federation-node-state.js, and PEER_EVICTED
short-circuit in dist/application/federation-coordinator.js. All three
witness markers present in the published artifact.

Co-Authored-By: RuFlo <ruv@ruv.net>

* feat(federation): ADR-097 Phase 4 — operator surface (3 MCP tools + doctor)

Closes the loop on the breaker so operators can see and operate it:

**3 new MCP tools** (mcp-tools.ts):
- federation_breaker_status — per-peer state snapshot + bucket counts;
  optional nodeId filter
- federation_evict — operator-initiated MANUAL_EVICT with audit log +
  session-termination side effect; idempotent (already-evicted returns
  false but still emits audit for operator visibility)
- federation_reactivate — escape hatch from SUSPENDED (after probe) or
  EVICTED (operator override)

**Coordinator additions** (federation-coordinator.ts):
- getPeerStates() — projects stateRecord over discovery.listPeers()
- getPeerStateCounts() — O(peers) bucket count for status lines
- evictPeer() / reactivatePeer() — async wrappers that audit-log every
  outcome (applied:true/false in metadata)

**Doctor health-check** (cli/doctor.ts):
- checkFederationBreaker — probes plugin loadability + asserts
  FederationNodeState export presence (catches version-skew where users
  have a pre-Phase-2 plugin installed). Optional plugin → "not
  installed" is a pass, not a fail. Selectable as `--component federation`.

**Plugin index exports** — FederationNodeState + breaker primitives so
external integrators (and the doctor probe) can detect Phase 2/4
support.

11 new coordinator tests pin: bucket-count math, audit emission on
both success/no-op evict paths, EVICTED → ACTIVE operator override,
unknown-peer no-op (no audit silence). 60 plugin-level tests updated
to assert the 12-tool count.

Witness markers added: ADR-097-Phase4-mcp + ADR-097-Phase4-doctor.
89 signed fixes in the manifest (was 87).

Published @claude-flow/plugin-agent-federation@1.0.0-alpha.7 to npm
alpha tag. Tarball validated: all 7 markers (Phase 2.a + 2.b + 4)
present in dist/.

ADR-097 status: Phases 1, 2.a, 2.b, 3-consumer, and 4 all Implemented.
Only Phase 3 upstream (federation_spend bus emission) remains
deferred — when it lands, the breaker's in-memory rolling buffer
becomes a transparent cache with zero breaker code changes.

Co-Authored-By: RuFlo <ruv@ruv.net>

* feat(federation): ADR-097 Phase 3 upstream — closes ADR-097 100%

The last remaining "Deferred" entry from ADR-097's status table. With
this, the breaker has end-to-end telemetry: integrators call
coordinator.reportSpend() after each downstream agent completes, and
the event fans out to TWO subscribers in parallel:

1. SpendReporter — pushes to whatever durable backend the integrator
   wires (cost-tracker memory namespace, Datadog, accounting DB). The
   event shape matches the cost-tracker consumer in
   plugins/ruflo-cost-tracker/scripts/federation.mjs verbatim:
     { peerId, taskId, tokensUsed, usdSpent, ts, success }

2. FederationBreakerService.recordOutcome — feeds the breaker's
   in-memory rolling buffer so cost/failure-ratio thresholds fire
   correctly. Without this fan-out, integrators would have to wire two
   parallel pipelines for the same telemetry.

Both integrations are constructor-optional. A coordinator with neither
still accepts reportSpend() calls (silent no-op) so callers don't
branch on configuration.

**New files:**
- application/spend-reporter.ts — SpendReporter interface,
  FederationSpendEvent type, InMemorySpendReporter reference impl
- __tests__/unit/coordinator-spend-reporting.test.ts — 10 tests
  pinning fan-out, contract shape, ts auto-fill, throw-bubble

**New MCP tool:** federation_report_spend (13 tools total)

**Coordinator constructor:** added optional 9th arg
`integrations: { spendReporter?, breakerService? }` — backward-compat
since omission preserves the existing 8-arg shape.

**Anti-coupling design:** federation doesn't own model pricing; the
caller owns it. reportSpend() is an explicit POST-completion call,
not auto-emitted from federation_send. Future enhancement could
auto-emit a "predicted cost" placeholder, but deferred until a
concrete request justifies the additional state.

**Witness:** 90 signed fixes (was 89). Phase 3 upstream marker added.

**Published:** @claude-flow/plugin-agent-federation@1.0.0-alpha.8 to
npm alpha tag. Tarball validated — all 8 markers (Phase 2.a + 2.b +
3-upstream + 4) present.

ADR-097 status: ALL FIVE PHASES IMPLEMENTED (1, 2.a, 2.b, 3-consumer
+ 3-upstream, 4). Future work is integrator-side (production
SpendReporter adapter that persists to ruflo memory; auto-emit
predicted-cost placeholder).

Co-Authored-By: RuFlo <ruv@ruv.net>

* chore(release): umbrella alpha.20 — ships ADR-097 Phase 4 doctor surface

Bumps + publishes the 3-package umbrella chain:
- @claude-flow/cli@3.7.0-alpha.20
- claude-flow@3.7.0-alpha.20
- ruflo@3.7.0-alpha.20

All three on alpha + latest + v3alpha tags per CLAUDE.md publishing
rules. Static check confirms `checkFederationBreaker` ships in the
published @claude-flow/cli@alpha dist (3 occurrences in
package/dist/src/commands/doctor.js).

Now visible to users:
- `npx ruflo@latest doctor --component federation` reports plugin
  loadability + version-skew detection (catches users running an
  older pre-Phase-2 federation plugin against the new MCP tools)

Federation plugin already at @claude-flow/plugin-agent-federation
@1.0.0-alpha.8 (published earlier this session) — installs via
`npm install @claude-flow/plugin-agent-federation@alpha`.

Witness manifest re-signed with new release versions; 90 fixes still
verified. No new fix entries since this is a release-only bump (the
underlying code already shipped in the prior 3 commits today).

Co-Authored-By: RuFlo <ruv@ruv.net>

* docs(adr): ADR-104 — federation wire transport (agentic-flow loader, WS today, QUIC roadmap)

Documents the transport selection for ADR-097 federation. Smoke test
mac↔ruvultra over tailscale revealed agentic-flow's published QUIC is
API-only — loadWasmModule returns {}, encode/decode are placeholders,
crates/agentic-flow-quic/src/wasm.rs explicitly notes the WASM build is
a stub since browsers can't do raw UDP.

Decision: adopt the loadQuicTransport() loader pattern with WebSocket
fallback. Same AgentTransport interface today (WS) and tomorrow (native
QUIC when it lands — set AGENTIC_FLOW_QUIC_NATIVE=1). Federation code
stays transport-agnostic.

Validated: real mac→ruvultra:9101 over tailscale, 125ms send latency
(real I/O, not the prior 0ms stub).

Upstream contribution:
- ruvnet/agentic-flow#153 — adds quic-loader.ts to the published inner
  package, with WebSocketServer-backed real fallback
- Patched build published as agentic-flow@2.0.12-fix.1 (`fix` dist-tag)
  so federation can consume the working transport without waiting for
  upstream merge

12h verification routine updated with Check 8 (ADR-104 transport smoke):
- Pulls agentic-flow@fix
- Stands up real WebSocketServer + client send round-trip on localhost
- Asserts real send latency (>0, <5000ms) — catches if a future regression
  re-introduces the no-op stub behavior
- SEVERITY:HIGH if stub regression detected

Re-open this ADR when:
- ruvnet/agentic-flow ships native QUIC binding
- Federation traffic exceeds ~100 RPS to any single peer
- Federation peers cross outside tailnet boundary
- We have measured WS-vs-QUIC switch data

Co-Authored-By: RuFlo <ruv@ruv.net>

* feat(federation): wire ADR-104 transport — real mac↔ruvultra peering

ADR-104 documented the design (agentic-flow loader pattern, WebSocket
fallback today, native QUIC roadmap). This commit wires it into the
plugin so federation_send actually moves bytes on the wire.

Changes to src/plugin.ts:
- New dep: agentic-flow@2.0.12-fix.2 (PR ruvnet/agentic-flow#153 patched
  build that fixes the published QUIC stub)
- initialize() loads transport via loadQuicTransport(), registers it as
  federation:transport service
- If config.port is set, transport.listen(port, host='0.0.0.0') binds an
  inbound listener (peers that only initiate outbound leave port unset)
- sendToNode callback now resolves peer.endpoint via discovery.getPeer,
  strips the protocol prefix (ws://, tailscale://), and calls
  transport.send(address, AgentMessage{id, type, payload, metadata})
- shutdown() closes the transport (terminates outbound + inbound clients,
  awaits server close cb)
- Graceful degrade: if transport load fails, plugin logs a warn and
  continues with the legacy in-process noop (preserves backward compat
  for tests/sandboxes without ws available)

Validated end-to-end on real hardware:
  ruvultra (linux/x64): plugin booted, listening on 0.0.0.0:9101
  mac (darwin/arm64):   plugin booted, sent envelope in 150ms over tailscale

Witness manifest: 91 fixes (was 90), ADR-104-transport marker added.

Service-count assertion bumped 7 → 8 (added federation:transport).
All 450 federation tests still pass (transport load is async + safe in
the mocked test PluginContext).

Published: @claude-flow/plugin-agent-federation@1.0.0-alpha.9 to npm
alpha tag. Tarball validated: dist/plugin.js contains 'loadQuicTransport'
+ 'agentic-flow/transport/loader' import + 'federation:transport'
service registration + dep agentic-flow@2.0.12-fix.2.

Co-Authored-By: RuFlo <ruv@ruv.net>

* fix(deps): regenerate pnpm-lock — was out of sync since alpha.9 (agentic-flow dep adds)

* chore: bump agentic-flow dep to 2.0.12-fix.8 to match lockfile (CI fix)

---------

Co-authored-by: Reuven <cohen@ruv-mac-mini.local>
2026-05-09 21:55:49 -04:00

173 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ADR-097: Federation-wide Budget Circuit Breaker and Token Quotas
**Status**: Accepted — Partially Implemented (Phase 1 + Phase 3 consumer-side landed; Phases 2 and 4 deferred)
**Date**: 2026-05-04 · **Updated**: 2026-05-09
**Version**: Phase 1 shipped in `@claude-flow/plugin-agent-federation` (v3.6.x); Phase 3 consumer in `ruflo-cost-tracker@0.14.0`
**Supersedes**: nothing
**Related**: ADR-086 (Agent Federation), ADR-095 (Architectural gaps — G2 includes federation transport), issues #1723 and #1724 (#1724 closed as duplicate of #1723), commit `6f495369` (G2 Ed25519 signing in federation)
## Context
Agent Federation (ADR-086) lets agents on one Ruflo node delegate tasks to peer nodes across trust boundaries. The implementation in `@claude-flow/plugin-agent-federation` already covers identity (Ed25519 keypairs), trust scoring, and `federation_send` for cross-node delegation. What it does **not** cover today:
- **Recursive delegation loops**. Node A → Node B → Node A → … If a peer wraps a received task and delegates it back, there is no hop counter to break the cycle. A pathological multi-node ring can run until process memory or the network gives out.
- **Cost cascades**. A 200-token task delegated to a peer can spawn a sub-swarm of five worker agents on the remote, each potentially calling expensive frontier models. The originator never sees the bill until the cost-tracker reconciles after the fact.
- **No backpressure on hostile / faulty peers**. A misbehaving peer that returns expensive responses cannot be muted automatically — the local trust score drifts but there's no mechanism that says "this peer is now too expensive, stop sending it work."
- **No unified cost surface**. The `ruflo-cost-tracker` plugin tracks local agent token usage but federation traffic is invisible to it.
The original issue (#1723, #1724 dup) frames this as a stability + enterprise-readiness gap. The fix is a **federated circuit breaker** layered on top of the existing federation protocol — no breaking changes to the wire format, just additive metadata.
### Existing surface to build on
| Component | Path | Today |
|---|---|---|
| Federation MCP tool | `v3/@claude-flow/plugin-agent-federation/src/mcp-tools.ts` (`federation_send`) | Sends a task to a peer; no cost / hop awareness |
| Federation node entity | `v3/@claude-flow/plugin-agent-federation/src/domain/entities/federation-node.ts` | `trustScore`, `trustLevel`, `lastSeen`; no `state: SUSPENDED` |
| Cost tracker plugin | `plugins/ruflo-cost-tracker/` | Tracks local model spend per agent / per session |
| Behavioral trust | ADR-086 §"Trust scoring" | Adjusts on protocol misbehavior; no cost-based decay yet |
## Decision
Ship four cohesive but independently-shippable parts. Each part is one iteration; the parts compose into the full circuit breaker.
### Part 1 — Budget header on `federation_send`
Extend the `federation_send` MCP tool input schema with three optional metadata fields:
```ts
{
// existing
peerId: string,
taskId: string,
payload: unknown,
// new (all optional, defaults preserve current behavior)
budget?: {
maxTokens?: number, // hard cap on Σ tokens across the whole hop chain
maxUsd?: number, // hard cap on Σ USD spend; enforced via cost-tracker
},
maxHops?: number, // default 8; 0 = no remote delegation allowed
}
```
These travel inside the federation envelope as a `budget` block alongside the payload. The receiving peer:
1. **Decrements `maxHops`** on receive. If it drops below 0, returns an error response (`HOP_LIMIT_EXCEEDED`) without invoking any agent. The originator gets the failure synchronously.
2. **Subtracts from the budget** as it spends. Each completed sub-task reports `tokensUsed` and `usdSpent`; the running total is checked against the cap before each subsequent action. Overshoot returns `BUDGET_EXCEEDED` and refuses further work.
3. **Propagates the remaining budget** if the peer itself federates onward. The downstream call inherits whatever budget remains, never more.
Budget defaults: when omitted, treat as `Infinity` for `maxTokens` / `maxUsd` and `8` for `maxHops`. The default hop limit alone closes the recursion-loop class without any caller change.
### Part 2 — Peer state machine: ACTIVE / SUSPENDED / EVICTED
Extend `federation-node.ts` with a `state` field driven by the breaker:
| State | When transitioned | What it means |
|---|---|---|
| `ACTIVE` | default; healthy | `federation_send` accepts deliveries to this peer |
| `SUSPENDED` | breaker tripped (cost threshold or repeated failures) | sends to this peer return `PEER_SUSPENDED` immediately; receives still accepted but ignored for trust accumulation |
| `EVICTED` | manual or post-grace-period escalation from SUSPENDED | peer removed from registry; new delivery errors are emitted as `PEER_EVICTED` |
Transition triggers:
- `ACTIVE → SUSPENDED` when **either**:
- Trailing 24h cost from this peer > configured threshold (`peer.costSuspensionUsd`, default `$5.00`)
- Trailing 1h failure ratio > 50% across ≥10 sends
- `SUSPENDED → ACTIVE` after a configurable cooldown (default 30 min) AND a successful health probe
- `SUSPENDED → EVICTED` after 24h continuous suspension OR explicit `federation_evict` MCP call
Cooldown + auto-recovery prevents the breaker from being a one-way door — same shape as a typical hystrix-style breaker.
### Part 3 — Cost-tracker integration
Wire the federation layer into `ruflo-cost-tracker` so federated spend appears in the same dashboards as local spend:
1. New event type `federation_spend` published to the cost-tracker bus on every `federation_send` completion: `{ peerId, taskId, tokensUsed, usdSpent, ts }`.
2. Cost-tracker aggregates per-peer rolling windows (1h / 24h / 7d) and exposes them via the existing `cost-report` skill.
3. The breaker queries cost-tracker for the per-peer 24h sum to evaluate the suspension threshold.
This is one direction (federation → cost-tracker). Cost-tracker doesn't need to mutate federation state directly; the breaker pulls.
### Part 4 — Doctor + observability
- `ruflo doctor` reports the current state of every known peer (`ACTIVE`/`SUSPENDED`/`EVICTED`) and the trailing-24h cost. A peer pinned in `SUSPENDED` for >1h shows up as a yellow warning.
- New MCP tool `federation_breaker_status` returns the same info programmatically for swarm coordinators that want to route around suspended peers.
- Structured log line on every state transition with `{prevState, newState, reason, peerId}` so post-incident triage doesn't need a debugger.
## Implementation outline
Per the user directive, the implementation team owns this section. Each phase is one iteration to keep blast radius bounded:
| Phase | Scope | Lands in |
|---|---|---|
| P1 | Budget envelope + hop counter, no peer state changes | federation plugin + new tests |
| P2 | Peer state machine (`ACTIVE`/`SUSPENDED`/`EVICTED`) + transition rules | federation-node.ts + tests |
| P3 | Cost-tracker bus event + per-peer rolling aggregation | cost-tracker plugin |
| P4 | Doctor + `federation_breaker_status` MCP tool | doctor command + mcp-tools |
Test surface:
- **Unit**: budget arithmetic, hop counter underflow, state-transition table (ACTIVE→SUSPENDED→ACTIVE→EVICTED).
- **Integration**: a synthetic peer that intentionally exceeds budget should produce `BUDGET_EXCEEDED` on the originator AND transition to `SUSPENDED` after the threshold.
- **Property**: random hop chains must always terminate at `maxHops` ≤ 8.
## Trade-offs
| Decision | Alternative | Why this |
|---|---|---|
| Optional budget fields, default `Infinity` | Required budget on every `federation_send` | Backward compatible: existing code paths keep working. Callers opt in to limits. |
| Hop counter default `8` | Default unlimited | `8` is generous for legitimate multi-hop flows (typical ≤3) and cheap insurance against the recursion-loop class. |
| Peer state in federation plugin (not cost-tracker) | Centralized in cost-tracker | Federation owns peer identity + trust; the breaker is a federation concern. Cost-tracker provides numbers; federation decides actions. |
| Trailing-24h $5 default suspension threshold | Per-peer config only | Sane default avoids "gun, foot" for new installs. Override via plugin config. |
| `EVICTED` separate from `SUSPENDED` | Single `INACTIVE` state | Eviction is operationally permanent (admin removes the peer); suspension is a soft, auto-recovering state. They have different audit/log/recovery semantics. |
| Pull from cost-tracker for breaker decisions | Push spend events into federation | Federation already speaks events to cost-tracker; reverse direction would couple in both ways. Pull-on-decision is simpler and just-in-time. |
## Risks
1. **Race between budget check and remote spend**. The receiving peer checks budget *before* invoking the agent, but the agent's actual spend is reported *after*. A budget can be modestly exceeded by the last-call's actual cost > predicted cost. Mitigation: enforce on the *predicted* cost (max_tokens × per-token-rate), then refund unused on the running total. Worst case overshoot is one model call's worth.
2. **Clock skew across peers**. The 24h rolling window is computed locally per-peer, so divergent clocks don't change the threshold semantics. Trust-score timestamps (`lastSeen`) already rely on local clocks; same threat model.
3. **Suspended peer recovery storm**. If many peers transition `SUSPENDED → ACTIVE` simultaneously after cooldown, they could all retry pending sends at once. Mitigation: jitter the cooldown by ±10% per peer.
4. **Cost-tracker plugin not installed**. If the user runs federation without cost-tracker, USD-based breaker rules degrade to "always permitted." Token-count breaker still works (federation tracks tokens itself). Document this — cost-tracker is the recommended pair, not strictly required.
## Implementation status (2026-05-09)
**ADR-097 is functionally complete end-to-end.** All five phases are landed: Phase 1 (budget envelope), Phase 2.a (state machine entity), Phase 2.b (breaker service + outbound short-circuit), Phase 3 (consumer-side cost aggregation + upstream `reportSpend` emission with `SpendReporter` interface and breaker fan-out), Phase 4 (operator surface — `federation_breaker_status` / `federation_evict` / `federation_reactivate` MCP tools + `ruflo doctor --component federation`). Integrators wire a `SpendReporter` implementation to push to their cost-tracker / Datadog / accounting backend; the included `InMemorySpendReporter` covers tests + reference impl.
| Phase / Component | Status | Files | Commit(s) |
|---|---|---|---|
| **Phase 1** — Budget envelope + hop counter on `federation_send` | Implemented | `v3/@claude-flow/plugin-agent-federation/src/domain/value-objects/federation-budget.ts` (new), `mcp-tools.ts` updated | `7e1cc06df feat(federation): ADR-097 Phase 1 — budget envelope + hop counter (#1723)` |
| **Phase 2.a** — Peer state machine value object + entity transitions | Implemented | `domain/value-objects/federation-node-state.ts` (new), `domain/entities/federation-node.ts` (state field + suspend/evict/reactivate), `__tests__/unit/federation-node-state.test.ts` (27 tests) | `feat/adr-100-promote-097-phase2` |
| **Phase 2.b** — Breaker service + outbound short-circuit | Implemented | `application/federation-breaker-service.ts` (new — pure `evaluatePolicy` + stateful `FederationBreakerService` with bounded per-peer buffer), `application/federation-coordinator.ts` (sendMessage gates on `!peer.isActive` with `PEER_SUSPENDED`/`PEER_EVICTED` constant errors), `__tests__/unit/federation-breaker-service.test.ts` (25 tests) | `feat/adr-100-promote-097-phase2` |
| **Phase 3 consumer** — Cost-tracker bus event + per-peer rolling aggregation | Implemented | `plugins/ruflo-cost-tracker/scripts/federation.mjs`, `skills/cost-federation/SKILL.md` | `1c0804315 feat(cost-tracker): P6 — ADR-097 Phase 3 federation_spend consumer (v0.14.0)` |
| **Phase 3 upstream** — Federation `reportSpend()` + `SpendReporter` interface + breaker fan-out + `federation_report_spend` MCP tool | Implemented | `application/spend-reporter.ts` (new — interface + `InMemorySpendReporter` reference impl), `application/federation-coordinator.ts` (new optional `spendReporter` + `breakerService` constructor integrations + `reportSpend` method), `mcp-tools.ts` (federation_report_spend), `__tests__/unit/coordinator-spend-reporting.test.ts` (10 tests) | `feat/adr-100-promote-097-phase2` |
| **Phase 3 plugin wiring** — federation plugin adopts budget integration + ADR-097 doc | Implemented | `plugins/ruflo-federation/` (v0.2.0), `docs/adrs/0001-federation-contract.md` | `b0168e4a5 feat(ruflo-federation): adopt plugin contract — 3-gate alignment + ADR-097 budget integration + smoke` |
| **Phase 4** — Operator surface: 3 MCP tools + doctor health-check | Implemented | `mcp-tools.ts` (federation_breaker_status / federation_evict / federation_reactivate), `application/federation-coordinator.ts` (getPeerStates / getPeerStateCounts / evictPeer / reactivatePeer), `v3/@claude-flow/cli/src/commands/doctor.ts` (checkFederationBreaker), `__tests__/unit/federation-coordinator-breaker.test.ts` (11 tests) | `feat/adr-100-promote-097-phase2` |
### Open questions resolved during implementation
| Original question | Resolution |
|---|---|
| Is upstream `federation_spend` event emission required for Phase 3 consumer? | No — the consumer (`scripts/federation.mjs`) activates the moment the upstream emitter lands; it reads from the `federation-spend` namespace. Until Phase 2/3 emit events, the consumer runs against an empty namespace and reports zero spend. |
| Does Phase 1's default `maxHops=8` close the recursion-loop class for all callers? | Yes — hop enforcement fires even when no explicit budget is passed (`validateBudget(undefined)` returns a default-bounded budget). |
### Deferred
_All ADR-097 phases are landed. Future work is integrator-side wiring:_
- **Production `SpendReporter` adapter** that persists to ruflo memory (`namespace=federation-spend`, `key=fed-spend-<peerId>-<ts>`) per the cost-tracker consumer convention. Reference implementation: `InMemorySpendReporter` (in-memory buffer, fine for tests, NOT for production). A real adapter is a thin shell around `memory_store` — left to the integrator since they own the credentials/quota policy.
- **Auto-emission from `federation_send`** — currently `reportSpend()` is an explicit caller-side call (correct since the federation layer doesn't own model pricing). A future enhancement could auto-emit a "send-completed" event with the budget enforcement's predicted cost as a placeholder, but the integrator overrides with actual cost on completion. Decision deferred until a concrete integration request justifies the additional state.
---
## Acceptance criteria
The full implementation is done when:
- `federation_send` accepts and propagates `budget` + `maxHops` without breaking any existing test
- A synthetic recursive delegation chain (peer A → B → A → …) terminates at `maxHops` with `HOP_LIMIT_EXCEEDED` on the originator
- A peer that exceeds the cost threshold over 24h transitions to `SUSPENDED`, refuses sends for the cooldown window, and auto-recovers on a successful probe
- `ruflo doctor` shows peer states + trailing-24h cost
- `federation_breaker_status` MCP tool returns structured state per peer
- Cost-tracker `cost-report` shows federated spend grouped by peer
- New tests cover budget arithmetic, hop counter, state transitions, and cost-tracker bus events
- Full vitest suite stays green; no regressions in the 1917-test baseline