carlos / platform Public

usage: hop counters on EdgeRow, and a reserved backhaul_gb meter name

HopRequests/HopBytesOut are the SUBSET of Requests/BytesOut that arrived
over a verified backhaul hop — an annotation inside the totals, never an
addition to them, so a consumer that sums BytesOut has already counted
every backhauled byte and must never add the hop fields on top.

Additive with omitempty, per the wire-shape rule: day objects written
before these existed parse with both zero, objects written after them are
ignored field-for-field by an older binary, and a single-region
deployment — which never sees a hop — keeps writing byte-identical
objects. EdgeDay.V stays 1.

MeterBackhaulGB is reserved and wired into nothing: pricing backhauled
bytes is Paul's call, meter names are frozen once merged, and reserving
beats renaming.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev c51027e08383855be5734a12fcea2cd8aeb061bb parent 04dece9
3 files changed, +99 −0
  • internal/usage/edge.go +16 −0
  • internal/usage/edge_test.go +76 −0
  • internal/usage/usage.go +7 −0
diff --git a/internal/usage/edge.go b/internal/usage/edge.go
index af4ba8f5f..04f7bef1f 100644
--- a/internal/usage/edge.go
+++ b/internal/usage/edge.go
@@ -17,6 +17,20 @@ type EdgeRow struct {
App string `json:"app"`
Requests int64 `json:"requests"`
BytesOut int64 `json:"bytes_out"`
+ // HopRequests/HopBytesOut are the SUBSET of Requests/BytesOut that
+ // arrived over a verified backhaul hop (multi-region spec Part 3) — an
+ // annotation inside the totals, never an addition to them: a consumer
+ // that sums BytesOut already includes every hop byte, and must never add
+ // HopBytesOut on top. They exist so the platform can see which traffic
+ // crossed a region (its own egress is doubled by a hop) and so backhaul
+ // can be priced later without a shape change; nothing bills them today.
+ //
+ // Additive and omitempty: a day object written before these existed
+ // parses with both zero, one written after them is ignored field-for-
+ // field by an older binary, and a single-region deployment's objects
+ // stay byte-identical to what it wrote yesterday.
+ HopRequests int64 `json:"hop_requests,omitempty"`
+ HopBytesOut int64 `json:"hop_bytes_out,omitempty"`
}
// EdgeDay is usage/edge/<box>/days/<date>.json — a box's edge traffic for
@@ -59,6 +73,8 @@ func (d *EdgeDay) AddDeltas(hour string, deltas map[string]EdgeRow) {
row.Account, row.App = delta.Account, delta.App
row.Requests += delta.Requests
row.BytesOut += delta.BytesOut
+ row.HopRequests += delta.HopRequests
+ row.HopBytesOut += delta.HopBytesOut
rows[host] = row
}
}
diff --git a/internal/usage/edge_test.go b/internal/usage/edge_test.go
index 424f0c9e7..97f1a382f 100644
--- a/internal/usage/edge_test.go
+++ b/internal/usage/edge_test.go
@@ -69,6 +69,82 @@ func TestUpsertEdgeDayMergesAcrossFlushes(t *testing.T) {
}
}
+// The hop counters are an ADDITIVE shape change (the paid-for wire rule):
+// every day object already in a production bucket predates them, so one
+// written without them must still parse, fold and round-trip — and a
+// single-region box, which never sees a hop, must keep writing objects that
+// are byte-identical to what it wrote before the fields existed.
+func TestEdgeDayHopFieldsAdditive(t *testing.T) {
+ // A day object as a pre-ruling binary wrote it: no hop keys at all.
+ const old = `{"v":1,"box":"box1","date":"2026-08-24","hours":{"09":{"a.example.com":` +
+ `{"account":"bab","app":"hello","requests":10,"bytes_out":1000}}}}`
+ var day EdgeDay
+ if err := json.Unmarshal([]byte(old), &day); err != nil {
+ t.Fatalf("a day object written before the hop fields existed must still parse: %v", err)
+ }
+ row := day.Hours["09"]["a.example.com"]
+ if row.Requests != 10 || row.BytesOut != 1000 || row.HopRequests != 0 || row.HopBytesOut != 0 {
+ t.Fatalf("row = %+v, want the old counters and zero hop counters", row)
+ }
+ // And it folds a hop-bearing delta on top without losing the old counts.
+ day.AddDeltas("09", map[string]EdgeRow{
+ "a.example.com": {Account: "bab", App: "hello", Requests: 3, BytesOut: 300, HopRequests: 2, HopBytesOut: 250},
+ })
+ row = day.Hours["09"]["a.example.com"]
+ if row.Requests != 13 || row.BytesOut != 1300 || row.HopRequests != 2 || row.HopBytesOut != 250 {
+ t.Fatalf("folded row = %+v", row)
+ }
+
+ // Round-trip: what a hop-annotating box writes, a hop-annotating box reads.
+ raw, err := json.Marshal(day)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var back EdgeDay
+ if err := json.Unmarshal(raw, &back); err != nil {
+ t.Fatal(err)
+ }
+ if got := back.Hours["09"]["a.example.com"]; got != row {
+ t.Fatalf("round-tripped row = %+v, want %+v", got, row)
+ }
+
+ // omitempty, the single-region promise: a row with no hop traffic writes
+ // no hop keys, so a one-box deployment's objects do not change shape.
+ plain, err := json.Marshal(EdgeRow{Account: "bab", App: "hello", Requests: 1, BytesOut: 10})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if want := `{"account":"bab","app":"hello","requests":1,"bytes_out":10}`; string(plain) != want {
+ t.Fatalf("hop-free row marshals as %s, want %s — omitempty keeps single-region objects unchanged", plain, want)
+ }
+}
+
+// The fold accumulates the hop counters across flushes exactly as it does the
+// totals — they are counters in the same hour bucket, not a snapshot.
+func TestEdgeDayHourFoldSumsHopFields(t *testing.T) {
+ var day EdgeDay
+ delta := map[string]EdgeRow{
+ "a.example.com": {Account: "bab", App: "hello", Requests: 4, BytesOut: 400, HopRequests: 1, HopBytesOut: 100},
+ }
+ day.AddDeltas("09", delta)
+ day.AddDeltas("09", delta)
+ day.AddDeltas("10", delta)
+
+ if got := day.Hours["09"]["a.example.com"]; got.Requests != 8 || got.BytesOut != 800 ||
+ got.HopRequests != 2 || got.HopBytesOut != 200 {
+ t.Fatalf("hour 09 row = %+v, want two flushes summed", got)
+ }
+ if got := day.Hours["10"]["a.example.com"]; got.HopRequests != 1 || got.HopBytesOut != 100 {
+ t.Fatalf("hour 10 row = %+v, want the third flush in its own bucket", got)
+ }
+ // The subset invariant, stated where a future reader will meet it: hop
+ // bytes live INSIDE bytes_out, so a consumer summing BytesOut has already
+ // counted them.
+ if got := day.Hours["09"]["a.example.com"]; got.HopBytesOut > got.BytesOut || got.HopRequests > got.Requests {
+ t.Fatalf("row = %+v — the hop counters are a subset of the totals, never an addition", got)
+ }
+}
+
func TestUpsertEdgeDayEmptyDeltasIsNoop(t *testing.T) {
s := release.NewDirStore(t.TempDir())
if err := UpsertEdgeDay(context.Background(), s, "box1", time.Now().UTC(), nil); err != nil {
diff --git a/internal/usage/usage.go b/internal/usage/usage.go
index 65c586505..437949e54 100644
--- a/internal/usage/usage.go
+++ b/internal/usage/usage.go
@@ -23,6 +23,13 @@ const (
MeterInstanceHours = "instance_hours"
MeterFleetBoxHoursServer = "fleet_box_hours_server"
MeterFleetBoxHoursDevice = "fleet_box_hours_device"
+ // MeterBackhaulGB is RESERVED, not yet billed: the egress a request
+ // spent crossing a region on a backhaul hop (EdgeRow.HopBytesOut). The
+ // ruling that added the counters (2026-08-24) deliberately left the
+ // pricing to Paul and shipped them dormant — no statement, no rate-card
+ // line, bills byte-identical to before. The name is reserved here
+ // because meter names are frozen once merged: reserving beats renaming.
+ MeterBackhaulGB = "backhaul_gb"
)
// DayOf is the UTC date label used in every usage key.