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

15 KiB
Raw Permalink Blame History

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:

{
  // 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