carlos / platform Public

host+edge: stage-5 gates — seeded edges gate, region field, stuck-state visibility

edgesFileLoop now seeds *last from the bucket at arm (seedEdgesFileGate,
mirroring seedSnapshotFingerprint's contract): a crash-looping agent no
longer rewrites an unchanged edges file on every restart, and a restart
that hits a transient IMDS blank on its very first tick no longer blanks a
known-good EIP (plan-2 final review, Important 1, both halves).

edgesFile gains an additive "region" field (omitempty, CARLOS_REGION
verbatim, read once at loop arm per the domains loop's precedent) for
Task 4's steering converger. It participates in the change gate like every
other field, so a seeded pre-region body and a fresh body with region
differ in bytes and write exactly once.

staleHopRefusal and the backhaul ErrorHandler were both silent; a stuck
home move can leave a sender retrying for minutes (edges never bump
control/serial). staleHopRefusal now logs first-and-every-tenth per
(box, host), following homemap.go's own partial-poll cadence; the backhaul
ErrorHandler routes through e.logThrottled keyed "backhaul-error "+box.

Also fixes edgesfile.go's loop doc, which claimed converge heals a stale
home map faster than this loop — there is no such path (edges never bump
control/serial); the real heal is this loop's 5min+jitter cycle plus the
mirror's 30s poll, the same correction already made in homemap.go's
staleHopRefusal doc.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 8bd84c66274e1b21703fbf1402e0e9bac3e473f9 parent d662f1b
5 files changed, +446 −29
  • internal/edge/edge.go +15 −6
  • internal/edge/homemap.go +42 −1
  • internal/edge/homemap_test.go +83 −0
  • internal/host/edgesfile.go +72 −12
  • internal/host/edgesfile_test.go +234 −10
diff --git a/internal/edge/edge.go b/internal/edge/edge.go
index bb69f7d43..31d8e8b14 100644
--- a/internal/edge/edge.go
+++ b/internal/edge/edge.go
@@ -832,7 +832,7 @@ func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// accepted hop physically cannot reach it. (e.backhaul opens with
// the same refusal anyway, so moving this branch cannot open a
// forwarding loop either — see its doc.)
- staleHopRefusal(w)
+ e.homes.Load().staleHopRefusal(w, host)
return
}
// Nothing here serves this host — but another box in the deployment
@@ -1807,11 +1807,11 @@ const (
// instead of being forwarded onward. A forwarding loop must take TWO mistakes,
// not one.
func (e *Edge) backhaul(w http.ResponseWriter, r *http.Request, host string) bool {
+ m := e.homes.Load()
if hopVerified(r.Context()) {
- staleHopRefusal(w)
+ m.staleHopRefusal(w, host)
return true
}
- m := e.homes.Load()
eip, box, ok := m.Home(host)
if !ok {
return false
@@ -1832,7 +1832,7 @@ func (e *Edge) backhaul(w http.ResponseWriter, r *http.Request, host string) boo
// that may have matched it — because the home edge verifies against its own
// literal request host (hopMAC canonicalizes both, mesh.go).
auth := signHop(key, host, time.Now())
- backhaulProxy(host, eip, box, auth, e.backhaulTransport(eip)).ServeHTTP(w, r)
+ backhaulProxy(host, eip, box, auth, e.backhaulTransport(eip), e.logThrottled).ServeHTTP(w, r)
return true
}
@@ -1935,7 +1935,16 @@ func (e *Edge) backhaulTransport(eip string) *http.Transport {
// it would be if the request had landed on the home box directly. (The restore
// branch cannot fire on THIS side: backhaul only runs when the request did not
// arrive as a verified hop.)
-func backhaulProxy(host, eip, box, auth string, tr http.RoundTripper) *httputil.ReverseProxy {
+//
+// logThrottled is e.logThrottled, threaded through rather than closed over
+// directly: this is a free function (built fresh per request, unlike the
+// cached backhaulTransport) so it takes no *Edge, and a dial failure is a
+// STANDING condition for as long as a home box is unreachable — an
+// unthrottled log.Printf here would fill the journal with one line per
+// failed request for the whole outage. Keyed "backhaul-error "+box (per home
+// box, not per host — a wildcard home behind one unreachable box would
+// otherwise mint a fresh throttle bucket per invented hostname).
+func backhaulProxy(host, eip, box, auth string, tr http.RoundTripper, logThrottled func(key, msg string)) *httputil.ReverseProxy {
return &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
setForwarded(pr)
@@ -1959,7 +1968,7 @@ func backhaulProxy(host, eip, box, auth string, tr http.RoundTripper) *httputil.
// against a cert the mirror has not distributed yet, or a home edge
// that hung up mid-response are all genuinely bad gateways — and the
// cert case heals itself on the next mirror poll.
- log.Printf("edge: backhaul %s → %s (%s): %v", host, box, eip, err)
+ logThrottled("backhaul-error "+box, fmt.Sprintf("backhaul %s → %s (%s): %v", host, box, eip, err))
w.WriteHeader(http.StatusBadGateway)
},
}
diff --git a/internal/edge/homemap.go b/internal/edge/homemap.go
index dec82865a..cea4be36a 100644
--- a/internal/edge/homemap.go
+++ b/internal/edge/homemap.go
@@ -172,6 +172,12 @@ type homeMap struct {
// partialPolls counts polls that could not read (or parse) every object,
// for the first-and-every-tenth log cadence.
partialPolls int
+ // staleHopN counts staleHopRefusal occurrences per "box host" key (box is
+ // always m.self — see staleHopRefusal's doc for why it is still named in
+ // the key and the log line), for the SAME first-and-every-tenth cadence
+ // partialPolls drives. Guarded by mu, following logPartialPoll's own
+ // choice to reuse the one lock rather than mint a second.
+ staleHopN map[string]int
// The mirrored mesh keys, read on the same refresh. Replaced only on a
// SUCCESSFUL load: a bucket blip leaves the last good key verifying hops,
@@ -559,9 +565,44 @@ func stripForwardedHeaders(r *http.Request) {
// line for the ONE case where it could still leak: a peer whose key is valid
// but whose map is wrong learns only "not here", never "here is where it
// went".
-func staleHopRefusal(w http.ResponseWriter) {
+//
+// Silent until now was leaving a home move that gets stuck mid-way with
+// nothing on this box's OWN journal to show it — a sender can retry into
+// this refusal for minutes (see above) and the only visibility was on the
+// SENDING side. This logs first-and-every-tenth per (box, host), the same
+// cadence logPartialPoll (above) uses for its own standing condition; box is
+// always m.self (there is exactly one homeMap per process, and it never
+// learns the sending edge's identity — the hop MAC covers only host and
+// time, mesh.go), named in the key and the line anyway so a centrally
+// aggregated journal reads as "box X refused host Y" rather than an
+// anonymous host with no owner.
+//
+// A nil receiver (no deployment store — the no-mesh-at-all case) still
+// answers the refusal; it just cannot count or log one, the same "answer
+// first, degrade the bookkeeping" shape Home and meshKeys already have.
+func (m *homeMap) staleHopRefusal(w http.ResponseWriter, host string) {
w.Header().Set("Retry-After", "2")
http.Error(w, "not served here", http.StatusServiceUnavailable)
+ if m == nil {
+ return
+ }
+ m.logStaleHop(host)
+}
+
+// logStaleHop is staleHopRefusal's counting half, split out so the HTTP
+// response is written before anything touches mu.
+func (m *homeMap) logStaleHop(host string) {
+ key := m.self + " " + host
+ m.mu.Lock()
+ if m.staleHopN == nil {
+ m.staleHopN = make(map[string]int)
+ }
+ m.staleHopN[key]++
+ n := m.staleHopN[key]
+ m.mu.Unlock()
+ if n == 1 || n%10 == 0 {
+ log.Printf("edge: stale hop refusal on box %s for %s (#%d) — the sending edge's home map has not caught up yet", m.self, host, n)
+ }
}
// setForwarded is what every proxy Rewrite in this package calls in place of
diff --git a/internal/edge/homemap_test.go b/internal/edge/homemap_test.go
index 687a85927..56b61245b 100644
--- a/internal/edge/homemap_test.go
+++ b/internal/edge/homemap_test.go
@@ -386,6 +386,42 @@ func TestVerifiedHopForAnUnservedHostRefusesWithoutForwarding(t *testing.T) {
}
}
+// Task 1: the refusal used to be silent. A home move stuck mid-way can leave
+// a sender retrying into this refusal for minutes (the loop cadences,
+// edgesfile.go/homemap.go's own docs), so it now logs — but first-and-
+// every-tenth, logPartialPoll's own cadence, or a stuck move would fill the
+// journal with one line per hop.
+func TestStaleHopRefusalLogsFirstAndEveryTenth(t *testing.T) {
+ e, m, key := hopEdge(t, "annie", "app.test")
+ putJSON(t, m.s, "control/edges/betty.json",
+ `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"moved.test":{"role":"home"}}}`)
+ refreshHomeMap(t, m)
+
+ buf := captureLog(t)
+ for i := 0; i < 10; i++ {
+ resp := hopRequest(t, e, "moved.test", map[string]string{
+ hopHeader: "1",
+ hopAuthHeader: signHop(key, "moved.test", time.Now()),
+ })
+ if resp.StatusCode != http.StatusServiceUnavailable {
+ t.Fatalf("refusal #%d: status = %d, want 503", i+1, resp.StatusCode)
+ }
+ }
+ got := buf.String()
+ if n := strings.Count(got, "moved.test"); n != 2 {
+ t.Fatalf("log mentions the stale host %d time(s), want exactly 2 (occurrence #1 and #10, not one line per refusal): %s", n, got)
+ }
+ if !strings.Contains(got, "(#1)") {
+ t.Fatalf("log output = %q, want the first occurrence logged immediately", got)
+ }
+ if !strings.Contains(got, "(#10)") {
+ t.Fatalf("log output = %q, want the tenth occurrence logged", got)
+ }
+ if !strings.Contains(got, "box annie") {
+ t.Fatalf("log output = %q, want this box's own label named in the line", got)
+ }
+}
+
// Verification failure is not an error path: it is "you are an ordinary
// client", with every forwarding claim you made discarded.
func TestUnverifiedHopIsServedAsAnOrdinaryClientWithForwardedHeadersStripped(t *testing.T) {
@@ -928,6 +964,53 @@ func TestBackhaulToAnUnreachableHomeIsA502(t *testing.T) {
}
}
+// Task 1: an unreachable home used to log unthrottled, once per failed dial —
+// a home box down for any real stretch would fill the journal with one line
+// per request. It now goes through e.logThrottled, keyed "backhaul-error
+// "+box, so a run of failures against the SAME home box produces one line
+// (occurrence #1), not one per failure.
+func TestBackhaulErrorHandlerRoutesThroughLogThrottled(t *testing.T) {
+ e, m, _ := hopEdge(t, "annie", "local.test")
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("listen: %v", err)
+ }
+ eip, port, err := net.SplitHostPort(ln.Addr().String())
+ if err != nil {
+ t.Fatalf("split: %v", err)
+ }
+ ln.Close()
+ old := backhaulPort
+ backhaulPort = port
+ t.Cleanup(func() { backhaulPort = old })
+ homedOn(t, m, "betty", eip, "example.com")
+
+ buf := captureLog(t)
+ for i := 0; i < 3; i++ {
+ resp := hopRequest(t, e, "example.com", nil)
+ if resp.StatusCode != http.StatusBadGateway {
+ t.Fatalf("attempt #%d: status = %d, want 502", i+1, resp.StatusCode)
+ }
+ }
+ got := buf.String()
+ // logThrottled's own format is "edge: <msg> (occurrence #N)" — it prints
+ // no key of its own, so the key's effect is observed as throttling: three
+ // failed dials against the SAME "backhaul-error "+box key must produce
+ // exactly one logged line (#1), not three.
+ if n := strings.Count(got, "backhaul"); n != 1 {
+ t.Fatalf("log mentions the backhaul failure %d time(s), want exactly 1 (logThrottled's first-and-every-300th, not one line per failed dial): %s", n, got)
+ }
+ if !strings.Contains(got, "occurrence #1") {
+ t.Fatalf("log output = %q, want the first occurrence logged (logThrottled's own format)", got)
+ }
+ if strings.Contains(got, "occurrence #2") || strings.Contains(got, "occurrence #3") {
+ t.Fatalf("log output = %q, want the second and third failures throttled, not logged again", got)
+ }
+ if !strings.Contains(got, "example.com") || !strings.Contains(got, "betty") {
+ t.Fatalf("log output = %q, want the host and the home box named", got)
+ }
+}
+
// One transport per home BOX. The pool is what makes a backhaul cost one
// handshake rather than one per request; keying it per box rather than per
// host is what keeps a catch-all home — where every invented subdomain is a
diff --git a/internal/host/edgesfile.go b/internal/host/edgesfile.go
index 4fa38a39d..fbe7d5523 100644
--- a/internal/host/edgesfile.go
+++ b/internal/host/edgesfile.go
@@ -8,6 +8,7 @@ import (
"log"
"math/rand/v2"
"os"
+ "strings"
"time"
"github.com/carlosframework/platform/internal/registry"
@@ -31,11 +32,18 @@ type edgeHost struct {
// 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"}}}
+// {"v":1,"box":"annie-welding","eip":"99.81.104.219","region":"eu-west-1","generation":"2026-08-22T09:00:00Z","hosts":{"app.example.com":{"role":"home"}}}
+//
+// Region is additive (Part 3, Task 4's steering converger needs it for
+// latency records): omitempty, value = CARLOS_REGION verbatim, absent
+// entirely when the env is unset — a pre-region body written by an older
+// build still parses, and this build's own body for an unset-region box is
+// byte-identical to what it always published.
type edgesFile struct {
V int `json:"v"`
Box string `json:"box"`
EIP string `json:"eip"`
+ Region string `json:"region,omitempty"`
Generation string `json:"generation"`
Hosts map[string]edgeHost `json:"hosts"`
}
@@ -63,7 +71,12 @@ type edgesFile struct {
// file's EIP instead. "" only ever reaches the wire for a box that has
// NEVER had a discoverable IP — the DR runbook's hosts list still gets
// published in that case, just without a dial address.
-func edgesFilePass(ctx context.Context, s release.Store, reg *registry.Registry, box, eip string, last *[]byte) error {
+//
+// region is CARLOS_REGION verbatim (read once at loop arm, the domains
+// loop's precedent — see edgesFileLoop) and is part of the change gate like
+// every other field except Generation: a region relabel is a real change and
+// must write, not be swallowed as a timestamp-only refresh would be.
+func edgesFilePass(ctx context.Context, s release.Store, reg *registry.Registry, box, eip, region string, last *[]byte) error {
routes, err := reg.List()
if err != nil {
return fmt.Errorf("edges: list: %w", err)
@@ -98,7 +111,7 @@ func edgesFilePass(ctx context.Context, s release.Store, reg *registry.Registry,
}
}
- f := edgesFile{V: 1, Box: box, EIP: eip, Hosts: hosts}
+ f := edgesFile{V: 1, Box: box, EIP: eip, Region: region, Hosts: hosts}
gate, err := json.Marshal(f)
if err != nil {
return fmt.Errorf("edges: marshal: %w", err)
@@ -119,14 +132,57 @@ func edgesFilePass(ctx context.Context, s release.Store, reg *registry.Registry,
return nil
}
+// seedEdgesFileGate reads this box's last-published edges file at loop arm
+// and returns the change-gate body (Generation cleared) to prime *last,
+// mirroring seedSnapshotFingerprint's contract exactly (snapshot_loop.go):
+// without this, a freshly started (or crash-looping) process's in-memory
+// *last starts empty, so edgesFilePass's own change gate — bytes.Equal(gate,
+// *last) — can never match on the first tick and the process republishes an
+// UNCHANGED file on every restart, burning an ETag/ifMatch round trip for
+// nothing (plan-2 final review, Important 1, first half).
+//
+// The same read also recovers the EIP hold for free: edgesFilePass's blank-
+// probe fallback reads *last's EIP when this tick's probe is "", and the
+// seeded gate still carries the prior file's EIP (only Generation is
+// cleared). Without this seed, a box that restarts and hits a transient
+// IMDS blip on its very first tick has a nil *last, the hold's `*last !=
+// nil` guard never fires, and the box would publish "" — blanking a known-
+// good EIP for every remote edge's dial address until the next successful
+// probe (the same review's Important 1, second half; the restart-blip
+// case). One seed read closes both.
+//
+// Any read or parse failure — no prior file yet, a corrupt object, a store
+// that is down — returns nil: the loop then behaves exactly as it always
+// has on a box's first-ever tick. This must never fail the loop.
+func seedEdgesFileGate(ctx context.Context, s release.Store, box string) []byte {
+ body, _, err := s.Get(ctx, edgesFileKey(box))
+ if err != nil {
+ return nil
+ }
+ var prior edgesFile
+ if err := json.Unmarshal(body, &prior); err != nil {
+ return nil
+ }
+ prior.Generation = ""
+ gate, err := json.Marshal(prior)
+ if err != nil {
+ return nil
+ }
+ return gate
+}
+
// 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.
+// the same cadence and arming site as registrySnapshotLoop. Home moves ARE
+// placement events, not per-request events, but the comment this replaces
+// overstated how fast that heals: there is no 2s serial-converge path for
+// this file (edges never bump control/serial — homemap.go's staleHopRefusal
+// doc makes the identical correction on the reader's side), so what
+// actually heals a stale home map is this loop's own 5min+jitter cycle
+// followed by each remote edge's 30s mirror poll (internal/edge's
+// homeMap) — minutes during a home move, not seconds. 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) {
@@ -139,14 +195,18 @@ func edgesFileLoop(reg *registry.Registry) {
return
}
box := metricsBoxLabel(os.Getenv, readMachineID())
+ // CARLOS_REGION, read once here at arm — the domains loop's read-once
+ // precedent (domainsLoop reads its env once before the loop and threads
+ // the result through every tick via cfg, never re-reading per pass).
+ region := strings.TrimSpace(os.Getenv("CARLOS_REGION"))
log.Printf("agent: edges file loop armed (5min + jitter, %s)", edgesFileKey(box))
- var last []byte
+ last := seedEdgesFileGate(ctx, store, box)
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 {
+ if err := edgesFilePass(ctx, store, reg, box, eip, region, &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
index 162db2870..43fd5a19c 100644
--- a/internal/host/edgesfile_test.go
+++ b/internal/host/edgesfile_test.go
@@ -49,7 +49,7 @@ 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 {
+ 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")
@@ -80,7 +80,7 @@ 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 {
+ if err := edgesFilePass(context.Background(), s, reg, "annie", "", "", &lastBody); err != nil {
t.Fatal(err)
}
got, _, err := s.Get(context.Background(), "control/edges/annie.json")
@@ -106,7 +106,7 @@ func TestEdgesFileSkipsWriteWhenUnchanged(t *testing.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 {
+ if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", "", &lastBody); err != nil {
t.Fatal(err)
}
if s.puts != 1 {
@@ -116,7 +116,7 @@ func TestEdgesFileSkipsWriteWhenUnchanged(t *testing.T) {
// 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 {
+ if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", "", &lastBody); err != nil {
t.Fatal(err)
}
if s.puts != 1 {
@@ -125,7 +125,7 @@ func TestEdgesFileSkipsWriteWhenUnchanged(t *testing.T) {
// 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 {
+ if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", "", &lastBody); err != nil {
t.Fatal(err)
}
if s.puts != 2 {
@@ -145,12 +145,12 @@ func TestEdgesFileHoldsLastKnownGoodEIPOnTransientBlank(t *testing.T) {
seedRoute(t, reg, "app.example.com")
var last []byte
- if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", &last); err != nil {
+ if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", "", &last); err != nil {
t.Fatal(err)
}
// Simulate a transient IMDS blip: this tick's probe returns "".
- if err := edgesFilePass(context.Background(), s, reg, "annie", "", &last); err != nil {
+ if err := edgesFilePass(context.Background(), s, reg, "annie", "", "", &last); err != nil {
t.Fatal(err)
}
@@ -180,7 +180,7 @@ func TestEdgesFileExcludesNonReadyRows(t *testing.T) {
}
var last []byte
- if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", &last); err != nil {
+ if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", "", &last); err != nil {
t.Fatal(err)
}
got, _, err := s.Get(context.Background(), "control/edges/annie.json")
@@ -213,7 +213,7 @@ func TestEdgesFileWildcardRowPublishesVerbatim(t *testing.T) {
}
var last []byte
- if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", &last); err != nil {
+ if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", "", &last); err != nil {
t.Fatal(err)
}
got, _, err := s.Get(context.Background(), "control/edges/annie.json")
@@ -244,7 +244,7 @@ func TestEdgesFileRawWireBodyMatchesSchema(t *testing.T) {
seedRoute(t, reg, "app.example.com")
var last []byte
- if err := edgesFilePass(context.Background(), s, reg, "annie-welding", "99.81.104.219", &last); err != nil {
+ if err := edgesFilePass(context.Background(), s, reg, "annie-welding", "99.81.104.219", "", &last); err != nil {
t.Fatal(err)
}
got, _, err := s.Get(context.Background(), "control/edges/annie-welding.json")
@@ -268,3 +268,227 @@ func TestEdgesFileRawWireBodyMatchesSchema(t *testing.T) {
t.Fatalf("raw wire body =\n%s\nwant\n%s", got, want)
}
}
+
+// FINDING 5 (task 1, 2026-08-22): region is additive to the pinned schema —
+// this extends FINDING 4's raw-body pin rather than replacing it. Two
+// bodies, byte for byte: CARLOS_REGION unset must produce the IDENTICAL
+// bytes FINDING 4 already pins (no "region" key at all, omitempty), and
+// CARLOS_REGION set must insert exactly `"region":"eu-west-1"` between
+// "eip" and "generation" — the struct field order — with nothing else
+// disturbed.
+func TestEdgesFileRawWireBodyRegionField(t *testing.T) {
+ s, reg := release.NewDirStore(t.TempDir()), edgesTestRegistry(t)
+ seedRoute(t, reg, "app.example.com")
+
+ genOf := func(t *testing.T, body []byte) string {
+ t.Helper()
+ var probe struct {
+ Generation string `json:"generation"`
+ }
+ if err := json.Unmarshal(body, &probe); err != nil {
+ t.Fatal(err)
+ }
+ return probe.Generation
+ }
+
+ t.Run("unset region is omitted entirely", func(t *testing.T) {
+ var last []byte
+ if err := edgesFilePass(context.Background(), s, reg, "annie-welding", "99.81.104.219", "", &last); err != nil {
+ t.Fatal(err)
+ }
+ got, _, err := s.Get(context.Background(), "control/edges/annie-welding.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ gen := genOf(t, got)
+ want := `{"v":1,"box":"annie-welding","eip":"99.81.104.219","generation":"` +
+ gen + `","hosts":{"app.example.com":{"role":"home"}}}`
+ if string(got) != want {
+ t.Fatalf("raw wire body (region unset) =\n%s\nwant\n%s", got, want)
+ }
+ })
+
+ t.Run("set region is inserted between eip and generation", func(t *testing.T) {
+ s2, reg2 := release.NewDirStore(t.TempDir()), edgesTestRegistry(t)
+ seedRoute(t, reg2, "app.example.com")
+ var last []byte
+ if err := edgesFilePass(context.Background(), s2, reg2, "annie-welding", "99.81.104.219", "eu-west-1", &last); err != nil {
+ t.Fatal(err)
+ }
+ got, _, err := s2.Get(context.Background(), "control/edges/annie-welding.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ gen := genOf(t, got)
+ want := `{"v":1,"box":"annie-welding","eip":"99.81.104.219","region":"eu-west-1","generation":"` +
+ gen + `","hosts":{"app.example.com":{"role":"home"}}}`
+ if string(got) != want {
+ t.Fatalf("raw wire body (region set) =\n%s\nwant\n%s", got, want)
+ }
+ })
+}
+
+// FINDING 6 (task 1, 2026-08-22, the plan-2 final review's Important 1): a
+// freshly ARMED loop must seed its change-gate from whatever this box last
+// published, or two bugs follow — see seedEdgesFileGate's doc. This test
+// pins the seed-then-pass contract directly: a process that primes its
+// *last from seedEdgesFileGate, then runs a pass against UNCHANGED registry
+// state, must write ZERO times. Before this task, edgesFileLoop always
+// started with a bare `var last []byte`, so the very first tick after any
+// restart always wrote — this is the regression that closes.
+func TestEdgesFileSeedGateSkipsWriteOnUnchangedBucketState(t *testing.T) {
+ real := release.NewDirStore(t.TempDir())
+ reg := edgesTestRegistry(t)
+ seedRoute(t, reg, "app.example.com")
+
+ // A prior process (or the prior pass of this one) already published.
+ var priming []byte
+ if err := edgesFilePass(context.Background(), real, reg, "annie", "1.2.3.4", "", &priming); err != nil {
+ t.Fatal(err)
+ }
+
+ // A fresh process arms: seed the gate from the bucket, as edgesFileLoop
+ // now does at loop arm.
+ last := seedEdgesFileGate(context.Background(), real, "annie")
+ if last == nil {
+ t.Fatal("seedEdgesFileGate returned nil, want the prior file's gate body")
+ }
+
+ counting := &putCountingStore{Store: real}
+ if err := edgesFilePass(context.Background(), counting, reg, "annie", "1.2.3.4", "", &last); err != nil {
+ t.Fatal(err)
+ }
+ if counting.puts != 0 {
+ t.Fatalf("puts = %d, want 0 — a seeded gate against unchanged state must not write", counting.puts)
+ }
+}
+
+// The restart-blip case named in the final review: a process restarts,
+// arms, and its very FIRST tick hits both a transient IMDS blank AND a real
+// registry change (so the pass must write). The write must still carry the
+// bucket's held EIP, not the blank probe — seedEdgesFileGate's dual
+// contract (gate AND EIP hold) has to survive an ACTUAL write, not just a
+// no-op pass, or the hold half of the fix is unverified.
+func TestEdgesFileSeedHoldsEIPThroughRestartBlip(t *testing.T) {
+ s, reg := release.NewDirStore(t.TempDir()), edgesTestRegistry(t)
+ seedRoute(t, reg, "app.example.com")
+
+ var priming []byte
+ if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", "", &priming); err != nil {
+ t.Fatal(err)
+ }
+
+ last := seedEdgesFileGate(context.Background(), s, "annie")
+ if last == nil {
+ t.Fatal("seedEdgesFileGate returned nil, want the prior file's gate body")
+ }
+
+ // This tick both hits a transient IMDS blip AND sees a real registry
+ // change, so the pass must write.
+ seedRoute(t, reg, "second.example.com")
+ if err := edgesFilePass(context.Background(), s, reg, "annie", "", "", &last); 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 != "1.2.3.4" {
+ t.Fatalf("eip after restart + blank probe = %q, want held %q", f.EIP, "1.2.3.4")
+ }
+ if _, ok := f.Hosts["second.example.com"]; !ok {
+ t.Fatalf("hosts = %+v, want second.example.com present — the write must carry the real registry change too", f.Hosts)
+ }
+}
+
+// Without the seed, a fresh *last is nil and edgesFilePass's own hold guard
+// (`*last != nil`) cannot fire — this pins that this really is the bug the
+// two tests above close, not a tautology of the hold logic that would pass
+// regardless of seeding.
+func TestEdgesFileUnseededLastPublishesBlankEIPOnFirstTickBlip(t *testing.T) {
+ s, reg := release.NewDirStore(t.TempDir()), edgesTestRegistry(t)
+ seedRoute(t, reg, "app.example.com")
+
+ var priming []byte
+ if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", "", &priming); err != nil {
+ t.Fatal(err)
+ }
+
+ // The pre-fix shape: a fresh process's *last starts nil (no seed read).
+ var unseeded []byte
+ if err := edgesFilePass(context.Background(), s, reg, "annie", "", "", &unseeded); 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 blank — demonstrating why the seed in the tests above is load-bearing, not incidental", f.EIP)
+ }
+}
+
+// Self-review: does the seed interact correctly with the change gate when
+// the bucket file PREDATES the region field? A box upgraded to this build
+// finds a bucket file an older build left with no "region" key at all.
+// Seeding *last from it must not treat this build's first pass — the one
+// that finally sets a real region — as unchanged: Region participates in
+// the change gate exactly like every other field (edgesFilePass's doc), so
+// a seeded pre-region gate and a fresh body WITH region differ in bytes and
+// must write exactly once, not zero and not on every tick after.
+func TestEdgesFileSeedPreRegionBucketWritesOnceWhenRegionArrives(t *testing.T) {
+ real := release.NewDirStore(t.TempDir())
+ reg := edgesTestRegistry(t)
+ seedRoute(t, reg, "app.example.com")
+
+ // Hand-written exactly as a pre-region build would have left it in the
+ // bucket: no "region" key present.
+ preRegion := []byte(`{"v":1,"box":"annie","eip":"1.2.3.4","generation":"2026-08-01T00:00:00Z","hosts":{"app.example.com":{"role":"home"}}}`)
+ if err := putReplacing(context.Background(), real, edgesFileKey("annie"), preRegion); err != nil {
+ t.Fatal(err)
+ }
+
+ last := seedEdgesFileGate(context.Background(), real, "annie")
+ if last == nil {
+ t.Fatal("seedEdgesFileGate returned nil")
+ }
+
+ // This build now has CARLOS_REGION set: the first pass must write, once.
+ counting := &putCountingStore{Store: real}
+ if err := edgesFilePass(context.Background(), counting, reg, "annie", "1.2.3.4", "eu-west-1", &last); err != nil {
+ t.Fatal(err)
+ }
+ if counting.puts != 1 {
+ t.Fatalf("puts = %d, want 1 — a region arriving on a seeded pre-region body is a real change", counting.puts)
+ }
+ got, _, err := real.Get(context.Background(), edgesFileKey("annie"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var f edgesFile
+ if err := json.Unmarshal(got, &f); err != nil {
+ t.Fatal(err)
+ }
+ if f.Region != "eu-west-1" {
+ t.Fatalf("region = %q, want eu-west-1", f.Region)
+ }
+
+ // A second, identical-region pass must not write again — the gate now
+ // includes region on both sides.
+ if err := edgesFilePass(context.Background(), counting, reg, "annie", "1.2.3.4", "eu-west-1", &last); err != nil {
+ t.Fatal(err)
+ }
+ if counting.puts != 1 {
+ t.Fatalf("puts after unchanged second pass = %d, want still 1", counting.puts)
+ }
+}