carlos / platform Public

docs: final-review fix wave — honest replay/heal prose, XFF amendment, rollout gates

Five doc/comment-only corrections from the whole-branch final review:

- homemap.go replay paragraph: a captured Hop-Auth DOES buy XFF belief
  for that host within its window; the real defense is that capturing
  one requires breaking the hop's TLS or holding the mesh key outright.
- staleHopRefusal doc: the real heal path is the edges-file cycle
  (5min+jitter publish + 30s mirror), not a 2s serial converge;
  Retry-After: 2 is a keep-trying hint, not a heal estimate.
- spec Part 3: dated amendment — Task 3 shipped verbatim-restore of the
  inbound X-Forwarded-For chain, not append.
- plan rollout note 8: add the missing pre-regions-apply scan for bucket
  records whose Region mismatches the deployment's single region.
- plan rollout notes: new top item recording the final review's two
  MUST-FIX-BEFORE-STAGE-5 gates — seed the edges-file *last from the
  bucket at loop arm, and add visibility for the two silent stuck-state
  paths before steering sends sustained traffic.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 3aea65cceca6be7a1e70611b6eaa8f69b789d1f8 parent e0ebd88
3 files changed, +80 −5
  • docs/superpowers/plans/2026-08-22-multi-region-backhaul.md +58 −0
  • docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md +6 −0
  • internal/edge/homemap.go +16 −5
diff --git a/docs/superpowers/plans/2026-08-22-multi-region-backhaul.md b/docs/superpowers/plans/2026-08-22-multi-region-backhaul.md
index 20c1ef82d..79e338ea9 100644
--- a/docs/superpowers/plans/2026-08-22-multi-region-backhaul.md
+++ b/docs/superpowers/plans/2026-08-22-multi-region-backhaul.md
@@ -477,6 +477,42 @@ armed by merging this branch — it is the checklist and the open-questions
list for the rollout that follows, including the stage-5/6 arming this
plan's own spec Part 4 describes.
+### Gates from the whole-branch final review (2026-08-22)
+
+The whole-branch final review (2026-08-22) marked two items
+MUST-FIX-BEFORE-STAGE-5. Recorded here as gates on the checklist below,
+not left to stand alone in the review record:
+
+a. **Seed the edges-file change-gate (`*last`) from the bucket at loop
+ arm.** `edgesFilePass`'s change-gate (`internal/host/edgesfile.go`,
+ Task 1) compares each pass's body against `*last`, but `*last` starts
+ nil at process start, in memory only. Two consequences follow from
+ that until it is seeded: a box that restarts during a transient
+ `readPublicIP()` miss (an IMDS blip) publishes `"eip":""` over its own
+ last-good file instead of falling back to it — the `eip == "" && *last
+ != nil` carry-forward this file already has only helps once `*last`
+ is populated, and right after a restart it never is — and separately,
+ every restart forces one gate-crossing write regardless of whether
+ anything about the box's routes actually changed, churning the
+ object's ETag on every box bounce. Seeding `*last` from the bucket's
+ current `control/edges/<box>.json` at loop arm (read-old-then-loop,
+ the same shape the loop already uses for its steady-state reads)
+ closes both. Land this as the FIRST commit of the stage-5 arming
+ branch, before anything else in the checklist below runs against a
+ real second box.
+b. **Visibility for the two stuck-state paths before steering sends
+ sustained traffic.** Both are silent today. `staleHopRefusal`
+ (`internal/edge/homemap.go:551`) answers a sender stuck dialing a home
+ box whose map has moved on with a correct 503, but nothing is LOGGED
+ on either end, so a sender looping into it during a stuck map
+ propagation produces zero signal. The backhaul 502 `ErrorHandler` is
+ unthrottled the other direction — one log line per failed dial, not a
+ throttled cadence (detail and cross-ref: item 14 below). Neither is a
+ correctness bug, but both need eyes before Part 4 (steering) can
+ legitimately point real, sustained traffic at this path — the
+ dormant/low-volume window this plan ships into is where to add the
+ logging, not after a stuck-move incident goes unseen in production.
+
### Stage-5 arming checklist (before flagship AU, before any real second box)
1. **Per-box edges files verified present for every armed box, BEFORE
@@ -642,6 +678,28 @@ plan's own spec Part 4 describes.
then eyeball each `addr` against `publiclyDialable`'s rule
(`net.ParseIP` non-nil, `IsGlobalUnicast()`, not private/loopback/
link-local) — any hit is a route to look at BEFORE arming, not after.
+
+ The same before-first-regions-apply window wants a second, adjacent
+ scan that the SQL above cannot reach: bucket **records**
+ (`apps/<sqid>/<app>/instances/<host>.json`, `Region` field —
+ `internal/provision/provision.go:40`) whose region does not match the
+ deployment's own single region. That mismatch is the only shape that
+ would make Task 5's widened everywhere-class adoption gate fire on the
+ very first roll, on a record no one has looked at since it was
+ written, since adoption reads the ROW's region, not the box's
+ assumption of it. Expect empty, same as the addr scan above, but
+ unverified — enumerate with a bucket-wide key scan in the spirit of
+ the bucket-writer audit's own enumeration method
+ (`docs/superpowers/specs/2026-08-20-bucket-writer-audit.md`):
+
+ ```bash
+ aws --profile <profile> s3 sync s3://<control-bucket>/apps/ /tmp/apps-scan/ \
+ --exclude "*" --include "*/instances/*.json"
+ grep -rl '"region"' /tmp/apps-scan | xargs -r grep -L '"region":"<deployment-region>"'
+ ```
+
+ Any file the second `grep` prints carries a region other than the
+ deployment's own — a record to look at BEFORE arming, not after.
9. **Edges-file `role: "home"` over-claims for static hosts — wants a
`"replica"` role before Part 4 leans on the map.** Task 5's own
concern (report §4.1): once static routes are everywhere-class, EVERY
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 fa911ee7c..19e15f475 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
@@ -253,6 +253,12 @@ forwarded headers stripped, normal serving rules. Timestamp skew bound
±30s (boxes run NTP). Key rotation: dual-accept window, old + new both
verify until every edge converges the new key.
+**Amendment (2026-08-22):** Task 3 shipped verbatim-restore, not append —
+on a verified hop the receiving edge replaces its own computed
+`X-Forwarded-For` with the sending edge's inbound chain unchanged (the
+last element stays the client); the sending edge's own address is never
+appended (`internal/edge/homemap.go`'s `setForwarded`).
+
**Hibernation** composes unchanged: the home edge owns wake (wake-once
singleflight, #199), and a backhaul hop is just another first client.
diff --git a/internal/edge/homemap.go b/internal/edge/homemap.go
index 14ead04e0..dec82865a 100644
--- a/internal/edge/homemap.go
+++ b/internal/edge/homemap.go
@@ -33,9 +33,15 @@ package edge
// captured X-Carlos-Hop-Auth is good for at most 2×hopSkew against ONE
// host, and what it buys a replayer is a request to a route the edge
// already serves to the whole internet — no new reachability, no
-// privilege. What it must NOT buy is the ability to state who the client
-// was, which is why verification gates exactly one thing: whether the
-// inbound X-Forwarded-For is believed.
+// privilege — PLUS, for that one host and that one window, XFF belief:
+// verification gates exactly one thing, whether the inbound
+// X-Forwarded-For is believed, and a valid MAC is what grants that
+// belief. What keeps the loose envelope affordable is not that XFF
+// belief is withheld — it is that capturing a valid Hop-Auth in the
+// first place requires breaking the hop's TLS or holding
+// control/edge-mesh-key outright, either of which already hands an
+// attacker strictly more than "who does this one host think the client
+// was for 2×hopSkew seconds."
//
// - XFF is trusted as an inbound chain to CARRY, never one to extend. On a
// verified hop the chain the sending edge built is passed through
@@ -538,8 +544,13 @@ func stripForwardedHeaders(r *http.Request) {
// staleHopRefusal answers a VERIFIED hop that named a host this box does not
// serve: the sending edge's home map is stale, mid-move. 503 + Retry-After: 2
-// is the same "come back shortly" answer a dark fleet gets (fleetProxy), and
-// converge heals it within a publish cycle.
+// is the same "come back shortly" answer a dark fleet gets (fleetProxy).
+// Retry-After: 2 is a keep-trying hint, not a heal estimate: there is no 2s
+// serial-converge path here (edges never bump control/serial), so what
+// actually heals a stale map is the edges-file cycle — the sending box's own
+// 5min+jitter publish loop (internal/host's edgesFileLoop) followed by this
+// box's 30s mirror poll — which can leave a sender retrying into this refusal
+// for minutes during a home move, not seconds.
//
// The body says nothing. It is reachable only by a party holding the mesh key
// — an unauthenticated stranger asking for the same host gets the ordinary,