carlos / platform Public

host: per-box edges file — what this box homes, for the fleet's home map

Task 1 of the multi-region backhaul plan (spec 2026-08-20, Part 3):
each platform box now publishes control/edges/<box>.json — its EIP and
a host -> {role} map for every route it homes, change-gated (a
timestamp-only refresh writes nothing) and armed at the same site and
cadence as registrySnapshotLoop, sharing its box-label derivation
(metricsBoxLabel) rather than duplicating it. Nothing reads the file
yet — inert on today's single-box deployments until Task 3's mirror
ships.

Amends the spec's Part 3 sentence that claimed the file was
serial-bumped: it is change-gated and written by each box's own loop,
never touching control/serial; edges will mirror the prefix on a
30-second poll, matching Part 2's certs ruling.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 23a2b0c22c5ce91147c586544161ecc37f5b82c2 parent cea1911
4 files changed, +266 −5
  • docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md +11 −5
  • internal/host/agent.go +1 −0
  • internal/host/edgesfile.go +121 −0
  • internal/host/edgesfile_test.go +133 −0
diff --git a/docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md b/docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md
index 0cd27bb3a..95ba18281 100644
--- a/docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md
+++ b/docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md
@@ -182,11 +182,17 @@ window ~30 days out.
| Home-boxed | exec, unit, socket-activated, tunnel-backed | backhaul to the home edge. The process exists on exactly one box — unchanged invariant |
**Home resolution.** Each box maintains `control/edges/<box>.json`: its
-EIP and a map `host → {role}` for the routes it homes, written by its
-converge loop, serial-bumped on change. v1 writes only `role: "home"`.
-Every edge folds these files into a deployment-wide host→home map. The
-file doubles as the DR runbook's explicit statement of what a dead box
-was serving.
+EIP and a map `host → {role}` for the routes it homes. v1 writes only
+`role: "home"`. **Amendment (2026-08-22):** written by each box's own
+loop (the registry snapshot loop's arming site and cadence, 5min +
+jitter), change-gated — a same-content refresh with only a new
+timestamp writes nothing. Edge writes do NOT bump `control/serial` —
+the serial stays console-written per the writer map, matching Part 2's
+certs ruling; edges mirror the `control/edges/` prefix on their own
+30-second poll, the same cadence the certs mirror uses. Every edge
+folds these files into a deployment-wide host→home map. The file
+doubles as the DR runbook's explicit statement of what a dead box was
+serving.
**The hop.** A remote edge dials `home-EIP:443` with ordinary SNI — the
home edge presents the same bucket-distributed cert. Two headers make it
diff --git a/internal/host/agent.go b/internal/host/agent.go
index 59ac7428a..06f3b0fb7 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -321,6 +321,7 @@ func RunAgent(args []string) {
stopHousekeeper := startHousekeeper()
go retentionLoop(reg)
go registrySnapshotLoop(reg)
+ go edgesFileLoop(reg)
// The sweep reads the registry only through this closure — the stamped
// bespoke units to sample beside the carlos- ones, re-read each tick.
go metricsLoop(cgm.Root(), func() []string { return stampedUnitNames(reg) })
diff --git a/internal/host/edgesfile.go b/internal/host/edgesfile.go
new file mode 100644
index 000000000..47a9b7c56
--- /dev/null
+++ b/internal/host/edgesfile.go
@@ -0,0 +1,121 @@
+package host
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "log"
+ "math/rand/v2"
+ "os"
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// The multi-region ingress spec (2026-08-20, Part 3, closes the design half
+// of #238): every platform box publishes what it homes so any edge can fold
+// the fleet's per-box files into a deployment-wide host→home map. v1 writes
+// only `role: "home"` — the anticipated read-local role values are Task 3's
+// concern, not this file's.
+func edgesFileKey(box string) string { return "control/edges/" + box + ".json" }
+
+// edgeHost is the per-host entry in edgesFile.Hosts. An object, not a bare
+// string, is deliberately load-bearing: it is read-local anticipation —
+// Task 3's mirror and later roles (e.g. a "read" role) need somewhere to
+// grow into without a wire-shape break.
+type edgeHost struct {
+ Role string `json:"role"`
+}
+
+// edgesFile is the wire shape at edgesFileKey(box), schema v1 (spec Part 3):
+//
+// {"v":1,"box":"annie-welding","eip":"99.81.104.219","generation":"2026-08-22T09:00:00Z","hosts":{"app.example.com":{"role":"home"}}}
+type edgesFile struct {
+ V int `json:"v"`
+ Box string `json:"box"`
+ EIP string `json:"eip"`
+ Generation string `json:"generation"`
+ Hosts map[string]edgeHost `json:"hosts"`
+}
+
+// edgesFilePass builds this box's edges file from the routes currently in
+// reg (every row this box serves, ALL kinds — the serving-class split
+// belongs to the reader, Task 3, not to this writer) and publishes it at
+// edgesFileKey(box) when it changed.
+//
+// The change gate is the marshaled body WITH Generation cleared: Generation
+// is a fresh RFC3339 stamp every pass, so comparing the full body would
+// write on every tick even when the box's homed hosts and EIP are
+// identical — a timestamp-only refresh is not a change worth a bucket
+// write. *last holds the previous pass's gate body (also Generation-
+// cleared) and is updated only after a successful write, mirroring
+// registrySnapshotPass's *lastFP contract.
+//
+// eip may be "" (readPublicIP's "unknown" case, e.g. not on EC2 or IMDS
+// disabled) — the file is still published, with `"eip":""`, so the DR
+// runbook's hosts list never depends on IMDS working.
+func edgesFilePass(ctx context.Context, s release.Store, reg *registry.Registry, box, eip string, last *[]byte) error {
+ routes, err := reg.List()
+ if err != nil {
+ return fmt.Errorf("edges: list: %w", err)
+ }
+ hosts := make(map[string]edgeHost, len(routes))
+ for _, r := range routes {
+ hosts[r.Host] = edgeHost{Role: "home"}
+ }
+
+ f := edgesFile{V: 1, Box: box, EIP: eip, Hosts: hosts}
+ gate, err := json.Marshal(f)
+ if err != nil {
+ return fmt.Errorf("edges: marshal: %w", err)
+ }
+ if bytes.Equal(gate, *last) {
+ return nil
+ }
+
+ f.Generation = time.Now().UTC().Format(time.RFC3339)
+ body, err := json.Marshal(f)
+ if err != nil {
+ return fmt.Errorf("edges: marshal: %w", err)
+ }
+ if err := putReplacing(ctx, s, edgesFileKey(box), body); err != nil {
+ return err
+ }
+ *last = gate
+ return nil
+}
+
+// edgesFileLoop publishes this box's edges file every 5 minutes + jitter —
+// the same cadence and arming site as registrySnapshotLoop (home moves are
+// placement events, not per-request events; the backhaul hop's stale-map
+// 503 + converge heals faster than this loop could anyway). Platform-box
+// job, retentionLoop's rules: skipped on fleet boxes, disabled with a log
+// line when there is no deployment bucket. Single-box deployments: the file
+// is published and nothing reads it yet — inert until Task 3's mirror
+// ships.
+func edgesFileLoop(reg *registry.Registry) {
+ ctx := context.Background()
+ if shouldSkipPublisherElection(os.Getenv) {
+ log.Printf("agent: edges file loop is a platform-box job — skipped on a fleet box")
+ return
+ }
+ store, err := openDeployStore(ctx)
+ if err != nil {
+ log.Printf("agent: edges file loop disabled: %v", err)
+ return
+ }
+ box := metricsBoxLabel(os.Getenv, readMachineID())
+ log.Printf("agent: edges file loop armed (5min + jitter, %s)", edgesFileKey(box))
+ var last []byte
+ delay := time.Minute
+ for {
+ time.Sleep(delay)
+ delay = 5*time.Minute + rand.N(time.Minute)
+ eip := readPublicIP()
+ if err := edgesFilePass(ctx, store, reg, box, eip, &last); err != nil {
+ log.Printf("agent: edges file pass: %v", err)
+ }
+ }
+}
diff --git a/internal/host/edgesfile_test.go b/internal/host/edgesfile_test.go
new file mode 100644
index 000000000..172261c80
--- /dev/null
+++ b/internal/host/edgesfile_test.go
@@ -0,0 +1,133 @@
+package host
+
+import (
+ "context"
+ "encoding/json"
+ "path/filepath"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func edgesTestRegistry(t *testing.T) *registry.Registry {
+ t.Helper()
+ reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { reg.Close() })
+ return reg
+}
+
+// seedRoute upserts a minimal ready route under the given host — any served
+// kind does for these tests, since edgesFilePass folds every kind in
+// (the serving-class split is the reader's business, Task 3).
+func seedRoute(t *testing.T, reg *registry.Registry, host string) {
+ t.Helper()
+ if err := reg.Upsert(registry.Route{Host: host, Kind: "service", Addr: "10.0.0.1:3000", Status: "ready"}); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// putCountingStore wraps a release.Store and counts Put calls — the same
+// wrap-and-count idiom agent_test.go's countingStore uses for Get, applied
+// to Put so the change-gate test can pin "second identical pass writes
+// nothing" without inspecting store internals.
+type putCountingStore struct {
+ release.Store
+ puts int
+}
+
+func (p *putCountingStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
+ p.puts++
+ return p.Store.Put(ctx, key, body, ifMatch)
+}
+
+func TestEdgesFilePublishesHomedHosts(t *testing.T) {
+ s, reg := release.NewDirStore(t.TempDir()), edgesTestRegistry(t)
+ seedRoute(t, reg, "app.example.com")
+ var lastBody []byte
+ if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", &lastBody); err != nil {
+ t.Fatal(err)
+ }
+ got, _, err := s.Get(context.Background(), "control/edges/annie.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var f edgesFile
+ if err := json.Unmarshal(got, &f); err != nil {
+ t.Fatal(err)
+ }
+ if f.V != 1 || f.Box != "annie" || f.EIP != "1.2.3.4" {
+ t.Fatalf("file = %+v", f)
+ }
+ if r, ok := f.Hosts["app.example.com"]; !ok || r.Role != "home" {
+ t.Fatalf("hosts = %+v", f.Hosts)
+ }
+ if f.Generation == "" {
+ t.Fatal("generation must be stamped")
+ }
+ if got, want := edgesFileKey("annie"), "control/edges/annie.json"; got != want {
+ t.Fatalf("edgesFileKey = %q, want %q", got, want)
+ }
+}
+
+// A box with no discoverable public IP still publishes the file, with
+// "eip":"" — the DR runbook's hosts list must not depend on IMDS working.
+func TestEdgesFilePublishesWithEmptyEIP(t *testing.T) {
+ s, reg := release.NewDirStore(t.TempDir()), edgesTestRegistry(t)
+ seedRoute(t, reg, "app.example.com")
+ var lastBody []byte
+ if err := edgesFilePass(context.Background(), s, reg, "annie", "", &lastBody); err != nil {
+ t.Fatal(err)
+ }
+ got, _, err := s.Get(context.Background(), "control/edges/annie.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var f edgesFile
+ if err := json.Unmarshal(got, &f); err != nil {
+ t.Fatal(err)
+ }
+ if f.EIP != "" {
+ t.Fatalf("eip = %q, want empty", f.EIP)
+ }
+ if _, ok := f.Hosts["app.example.com"]; !ok {
+ t.Fatalf("hosts = %+v, want app.example.com present despite empty eip", f.Hosts)
+ }
+}
+
+func TestEdgesFileSkipsWriteWhenUnchanged(t *testing.T) {
+ real := release.NewDirStore(t.TempDir())
+ s := &putCountingStore{Store: real}
+ reg := edgesTestRegistry(t)
+ seedRoute(t, reg, "app.example.com")
+
+ var lastBody []byte
+ if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", &lastBody); err != nil {
+ t.Fatal(err)
+ }
+ if s.puts != 1 {
+ t.Fatalf("first pass puts = %d, want 1", s.puts)
+ }
+
+ // Second pass, identical registry state: a fresh generation timestamp
+ // would differ from the first pass's, but the change-gate excludes
+ // Generation, so this must not write.
+ if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", &lastBody); err != nil {
+ t.Fatal(err)
+ }
+ if s.puts != 1 {
+ t.Fatalf("unchanged second pass puts = %d, want still 1 (no write)", s.puts)
+ }
+
+ // A registry change makes the next pass write again.
+ seedRoute(t, reg, "second.example.com")
+ if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", &lastBody); err != nil {
+ t.Fatal(err)
+ }
+ if s.puts != 2 {
+ t.Fatalf("changed third pass puts = %d, want 2", s.puts)
+ }
+}