Routing Case Study · March 2026

Path-vector routing: from 7.8x to <1.5x

Flood-and-dedup routing works at small scale. At 50 instances, each message generates 7.8 network operations per useful delivery. Path-vector cuts that to under 1.5x—matching the theoretical minimum for fault-tolerant delivery.


The problem

Campfire's original routing (v0.4.2) was flood-and-dedup: when a router received a message, it forwarded to every connected peer except the sender. Duplicates were dropped by an in-memory dedup table keyed on message ID. Simple, correct, and catastrophically expensive at scale.

The amplification factor on a network of N nodes with average degree D is O(D). At 50 instances with average degree 3, each message triggers ~7.8 forwarding operations per useful delivery. That's 7.8x amplification—and it grows as the network grows.

The dedup table doesn't help with amplification. It prevents infinite loops, but it can't prevent the initial burst. Every router still floods outward before its peers' dedup tables fire. The result is a wave of redundant sends that collapses only after traveling the diameter of the network.

An adversary sending M messages into a v0.4.2 network causes M × D × N network operations. At 50 instances and degree 3, that's 150x traffic amplification per message sent. Path-vector reduces this to M × N—the redundant wave disappears.

Network visualization — see the difference
Step 1 of 5 — Origin sends message

The design

Path-vector routing is the same mechanism BGP uses to prevent routing loops in the global Internet. Each route advertisement carries a list of the routers it has traversed. A router that sees its own ID in the path drops the advertisement. Loops are structurally impossible—not suppressed after the fact.

Applied to campfire, path-vector works at the beacon level. A routing:beacon carries a path field: an ordered list of router node IDs from origin to current node. When a router re-advertises a beacon, it appends its own ID. When a router sees its own ID in an incoming beacon's path, it drops the beacon.

The key insight: if beacons don't loop, routes don't loop, and messages following those routes don't loop. The forwarding problem reduces to "who is the next-hop for this campfire?"—a single-peer forward instead of a broadcast.

01 — Beacon

Paths in beacons

Every routing:beacon carries a path: []string of node IDs. Origin sends [self]. Each re-advertiser appends its ID. Path is covered by the inner signature—tampering invalidates the beacon.

02 — Table

Routing table with next-hop

Each route entry stores path and next_hop (the direct peer that delivered the beacon). Forwarding a message means: look up next-hop, send once. Multi-path stores multiple entries; best path wins by shortest length.

03 — Loop

Structural loop detection

On beacon receipt, check whether own node_id is in path. If yes, drop. O(path_length), bounded by max_hops. No coordination required. No dedup needed for loop prevention (dedup remains as defense-in-depth for transient duplicates).

04 — Peer

Peer-needs-set

Per campfire: the set of direct peers that need messages for it. Populated from beacon next-hops, message senders, and re-advertisers. Enables bidirectional flow without explicit subscribe messages.


Beacon paths

A beacon originates with path: [node_A]. As it propagates through the network, each router appends its own ID and re-signs before re-advertising.

BEACON PROPAGATION — campfire C on instance A
origin:    node_A path: [node_A]
hop 1:    node_A node_B path: [node_A, node_B]
hop 2:    node_A node_B node_C path: [node_A, node_B, node_C]
If node_B receives a beacon with path [node_A, node_B, node_C, ...] — node_B in path → DROP. Loop impossible.

The path field is included in the inner_signature computation—signed by the campfire's own key. A router cannot remove itself from the path or reorder entries without invalidating the signature. Path tampering is cryptographically blocked.

Threshold campfires. For campfires with threshold > 1, re-signing on every hop requires multi-party coordination. Instead, the path field is excluded from the inner_signature for threshold>1 campfires—the path is advisory, not cryptographically bound. Loop prevention falls back to dedup + max_hops. This is a deliberate trade-off: threshold campfires sacrifice path integrity for operational simplicity.

Backward compatibility

A beacon without a path field is valid. v0.5.0 routers treat missing path as an empty path (legacy beacon) and fall back to flood-and-dedup behavior for that route. v0.4.2 routers ignore the path field per the protocol's forward-compatibility rule (unknown fields are passed through). The path-vector amendment is fully backward-compatible with existing deployments.


Routing table

The v0.5.0 routing table extends the v0.4.2 entry with two new fields:

// RouteEntry — one entry per campfire_id per gateway
type RouteEntry struct {
    CampfireID    string
    Endpoint      string
    Transport     string
    Gateway       string
    Received      time.Time
    Verified      bool
    InnerTimestamp int64
    Path          []string  // NEW: node_ids from origin to advertiser
    NextHop       string    // NEW: direct peer that delivered the beacon
}

When the routing table has multiple entries for the same campfire (multi-path), route selection uses:

  1. Shortest path — fewer hops means fewer forwarding points, lower amplification, and faster delivery.
  2. Freshest inner timestamp — among equal-length paths, prefer the most recent advertisement.
  3. First received — tie-breaker: stability over churn.

Forwarding a message for campfire C: look up the best route's next_hop, send once. The campfire key signs a provenance hop. Done. No broadcast, no wave, no dedup race condition.


Loop detection

The BGP loop detection rule, directly applied: when a router receives a beacon, it checks whether its own node_id appears anywhere in the path field. If yes, this beacon has looped back—drop it.

// Loop check on beacon receipt (O(path_length), bounded by max_hops)
func (rt *RoutingTable) HasLoop(path []string, selfNodeID string) bool {
    for _, nodeID := range path {
        if nodeID == selfNodeID {
            return true
        }
    }
    return false
}

// On receipt:
if rt.HasLoop(beacon.Path, selfNodeID) {
    log.Debug("beacon loop detected, dropping", "campfire", beacon.CampfireID)
    return  // structural prevention, not dedup recovery
}

The loop check runs before the dedup check. A looping beacon is discarded without recording its ID in the dedup table—loop prevention is a routing-layer decision, not a dedup entry. The dedup table remains for transient duplicates during route convergence and for legacy nodes that don't carry path fields.

Defense-in-depth

Three layers of loop prevention, in order of precedence:

  1. Path-vector loop rejection (primary) — structural, fires on every beacon, O(path_length). Prevents loops before they form.
  2. Message ID dedup (secondary) — handles transient duplicates during route convergence, and legacy nodes without path fields.
  3. Max hops (tertiary) — bounds the damage radius of misconfigured topologies. Default: 8 hops. A message exceeding max_hops is dropped.

Peer-needs-set

Path-vector tells a router where to send messages destined for a campfire. But campfire traffic is bidirectional—messages also flow from a campfire toward its subscribers. The peer-needs-set handles the reverse direction.

Each router maintains a peer-needs-set per campfire: the set of direct peers that have demonstrated interest in messages for campfire C. A peer is added to the set when it:

The full forwarding set for campfire C is:

forwarding_set = (peer_needs_set[C] ∪ routing_next_hops[C]) - sender

This ensures traffic flows in both directions without requiring explicit subscribe messages. The peer-needs-set is populated as a side effect of normal operation—no additional protocol messages, no subscription handshake.


Amplification proof

The proof runs a 50-instance, 5-campfire simulation. Instances are connected in a ring with random skip links (average degree ~3). Each campfire has 3 members scattered across the ring.

Instances50
Campfires5
Members / campfire3
Topologyring + skip
Avg degree~3

Results

Verified on cf 0.30 (2026-05-14). TestAmplification on cf 0.30 dev (git v0.30.0-16-g0f470a08) measures path-vector at 1.00x amplification — well under the <1.5x design target. TestAmplificationBGP confirms 53% send reduction on a meshed topology (28.40x vs 60.25x flood). The original 7.8x figure is the spec’s design point at skip-ring topology (degree ~2 log N); other graph topologies produce different absolute flood numbers, but path-vector consistently outperforms flood by the same directional margin. Raw measurement results →

Mode
Network ops
Amplification
Improvement
Flood v0.4.2
36.25x measured
7.8x (avg)
Path-vector v0.30 verified
1.0x measured
<1.5x (avg)
>80%

Reading the numbers. The "measured" column is the worst-case send count from the simulation: flood generated 36.25x the sends of a single optimal delivery; path-vector achieved 1.0x (single-path, no redundant sends). The "avg" column is the expected amplification under typical multi-campfire load. The assert in the test suite: amplification < 1.5x—passes on cf 0.30.

Floodv0.4.2
7.8x
Path-vectorv0.30 verified
<1.5x
>80%
amplification reduction
1.0x
measured (single-path)
0
false deliveries
50
instances tested

Why <1.5x and not 1.0x?

Perfect 1.0x would mean every message takes exactly one network path with no redundant sends. The 1.5x ceiling allows for:

The 1.5x ceiling is the theoretical floor for fault-tolerant delivery in a ring topology. Degenerate single-path topologies achieve 1.0x; real deployments with redundancy land between 1.0x and 1.5x.


Implementation

Four changes to the campfire routing stack, shipped in a single swarm across 11 agents in 7 waves. All changes are backward-compatible.

1. BeaconDeclaration.Path

// Added to BeaconDeclaration in pkg/routing/beacon.go
type BeaconDeclaration struct {
    CampfireID        string   `json:"campfire_id"`
    Endpoint          string   `json:"endpoint"`
    // ... existing fields ...
    Path              []string `json:"path,omitempty"`     // NEW
    InnerSignature    string   `json:"inner_signature"`
}

// Signing input now includes path
func SignBeacon(b *BeaconDeclaration, key ed25519.PrivateKey) error {
    input := beaconSigningInput(b.CampfireID, b.Endpoint, b.Transport,
        b.Description, b.JoinProtocol, b.Timestamp,
        b.ConventionVersion, b.Path)  // path added
    // ...
}

2. RouteEntry Path and NextHop

// Extended in pkg/routing/table.go
type RouteEntry struct {
    // ... existing fields ...
    Path    []string `json:"path"`     // NEW: full path from origin
    NextHop string   `json:"next_hop"` // NEW: direct peer (first hop toward us)
}

// Route selection: shortest path first
func (rt *RoutingTable) BestRoute(campfireID string) *RouteEntry {
    entries := rt.entries[campfireID]
    sort.Slice(entries, func(i, j int) bool {
        if len(entries[i].Path) != len(entries[j].Path) {
            return len(entries[i].Path) < len(entries[j].Path)
        }
        return entries[i].InnerTimestamp > entries[j].InnerTimestamp
    })
    if len(entries) == 0 { return nil }
    return &entries[0]
}

3. Beacon re-advertisement

// In the router's beacon handler
func (r *Router) ReAdvertiseBeacon(b *BeaconDeclaration, fromPeer string) {
    // 1. Check for loop
    if hasLoop(b.Path, r.NodeID) {
        return
    }
    // 2. Append own node ID
    b2 := *b
    b2.Path = append(append([]string{}, b.Path...), r.NodeID)
    // 3. Re-sign (threshold=1; advisory for threshold>1)
    SignBeacon(&b2, r.CampfireKey)
    // 4. Advertise to own peers (except fromPeer)
    r.AdvertiseToPeers(&b2, fromPeer)
}

4. Peer-needs-set

// RoutingTable.peerNeeds tracks which peers need each campfire
peerNeeds map[string]map[string]struct{} // campfire_id -> {peer_id}

func (rt *RoutingTable) RecordPeerNeed(campfireID, peerID string) {
    if rt.peerNeeds[campfireID] == nil {
        rt.peerNeeds[campfireID] = make(map[string]struct{})
    }
    rt.peerNeeds[campfireID][peerID] = struct{}{}
}

// ForwardingSet combines next-hops and peer-needs-set
func (rt *RoutingTable) ForwardingSet(campfireID, exclude string) []string {
    seen := make(map[string]bool)
    var out []string
    for _, e := range rt.entries[campfireID] {
        if e.NextHop != "" && e.NextHop != exclude && !seen[e.NextHop] {
            seen[e.NextHop] = true
            out = append(out, e.NextHop)
        }
    }
    for peer := range rt.peerNeeds[campfireID] {
        if peer != exclude && !seen[peer] {
            seen[peer] = true
            out = append(out, peer)
        }
    }
    return out
}

Test coverage. 4 new tests for BeaconDeclaration.Path and signing, 7 new tests for RouteEntry extension and loop detection, 3 integration tests for re-advertisement and withdrawal propagation, 5 tests for peer-needs-set. Full suite: 22/22 passing on merge. Amplification assertion: assert amplification < 1.5 passes on the 50-instance simulation.

Run the proof

All tests are in the campfire repo. The amplification simulation is a single test function.

$ cd campfire
$ go test -v -run TestPathVectorAmplification ./pkg/routing/
$ go test -v -run TestLoopDetection ./pkg/routing/
$ go test -v -run TestPeerNeedsSet ./pkg/routing/
$ go test -v ./pkg/routing/  # full suite

Convention: Routing Convention v0.5.0