plan: region HA 1b answers Astra round 2: claim passes obey the retry backoff, one discharge case per check
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+130
−9
docs/superpowers/plans/2026-10-06-region-ha-1b-claim-readers.md+74 −9docs/superpowers/plans/CODEX-PLAN-REGION-HA-1B-BRIEF-3.md+56 −0
CI cancelled — superseded by a newer push — run details
diff --git a/docs/superpowers/plans/2026-10-06-region-ha-1b-claim-readers.md b/docs/superpowers/plans/2026-10-06-region-ha-1b-claim-readers.md| index 821c32955..e8a5e668b 100644 |
| --- a/docs/superpowers/plans/2026-10-06-region-ha-1b-claim-readers.md |
| +++ b/docs/superpowers/plans/2026-10-06-region-ha-1b-claim-readers.md |
| @@ -1977,7 +1977,7 @@ Spec §5.9 under H19: regional workers record each enrolled box's block device m |
| - A `terminated` read is never recorded. Its mappings are empty (the dev test of 2026-10-06), and a record of it would list every volume as removed. |
| - "The first record taken at enrollment" is checked as: the earliest record for this enrollment must be observed within `EnrollmentWindow` (one hour, the refresh period) of the roster's enrollment time. A later first record cannot speak for a volume attached and removed before it. |
| - The newest record must be no more than `LastRecordWindow` (2 hours) older than `terminatedAt`, the fence's `observed_at` (controller ruling 1). A recorder that stopped long before the box died cannot speak for its last hours. |
| -- `Discharge` checks every stored record of this enrollment before using it (Astra plan R1-6): a version other than 1, a state that carries no mapping (`terminated`, or none), an empty mapping, or a volume with no id or device keeps the chores open. `List` returns whatever is stored, so a record `Write` would never produce is still met here. |
| +- `Discharge` checks every stored record of this enrollment before using it (Astra plan R1-6, R2-2): a version other than 1, a state that carries no mapping (`terminated`, or none), an empty mapping, or a volume with no id or no device keeps the chores open. The test has one stored case per check, each failing that check alone. `List` returns whatever is stored, so a record `Write` would never produce is still met here. |
| - `Discharge` recomputes removals from consecutive records as well as reading `removed_volumes`, so a record one worker failed to write cannot hide a removal. |
| Keys use a fixed-width UTC time with `-` in place of `:`, so they sort in observation order. |
| @@ -2222,6 +2222,10 @@ func TestDischargeRefusesStoredRecordsThatAreNotEvidence(t *testing.T) { |
| // After it, keeping vol-root, so nothing is removed. |
| "a volume with no id": {`{"v":1,` + after + `,"instance_state":"running","block_devices":[{"device":"/dev/xvda","volume_id":"vol-root","delete_on_termination":true,"status":"attached"},{"device":"/dev/xvdb","volume_id":"","delete_on_termination":true,"status":"attached"}],"removed_volumes":[]}`, 5 * time.Minute}, |
| "a version 2 record": {`{"v":2,` + after + `,"instance_state":"running","block_devices":[{"device":"/dev/xvda","volume_id":"vol-root","delete_on_termination":true,"status":"attached"}],"removed_volumes":[]}`, 5 * time.Minute}, |
| + // Astra plan R2-2: one case per check, each failing only that check. |
| + "a running read with no volume": {`{"v":1,` + before + `,"instance_state":"running","block_devices":[],"removed_volumes":[]}`, -5 * time.Minute}, |
| + "a terminated read with its root mapping": {`{"v":1,` + after + `,"instance_state":"terminated","block_devices":[{"device":"/dev/xvda","volume_id":"vol-root","delete_on_termination":true,"status":"attached"}],"removed_volumes":[]}`, 5 * time.Minute}, |
| + "a volume with no device": {`{"v":1,` + after + `,"instance_state":"running","block_devices":[{"device":"/dev/xvda","volume_id":"vol-root","delete_on_termination":true,"status":"attached"},{"device":"","volume_id":"vol-data","delete_on_termination":true,"status":"attached"}],"removed_volumes":[]}`, 5 * time.Minute}, |
| } |
| for name, tc := range cases { |
| t.Run(name, func(t *testing.T) { |
| @@ -2664,7 +2668,7 @@ Spec §5.4: "The edges-file pass publishes a host as `home` only when this box i |
| - **The decision.** A claim whose `op.exec` is this box's reference: home, stamped with `holder.stamp`. A claim whose `op.exec` names another box, or none, or which has no holder: withheld. No claim: home, unstamped, exactly as before; §8's gate admits a second box only once every host has a claim. |
| - **Errors.** A read that fails keeps the last decision (claim C6: an error is never absence); a host never read publishes as before. |
| - **Cost.** Each read stands 10 minutes (H9), at most 256 reads a pass, never-attempted hosts first, then oldest attempt first. A failed read counts as an attempt, so a claim that keeps failing goes to the back of the queue and cannot starve the rest (Astra plan R1-4). About 288 thousand GETs a day per 2,000 hosts, under $0.12. |
| -- **Refresh.** The registry timer alone runs a pass every five to six minutes, which at 256 reads a pass would take 40 minutes to cover 2,000 hosts (Astra plan R1-5). So, with the flag on, the loop also runs a pass every 30 seconds while any claim read is due (`edgesLoopState`). Every claim is then re-read within about 14 minutes; the edges file is still written only when its body changes. With the flag off, `edgesLoopState` is exactly `edgesFileSchedule`. |
| +- **Refresh.** The registry timer alone runs a pass every five to six minutes, which at 256 reads a pass would take 40 minutes to cover 2,000 hosts (Astra plan R1-5). So, with the flag on, the loop also runs a pass every 30 seconds while any claim read is due (`edgesLoopState`). Every claim is then re-read within about 14 minutes; the edges file is still written only when its body changes. A claim pass is a publication like any other (Astra plan R2-1): it waits out the schedule's retry backoff after a failed write, and its own outcome goes through `edgesFileSchedule.done`, so a bucket that refuses every write is asked on the existing 2-second-to-5-minute ladder, not every 30 seconds. With the flag off, `edgesLoopState` is exactly `edgesFileSchedule`. |
| - **Aliases and catch-alls carry their primary's stamp** (Astra plan R1-2). An attached domain and a served `*.<parent>` catch-all are dialled through the primary's installation. Unstamped, a dead predecessor's alias would tie with the successor's at 0 and the edge (`dialBeats`) would keep the box that sorts first. |
| - **§18's open question.** A positive stamp is the mark: only relocatable hosts with a claim carry one, so the edge's report of two boxes at one positive stamp already concerns them alone. No separate marker field. |
| @@ -2684,7 +2688,7 @@ The withholding and stamping are applied before owners and aliases are computed, |
| const edgesClaimPassEvery = 30 * time.Second |
| type edgesClaims struct{ /* self, read, now, entries, hosts */ } |
| func (c *edgesClaims) backlog(now time.Time) bool |
| - type edgesLoopState struct{ sched *edgesFileSchedule; claims *edgesClaims; claimsNext time.Time; schedDue bool } |
| + type edgesLoopState struct{ sched *edgesFileSchedule; claims *edgesClaims; claimsNext time.Time; schedDue, claimDue bool } |
| func (s *edgesLoopState) due(now time.Time, fp string, fpOK bool) bool |
| func (s *edgesLoopState) timed() bool |
| func (s *edgesLoopState) done(now time.Time, ok bool) |
| @@ -2982,6 +2986,51 @@ func TestEdgesLoopRefreshesEveryClaimWithinTheTTL(t *testing.T) { |
| t.Fatalf("a claim went %s without a read, want at most %s", worst, limit) |
| } |
| } |
| + |
| +// Astra plan R2-1: a claim pass is a publication. While the bucket refuses |
| +// every write, claim passes wait out the schedule's backoff and their own |
| +// failures move it; once writes succeed, the 30-second claim passes resume. |
| +func TestEdgesLoopClaimPassesObeyTheRetryBackoff(t *testing.T) { |
| + book := &claimsBook{claims: map[string]*hostclaim.Claim{}} |
| + var hosts []string |
| + for i := 0; i < 4000; i++ { // enough that reads stay due throughout |
| + h := fmt.Sprintf("h%04d.bab.test", i) |
| + book.claims[h] = serving("annie", 1) |
| + hosts = append(hosts, h) |
| + } |
| + start := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) |
| + clock := start |
| + claims := newEdgesClaims("annie", book.read) |
| + claims.now = func() time.Time { return clock } |
| + state := &edgesLoopState{sched: newEdgesFileSchedule(start, func() time.Duration { return 5*time.Minute + 30*time.Second }), claims: claims} |
| + failUntil := start.Add(20 * time.Minute) |
| + var during, after []time.Time |
| + for clock = start; clock.Before(failUntil.Add(8 * time.Minute)); clock = clock.Add(edgesFileWatchInterval) { |
| + if !state.due(clock, "fp", true) { |
| + continue |
| + } |
| + claims.decide(context.Background(), hosts) |
| + ok := !clock.Before(failUntil) |
| + if ok { |
| + after = append(after, clock) |
| + } else { |
| + during = append(during, clock) |
| + } |
| + state.done(clock, ok) |
| + } |
| + // Unthrottled, a claim pass every 30 seconds would be 40 attempts in |
| + // 20 minutes. The backoff (2 s doubling to 5 min) plus the timer allows |
| + // about a dozen. |
| + t.Logf("%d attempts while failing, %d after", len(during), len(after)) |
| + if len(during) > 16 { |
| + t.Fatalf("%d publication attempts while every write failed, want at most 16: %v", len(during), during) |
| + } |
| + // Recovery waits out the backoff (at most five minutes), then the |
| + // 30-second claim passes resume. |
| + if len(after) < 3 || after[len(after)-1].Sub(after[len(after)-2]) != edgesClaimPassEvery { |
| + t.Fatalf("passes after writes recovered = %v, want the 30-second claim passes back within eight minutes", after) |
| + } |
| +} |
| ``` |
| And create `internal/edge/homemap_alias_stamp_test.go`, which pins the edge's half: with stamped aliases, the successor's alias wins over a dead box's that sorts first. It passes on main already (`dialBeats` compares stamps after the alias rule); it is here so the reason for stamping aliases is tested where the decision is made. |
| @@ -3208,27 +3257,34 @@ func applyEdgesClaims(ctx context.Context, claims *edgesClaims, routes []registr |
| // edgesLoopState is edgesFileLoop's per-tick decision: the registry and |
| // timer schedule, plus, with region HA on, an extra pass every |
| -// edgesClaimPassEvery while claim reads are due. |
| +// edgesClaimPassEvery while claim reads are due. A claim pass is a |
| +// publication like any other: it waits out the schedule's retry backoff |
| +// after a failed write, and its own outcome moves that backoff, so a bucket |
| +// that refuses every write is not asked every 30 seconds (Astra plan R2-1). |
| type edgesLoopState struct { |
| sched *edgesFileSchedule |
| claims *edgesClaims |
| claimsNext time.Time |
| schedDue bool |
| + claimDue bool |
| } |
| // due reports whether to run a pass on this tick. |
| func (s *edgesLoopState) due(now time.Time, fp string, fpOK bool) bool { |
| s.schedDue = s.sched.due(now, fp, fpOK) |
| - claimPass := s.claims != nil && !now.Before(s.claimsNext) && s.claims.backlog(now) |
| - return s.schedDue || claimPass |
| + s.claimDue = s.claims != nil && !now.Before(s.claimsNext) && !now.Before(s.sched.retryAt) && s.claims.backlog(now) |
| + return s.schedDue || s.claimDue |
| } |
| // timed reports whether the pass due just allowed is the timer's. |
| func (s *edgesLoopState) timed() bool { return s.schedDue && s.sched.timed } |
| -// done records the pass's outcome. |
| +// done records the pass's outcome. Every pass, claim passes included, goes |
| +// through the schedule: a failure advances its backoff, and a success |
| +// clears it and records the fingerprint the pass published. A claim pass |
| +// is never the timer's (due cleared timed), so the timer does not move. |
| func (s *edgesLoopState) done(now time.Time, ok bool) { |
| - if s.schedDue { |
| + if s.schedDue || s.claimDue { |
| s.sched.done(now, ok) |
| } |
| if s.claims != nil { |
| @@ -3426,7 +3482,7 @@ Run: |
| go test ./internal/host/ -run 'EdgesFile|EdgesClaims|EdgesLoop' -count=1 -v |
| go test ./internal/edge/ -run 'TestHomeMap' -count=1 |
| ``` |
| -Expected: PASS: the eight new host tests (including `TestEdgesFileStampsAliasesAndCatchAllsWithTheirPrimary`, `TestEdgesClaimsAFailingClaimDoesNotStarveTheRest` and `TestEdgesLoopRefreshesEveryClaimWithinTheTTL`), every existing edges-file test, and the home map tests. The `internal/edge` run of `TestHomeMap` alone needs no socket. |
| +Expected: PASS: the nine new host tests (including `TestEdgesFileStampsAliasesAndCatchAllsWithTheirPrimary`, `TestEdgesClaimsAFailingClaimDoesNotStarveTheRest`, `TestEdgesLoopRefreshesEveryClaimWithinTheTTL` and `TestEdgesLoopClaimPassesObeyTheRetryBackoff`), every existing edges-file test, and the home map tests. The `internal/edge` run of `TestHomeMap` alone needs no socket. |
| - [ ] **Step 6: Gate and commit** |
| @@ -4230,3 +4286,12 @@ Findings are in `CODEX-PLAN-REGION-HA-1B-R1.md` (reviewed at 856767d8). All seve |
| | 5 | P2 Task 6: 256 reads a pass at the timer's 5 to 6 minutes cannot refresh 2,000 hosts in 10 minutes | fixed: `edgesLoopState` adds a pass every 30 seconds while claim reads are due (flag on only); `TestEdgesLoopRefreshesEveryClaimWithinTheTTL` simulates the loop's schedule for three hours with 2,000 hosts and requires every claim re-read within 15 minutes (killed with the extra passes off) | |
| | 6 | P2 Task 5: incomplete, non-recordable or unknown-version records can authorize discharge | fixed: `Discharge` validates every stored record of the enrollment (version 1, a state that carries mappings, a non-empty mapping, every volume with an id and device); anything else keeps the chores open. `TestDischargeRefusesStoredRecordsThatAreNotEvidence` writes each bad record through the store and lists it back, placed so that no removal masks it; each case killed by removing its check | |
| | 7 | P2 Task 8: the policy test checks resources only | fixed: each statement is compared whole (effect, principal, actions, condition, resources); five mutations (Deny to Allow, a dropped delete action, the wrong condition key, a narrower principal, a wider prefix) and main's policy each fail it | |
| + |
| +## Astra plan round 2: dispositions |
| + |
| +Findings are in `CODEX-PLAN-REGION-HA-1B-R2.md` (reviewed at 161728ab). Round 2 marked R1-1 to R1-4 and R1-7 resolved and R1-5 and R1-6 partly resolved, and raised two findings, both checked and accepted. Tasks 5 and 6 were dry-run again from the plan's text, with every task applied in order to a clean copy of bf813540, built, vetted, cross-built and tested; each check below was removed in turn and its test watched to fail. |
| + |
| +| # | Finding | Disposition | |
| +|---|---|---| |
| +| R2-1 | P2 Task 6: claim passes bypass the publication retry backoff | fixed: a claim pass is gated on the schedule's `retryAt`, and its outcome goes through `edgesFileSchedule.done` (a failure advances the backoff, a success clears it; the timer does not move). `TestEdgesLoopClaimPassesObeyTheRetryBackoff` runs the loop's schedule against a bucket that fails for 20 minutes then recovers: 12 attempts while failing (40 unthrottled), and the 30-second passes back after recovery. It fails with the `retryAt` gate removed, with claim outcomes not recorded, and with claim passes off | |
| +| R2-2 | P2 Task 5: three validation checks survive removal | fixed: three more stored cases, each failing exactly one check: `running` with no volume (empty mapping), `terminated` with its root mapping (state), a volume with an id but no device (device). Removing each of the five checks (state, empty mapping, volume id, device, version) fails its own case | |
diff --git a/docs/superpowers/plans/CODEX-PLAN-REGION-HA-1B-BRIEF-3.md b/docs/superpowers/plans/CODEX-PLAN-REGION-HA-1B-BRIEF-3.md| new file mode 100644 |
| index 000000000..33d2ed145 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/CODEX-PLAN-REGION-HA-1B-BRIEF-3.md |
| @@ -0,0 +1,56 @@ |
| +# Review brief: region HA plan 1b, round 3 |
| + |
| +You are an independent second reader. Review the implementation plan |
| +`docs/superpowers/plans/2026-10-06-region-ha-1b-claim-readers.md` at the |
| +commit named in the prompt, against the tree at the working directory |
| +(Go, module github.com/carlosframework/platform) and the approved spec |
| +`docs/superpowers/specs/2026-10-04-region-ha-box-placement-design.md` |
| +(rev 5.4; rulings H1–H19 in §0). Your round 2 findings are in |
| +`CODEX-PLAN-REGION-HA-1B-R2.md`; the author's answers are the table |
| +"Astra plan round 2: dispositions" at the end of the plan. |
| + |
| +## Fixed: do not re-argue |
| + |
| +- The spec's rulings H1 to H19, and Paul's approval of rev 5.4. |
| +- The scope rule: plan 1b builds only what needs none of the spec's |
| + L1–L20 claim/moves changes; everything under "Waits for the claim" is |
| + out of scope. |
| +- The controller rulings in the plan's header (six, unchanged). |
| +- Everything stays dormant behind CARLOS_REGION_HA and changes nothing on |
| + a one-box region. |
| +- Round 1 findings you marked resolved in round 2 (R1-1 to R1-4, R1-7), |
| + and round 1 and 2 findings the dispositions mark fixed, unless the fix |
| + itself is wrong; say which, and why. |
| + |
| +## What changed since round 2 |
| + |
| +- Task 6, `edgesLoopState` (in `internal/host/edgesclaims.go`): a claim |
| + pass now waits for the schedule's `retryAt`, and every pass, claim |
| + passes included, records its outcome through `edgesFileSchedule.done`. |
| + New test `TestEdgesLoopClaimPassesObeyTheRetryBackoff`. |
| +- Task 5, `TestDischargeRefusesStoredRecordsThatAreNotEvidence`: three new |
| + stored cases, one per check (empty mapping, terminated state with a |
| + mapping, a volume with no device). |
| + |
| +Nothing else changed. |
| + |
| +## What to check |
| + |
| +1. The round 2 table first: are R2-1 and R2-2 (and so R1-5 and R1-6) |
| + resolved, partly resolved (say what is left), or not resolved? |
| +2. Then only new findings, in the two changed places or anything they |
| + break. In particular: can recording a claim pass's success through |
| + `edgesFileSchedule.done` advance the change-detection baseline past a |
| + registry change that pass did not publish? Does the backoff gate |
| + delay a timer pass or a change pass that would not have been delayed |
| + before, with the flag off or on? |
| +3. As before: the code compiles, the tests pass as written, and each new |
| + test fails with its mechanism removed. |
| + |
| +## Output |
| + |
| +Findings ranked P1 (would not compile, a test fails, or unsafe), P2 |
| +(wrong or missing), P3 (clarity). Each: task and step, the problem, the |
| +evidence (file:line, or the plan's own line), and a concrete fix. Start |
| +with the round 2 table, then new findings only. End with a verdict: |
| +ready to build, or not ready. |