carlos / platform Public

plan: region HA 1b records the controller's rulings: a recent last volume record, no README copy until Paul reviews it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev be08cec0b9f8aa440e9bd79ef66a16a14c4e4312 parent 257438b
1 file changed, +86 −52
  • docs/superpowers/plans/2026-10-06-region-ha-1b-claim-readers.md +86 −52
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 0130922ca..1467803e8 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
@@ -10,6 +10,17 @@
**Spec:** `docs/superpowers/specs/2026-10-04-region-ha-box-placement-design.md`, rev 5.4 (approved by Paul 2026-10-06). Executors read §0 (rulings H1 to H19), §2, §5.1, §5.4, §5.9, §5.10, §6, §8, §9 (the L-table), §18 and §19 first. Plan 1a (`docs/superpowers/plans/2026-10-05-region-ha-1a-foundations.md`, merged as aadb860) built the pieces this plan corrects; read its "After the build" section.
+## Controller rulings, 2026-10-06
+
+Made overnight by the controller on Paul's behalf, on the six points the first draft of this plan left open. Each names what it costs if it is wrong.
+
+1. **Discharge also needs a recent last record.** The newest running-time volume record must be no more than 2 hours older than the termination observation (`boxvolumes.LastRecordWindow`); otherwise the dead box's `park-local` chores stay open for the operator path. Pinned by `TestDischargeNeedsARecentLastRecord` (Task 5). *Cost if wrong:* a recorder outage longer than two hours before a death sends that box's cleanup to an operator; nothing about recovery waits on it (H19).
+2. **§5.4 is followed literally.** The edges file withholds a relocatable host whose claim's `op.exec` names another box or none (Task 6). *Cost if wrong:* with the flag on in a one-box region, a host whose claim briefly names no executor (a move's cut, once moves exist) is missing from remote edges for that window and answers there as unknown; moves are not built, so nothing is affected today.
+3. **Root's refusal stays behind `CARLOS_REGION_HA` in 1b** (Task 7). It becomes unconditional with claim D6 (see "Waits for the claim"). *Cost if wrong:* until D6 lands nothing writes `.hostexec/`, so no update is wrongly allowed today; the risk is forgetting to lift the flag when D6 lands, which the waits table records.
+4. **The stricter enrollment refusal stands:** one instance is never enrolled under two references (Task 1). *Cost if wrong:* re-enrolling a machine under a new label needs the old entry handled by an operator first.
+5. **The bind statement and `operator_role_arns` stay** in `deployment-buckets` (Task 8). Retiring both is a follow-up once no consuming root sets the variable. *Cost if wrong:* one harmless Deny on a prefix nothing writes.
+6. **No README change in this plan.** The region-host README paragraph needs Paul's copy review, which waits for the morning; its proposed text is under "Copy waiting for Paul" at the end, and Task 8 does not touch the README. *Cost if wrong:* the module README keeps saying region HA depends on root volumes being deleted, which H19 made untrue, until the copy lands.
+
## Why this is plan 1b, and what it leaves out
The spec asks the host lifecycle claim and move-any-app sessions for twenty changes (§9, L1 to L20). Neither session has accepted them. So this plan builds only what needs none of them, and stops at every point where the next step would write a claim, add an op kind, depend on the roster (`control/host-actors.json`, claim D7, not on main), or change moves. The section "Waits for the claim", after the tasks, maps everything left to its L-number.
@@ -18,7 +29,7 @@ Three of the candidates the brief named were checked against the spec and main a
- **Reading the termination queue.** The spec gives the placer loop that reads the queue to spec 3 (§12), and an event read is only useful to a writer of fence records (L2 to L4). A reader that cannot write a fence could only leave messages on the queue or delete evidence. It also needs `service/sqs`, which is not a dependency.
- **The Auto Scaling membership confirmation (H16, H17).** It reads the target region's group ARN from the roster's `regions` entry (claim P5-D2's extension) and lists the roster's enrollments in that region. There is no roster and no Auto Scaling group yet (spec 4), and the console may accept the record only once L19 is accepted. It needs `service/autoscaling` too.
-- **IAM for those two.** Nothing in this plan calls SQS or Auto Scaling, and this repository grants nothing for later. `ec2:DescribeInstances`, the one call this plan's code makes, is already allowed in every region by the inventory statement (`infra/modules/region-host/main.tf:213`); Task 8 records why a narrower statement would add nothing.
+- **IAM for those two.** Nothing in this plan calls SQS or Auto Scaling, and this repository grants nothing for later. `ec2:DescribeInstances`, the one call this plan's code makes, is already allowed in every region by the inventory statement (`infra/modules/region-host/main.tf:213`): a second, narrower Allow could not take that away, and the code pins every describe to the box's enrolled region instead.
## Global Constraints
@@ -33,14 +44,14 @@ Three of the candidates the brief named were checked against the spec and main a
- **AWS is behind interfaces.** Every EC2 call goes through `termfence.EC2`; tests use fakes and never call AWS. IMDS is reached through an `*http.Client` whose transport tests replace, so no test opens a socket.
- Linux-only syscalls live in `*_linux.go` with a `!linux` stub. This plan adds none: `systemparticipation.go` uses `os` only. CI cross-compiles darwin, windows and linux/arm64.
- Tests use `release.NewDirStore(t.TempDir())`; inject failures with a struct embedding `release.Store` that overrides one method.
-- **Copy.** No console or CLI copy is added. Log lines are operator-facing and follow the surrounding `agent: …` / `system apply: …` style. Task 8's README paragraph is docs prose: before committing it, put it through Paul's copy review (the `copy-review` skill) and apply his edits verbatim. If any other task finds it needs user-facing copy, stop.
+- **Copy.** No console, CLI or docs copy is added (controller ruling 6). Log lines are operator-facing and follow the surrounding `agent: …` / `system apply: …` style. If any task finds it needs user-facing copy, stop: it waits for Paul's copy review.
- Commit messages: lowercase `area:` prefix and a sentence stating the behaviour, then a blank line and `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
- **Go environment.** Run Go with `GOCACHE=$TMPDIR/gocache GOFLAGS="-mod=mod -buildvcs=false"` (every command below assumes both are exported). Tests that open Unix sockets fail inside the Claude sandbox with `socket: operation not permitted`; on this branch's base those are, in `internal/host`, `TestSleepingInstanceRearmsItsNextDeliveryBeforeReturning`, `TestScheduledTickWakesASleepingInstanceAndItHibernatesAgain`, `TestDeliveryReusesTheConnectionForASocket`, `TestArmScheduleFiresOnceTimersWithNoDeployStore`, `TestProxyCacheIsStableAndBounded`, `TestDeliveryDoesNotHoldTheInstanceAwake`, `TestDeliveryStillCarriesScheduleAtHeader` and `TestSocketServingProbe`; in `internal/activator`, the `TestExecBackendStart…`, `TestStartUnit…`, `TestUnitProc…`, `TestSeedUnit…`, `TestStop…` and `TestPrepareRemovesAStaleSocketFile` tests; and `internal/edge` as a whole. That is the sandbox, not the change: run those packages with the sandbox off, or rely on CI. Scratch files go under `$TMPDIR`. Never use `git stash`, `reset --hard`, `checkout --` or `clean`.
- **OpenTofu.** `tofu test` starts the AWS provider over a local socket, so it needs the sandbox off, and `tofu init` needs network access to `registry.opentofu.org` and GitHub.
- Gate before each commit: `go build ./... && go vet ./<pkgs>/ && gofmt -l <dirs> && go test ./<pkgs>/ -run '<tests>' -count=1`, then `GOOS=darwin go build ./... && GOOS=windows go build ./...`. Full gate before the branch lands: `go build ./... && make test`, and the three cross-builds.
-- **Exact values.** Claim read cache on the edges file **10 minutes** (H9's sweep); at most **256** claim reads per edges pass; a volume record at least once an **hour** when nothing changes; the first volume record must be taken within **1 hour** of enrollment; capability **`region-ha-1`**; fence state **`terminated`** only; root's marker **`/opt/carlos/system/guard/update-begun.json`**; controller records **`<DataDir>/.hostexec/`**; volume records **`control/box-volumes/<enrollment>/<observed_at>.json`**.
+- **Exact values.** Claim read cache on the edges file **10 minutes** (H9's sweep); at most **256** claim reads per edges pass; a volume record at least once an **hour** when nothing changes; the first volume record must be taken within **1 hour** of enrollment; the newest within **2 hours** before the termination observation; capability **`region-ha-1`**; fence state **`terminated`** only; root's marker **`/opt/carlos/system/guard/update-begun.json`**; controller records **`<DataDir>/.hostexec/`**; volume records **`control/box-volumes/<enrollment>/<observed_at>.json`**.
- Work on a branch cut from `origin/main` (suggested name `region-ha-1b`). Merging is Paul's call, through `amadan branch merge`.
-- This plan's code was dry-run on 2026-10-06: every task was applied in order to a clean copy of `origin/main` (bf813540), built, vetted, cross-built for darwin, windows and linux/arm64, and its tests run; the five Review Focus tests and four others were each watched to fail under a mutation of the code they pin; Task 8's tofu test was run against the AWS provider's mock and watched to fail against main's policy. Then the code was removed. What the dry run could not do is listed at the end.
+- This plan's code was dry-run on 2026-10-06: every task was applied in order to a clean copy of `origin/main` (bf813540), built, vetted, cross-built for darwin, windows and linux/arm64, and its tests run; the five Review Focus tests and five others were each watched to fail under a mutation of the code they pin; Task 8's tofu test was run against the AWS provider's mock and watched to fail against main's policy. Then the code was removed. What the dry run could not do is listed at the end.
## Review Focus
@@ -66,7 +77,6 @@ Three of the candidates the brief named were checked against the spec and main a
| `internal/host/systemparticipation.go` | Root's update-begun marker and `.hostexec` check |
| `internal/host/systemapply.go` | Wiring them into the apply |
| `infra/modules/deployment-buckets/main.tf`, `tests/evidence.tftest.hcl` | Create-only, never-deleted evidence prefixes |
-| `infra/modules/region-host/README.md` | The disk and DescribeInstances notes, revised for H19 |
---
@@ -1959,12 +1969,14 @@ Spec §5.9 under H19: regional workers record each enrolled box's block device m
func Write(ctx context.Context, s release.Store, rec Record) (string, error)
func List(ctx context.Context, s release.Store, enrollment string) ([]Record, error)
type Verdict struct{ Discharge bool; Reason string }
- func Discharge(records []Record, e Entry, enrolledAt time.Time) Verdict
+ const LastRecordWindow = 2 * time.Hour
+ func Discharge(records []Record, e Entry, enrolledAt, terminatedAt time.Time) Verdict
```
-**Three choices the spec leaves to the plan.**
+**Four choices the spec leaves to the plan.**
- 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` 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.
@@ -2138,7 +2150,8 @@ func records(t *testing.T, steps ...[]vol) []Record {
func TestDischarge(t *testing.T) {
enrolled := t0.Add(-5 * time.Minute)
- if v := Discharge(records(t, []vol{root}, []vol{root, data}, []vol{root, data}), entry, enrolled); !v.Discharge {
+ terminated := t0.Add(30 * time.Minute)
+ if v := Discharge(records(t, []vol{root}, []vol{root, data}, []vol{root, data}), entry, enrolled, terminated); !v.Discharge {
t.Fatalf("all true, nothing removed: %+v", v)
}
cases := map[string]struct {
@@ -2155,7 +2168,7 @@ func TestDischarge(t *testing.T) {
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
- if v := Discharge(tc.recs, entry, tc.at); v.Discharge || v.Reason == "" {
+ if v := Discharge(tc.recs, entry, tc.at, terminated); v.Discharge || v.Reason == "" {
t.Fatalf("verdict = %+v, want the chores left open with a reason", v)
}
})
@@ -2167,10 +2180,24 @@ func TestDischarge(t *testing.T) {
func TestDischargeSeesARemovalNoRecordListed(t *testing.T) {
recs := records(t, []vol{root, data}, []vol{root})
recs[1].RemovedVolumes = []string{}
- if v := Discharge(recs, entry, t0); v.Discharge {
+ if v := Discharge(recs, entry, t0, t0.Add(time.Hour)); v.Discharge {
t.Fatal("discharged across a silent removal")
}
}
+
+// Controller ruling, 2026-10-06: the newest record must be no more than
+// LastRecordWindow older than the termination observation. A recorder that
+// stopped long before the box died cannot speak for its last hours.
+func TestDischargeNeedsARecentLastRecord(t *testing.T) {
+ recs := records(t, []vol{root}, []vol{root}) // newest at t0+10m
+ newest := t0.Add(10 * time.Minute)
+ if v := Discharge(recs, entry, t0, newest.Add(LastRecordWindow)); !v.Discharge {
+ t.Fatalf("exactly at the window: %+v", v)
+ }
+ if v := Discharge(recs, entry, t0, newest.Add(LastRecordWindow+time.Second)); v.Discharge || v.Reason == "" {
+ t.Fatalf("a newest record older than the window discharged: %+v", v)
+ }
+}
```
- [ ] **Step 2: Run them to see them fail**
@@ -2227,6 +2254,10 @@ const (
// EnrollmentWindow is how soon after enrollment the first record must
// be taken for it to stand for the launch-time mappings.
EnrollmentWindow = time.Hour
+ // LastRecordWindow is the most the newest record may predate the
+ // termination observation (controller ruling, 2026-10-06). An older one
+ // cannot speak for a volume attached after it.
+ LastRecordWindow = 2 * time.Hour
keyTime = "2006-01-02T15-04-05.000000000Z"
)
@@ -2464,11 +2495,12 @@ type Verdict struct {
// Discharge decides from records (as List returns them) whether every EBS
// volume e's instance had attached, from the first record taken within
-// EnrollmentWindow of enrolledAt to the last, was DeleteOnTermination, with
+// EnrollmentWindow of enrolledAt to the last, taken within LastRecordWindow
+// of terminatedAt (the fence's observed_at), was DeleteOnTermination, with
// none ever removed (spec §5.9, H19). Records for another enrollment or
// another instance are ignored. Anything short of that keeps the chores
// open for an operator, which costs cleanup and never recovery.
-func Discharge(records []Record, e Entry, enrolledAt time.Time) Verdict {
+func Discharge(records []Record, e Entry, enrolledAt, terminatedAt time.Time) Verdict {
no := func(format string, a ...any) Verdict { return Verdict{Reason: fmt.Sprintf(format, a...)} }
if !e.Cloud.Complete() || e.Enrollment == "" {
return no("the roster entry has no proven cloud")
@@ -2495,6 +2527,9 @@ func Discharge(records []Record, e Entry, enrolledAt time.Time) Verdict {
if first := mine[0].at; first.After(enrolledAt.Add(EnrollmentWindow)) {
return no("the first record (%s) is more than %s after enrollment", first.Format(time.RFC3339), EnrollmentWindow)
}
+ if last := mine[len(mine)-1].at; terminatedAt.Sub(last) > LastRecordWindow {
+ return no("the newest record (%s) is more than %s before the termination", last.Format(time.RFC3339), LastRecordWindow)
+ }
for i, s := range mine {
for _, d := range s.rec.BlockDevices {
if !d.DeleteOnTermination {
@@ -2517,7 +2552,7 @@ func Discharge(records []Record, e Entry, enrolledAt time.Time) Verdict {
- [ ] **Step 4: Run the tests**
Run: `go test ./internal/boxvolumes/ -count=1 -v`
-Expected: PASS, including `TestObserveRefusesWhatIsNotTheInstancesMapping/terminated` and `TestDischargeSeesARemovalNoRecordListed`.
+Expected: PASS, including `TestObserveRefusesWhatIsNotTheInstancesMapping/terminated`, `TestDischargeSeesARemovalNoRecordListed` and `TestDischargeNeedsARecentLastRecord`.
- [ ] **Step 5: Gate and commit**
@@ -3444,7 +3479,7 @@ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
---
-### Task 8: Storage policy, the module notes, the spec note and the branch gate
+### Task 8: Storage policy, the spec note and the branch gate
Spec §6: "The control bucket denies `PutObject` without `s3:if-none-match: *`, and `DeleteObject` and `DeleteObjectVersion`, on `control/boxes/*` (plan 1a's policy) and on `control/box-volumes/*`, `control/box-enrollment/*` and `control/box-fences/*`. Each Deny is scoped to its prefix only." `control/boxes/` is already covered. Task 5's writer uses `control/box-volumes/`; `control/box-enrollment/` and `control/box-fences/` have no writer yet, but a Deny grants nothing, and putting all four in place at once means one apply. The `*.bind.json` statement and the `operator_role_arns` variable stay (§19: "harmless and can go at the next apply"): removing the variable would break every root that sets it.
@@ -3453,7 +3488,6 @@ Nothing here is applied. The flagship's root lives in `carlosframework/carloku-i
**Files:**
- Modify: `infra/modules/deployment-buckets/main.tf`
- Create: `infra/modules/deployment-buckets/tests/evidence.tftest.hcl`
-- Modify: `infra/modules/region-host/README.md`
- Modify: `docs/superpowers/specs/2026-10-04-region-ha-box-placement-design.md` (append §24)
- [ ] **Step 1: Write the failing module test**
@@ -3597,37 +3631,7 @@ The Sids are kept, so a reader of the bucket policy's history sees one statement
Run: `cd infra/modules/deployment-buckets && tofu fmt -check && tofu validate && tofu test; cd -` (sandbox off).
Expected: `Success! The configuration is valid.` and `Success! 1 passed, 0 failed.` Remove `.terraform/` and `.terraform.lock.hcl` again.
-- [ ] **Step 5: Revise the region-host README**
-
-The current text says region HA depends on the root volume being deleted. Under H19 it does not. In `infra/modules/region-host/README.md`, replace:
-
-```markdown
-The root volume is deleted when the instance is terminated. Region HA
-depends on that: a terminated box must leave no disk behind.
-
-Open item for region HA plan 1b: the box role's `ec2:DescribeInstances`
-is unconditioned, because the infrastructure inventory reads every
-region. The HA spec asks for an `ec2:Region` condition on the call the
-placer makes. Decide there whether that is a second, narrower statement.
-```
-
-with (after Paul's copy review, his wording verbatim):
-
-```markdown
-The root volume is deleted when the instance is terminated. The fence
-does not depend on that: a `terminated` read is enough. It decides only
-whether a dead box's local cleanup can be skipped automatically, so keep
-it, and keep Recycle Bin, Data Lifecycle Manager and AWS Backup rules off
-platform box volumes.
-
-The box role's `ec2:DescribeInstances` has no region condition, because
-the infrastructure inventory reads every region, and a second, narrower
-Allow could not take that away. Region HA keeps to the box's own region
-in code instead: every describe it makes for a fence or a volume record
-is pinned to the region the box was enrolled in.
-```
-
-- [ ] **Step 6: Record this plan's decisions in the spec**
+- [ ] **Step 5: Record this plan's decisions in the spec**
Append to `docs/superpowers/specs/2026-10-04-region-ha-box-placement-design.md`:
@@ -3660,7 +3664,10 @@ and settles five details:
4. **"The first record taken at enrollment" (§5.9)** is the earliest
volume record for the enrollment, observed within one hour of the
roster's enrollment time. A later first record cannot speak for a
- volume attached and removed before it, so the chores stay open.
+ volume attached and removed before it, so the chores stay open. And
+ (controller ruling, 2026-10-06) the newest record must be no more
+ than two hours older than the termination observation; otherwise the
+ chores stay open for the operator path.
5. **No volume record from a `terminated` read.** Its mappings are empty
(§8 step 5's dev test), and recording it would list every volume as
removed.
@@ -3668,23 +3675,29 @@ and settles five details:
It also removes plan 1a's `Bind`, `BindKey`, `Verifier.Verify` and
`activator.WarmCopyTrusted`, and makes every box-evidence prefix
create-only and never deleted in the deployment bucket's policy.
+
+Controller rulings of 2026-10-06, on Paul's behalf: the edges file
+withholds a relocatable host when `op.exec` names another box or none
+(§5.4 as written); root's §5.10 refusal stays behind `CARLOS_REGION_HA`
+until claim D6 lands, then becomes unconditional; the `*.bind.json`
+statement and `operator_role_arns` stay until no root sets the variable.
```
-- [ ] **Step 7: Run the whole gate**
+- [ ] **Step 6: Run the whole gate**
Run: `go build ./... && make test`, then `GOOS=darwin go build ./... && GOOS=windows go build ./... && GOOS=linux GOARCH=arm64 go build ./...`.
Expected: `gofmt -l .` clean and every package `ok`, with the sandbox off (see Global Constraints for the socket tests that fail inside it). `make test` takes up to thirty minutes. If a package unrelated to this plan fails, do not fix it here and do not skip it: run that package alone, confirm it also fails on `origin/main`, and report both results.
-- [ ] **Step 8: Commit**
+- [ ] **Step 7: Commit**
```bash
-git add infra/modules/deployment-buckets infra/modules/region-host/README.md docs/superpowers/specs/2026-10-04-region-ha-box-placement-design.md
+git add infra/modules/deployment-buckets docs/superpowers/specs/2026-10-04-region-ha-box-placement-design.md
git commit -m "infra: box evidence prefixes are create-only and never deleted; spec: plan 1b's decisions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
-- [ ] **Step 9: Push and read CI**
+- [ ] **Step 8: Push and read CI**
```bash
git push origin region-ha-1b
@@ -3714,6 +3727,7 @@ Everything spec 1 rev 5.4 asks for that this plan does not build, with the reque
| Discharging `park-local` chores in the fence CAS from `boxvolumes.Discharge` | **L3** | a claim D5 change |
| The census excluding a fenced box and a console on it; the `compute` binding (§5.9) | **L5** | a claim D7 change |
| A stale console report accepted for box-started relocations (H18) | **L20** | a claim D7 change |
+| Root's §5.10 refusal made unconditional, no longer behind `CARLOS_REGION_HA` (controller ruling 3) | claim **D6** (with **L6**) | until D6's controller writes `.hostexec/`, the flag is the only thing keeping a one-box region unchanged |
| The participant's half of §5.10: write `pending`, then refuse to acknowledge while root's marker exists | **L6** (and claim D6's controller, not on main) | `.hostexec/` is D6's; nothing writes it yet |
| Enrollment itself: the console verifying records with `boxid.Enroll` into `control/host-actors.json`, the tofu check, refusals | **L7** (claim D7's roster, not on main) | there is no roster to write |
| The volume recorder loop: describing every enrolled box in the region on the 10-minute sweep and on `shutting-down` | **L7** | records are keyed by enrollment, which only the roster assigns |
@@ -3724,6 +3738,8 @@ Everything spec 1 rev 5.4 asks for that this plan does not build, with the reque
| The joint model with two boxes per region | **L18** | the model belongs to the claim and moves sessions |
| Reading the termination queue and the placer loop | spec 3 (§12), then **L2** to **L4** | nothing can be done with an event before a fence can be written |
+**Follow-up, not waiting for the claim.** Retire the `OnlyOperatorsWriteBinds` statement and the `operator_role_arns` variable in `deployment-buckets` once no consuming root sets the variable (controller ruling 5).
+
**Not waiting for the claim, and not in this plan.** Plan 1a's "After the build" items 9 (a streaming put, lifting the 64 MiB limit, and the data bucket's conditional-write policy for `files/`), 11 (one inotify instance per agent) and 12 (a size cap in `StagePlatform`, a rate limit on `Commit`) need no claim change. They matter only once the files publisher is switched on, which waits for L13, so they belong with that plan.
## What the dry run could not check
@@ -3735,7 +3751,25 @@ Everything spec 1 rev 5.4 asks for that this plan does not build, with the reque
## Self-review
-- **Spec coverage.** §19's corrections: Tasks 1, 2 and 4 (box records as evidence, the bind's tofu check moved into enrollment, `eligibleBoxes` from the roster, `chooseBox`'s tie-break, `WarmCopyTrusted`, `termfence` on `cloud` with no volume input). §5.1 enrollment evidence: Tasks 1 and 3. §5.4: Task 6. §5.9 under H19: Tasks 4 and 5. §5.10 root's half: Task 7. §6 storage: Task 8. §10's unit tests that need no claim: enrollment evidence (Task 1), `eligibleBoxes` and `choose` (Task 2), termination-fence acceptance (Task 4: NotFound, wrong account, wrong region, stopped, an event alone, a describe alone, `shutting-down` offered as evidence, accepted with no volume record), the volume recorder and chore discharge (Task 5), root's refusal on any controller record (Task 7). Everything else is in "Waits for the claim".
+- **Spec coverage.** §19's corrections: Tasks 1, 2 and 4 (box records as evidence, the bind's tofu check moved into enrollment, `eligibleBoxes` from the roster, `chooseBox`'s tie-break, `WarmCopyTrusted`, `termfence` on `cloud` with no volume input). §5.1 enrollment evidence: Tasks 1 and 3. §5.4: Task 6. §5.9 under H19: Tasks 4 and 5. §5.10 root's half: Task 7. §6 storage: Task 8. §10's unit tests that need no claim: enrollment evidence (Task 1), `eligibleBoxes` and `choose` (Task 2), termination-fence acceptance (Task 4: NotFound, wrong account, wrong region, stopped, an event alone, a describe alone, `shutting-down` offered as evidence, accepted with no volume record), the volume recorder and chore discharge, including the 2-hour last-record window (Task 5), root's refusal on any controller record (Task 7). Everything else is in "Waits for the claim".
- **Placeholders.** None: every code step is a whole file or an exact replacement.
- **Type consistency.** `boxid.Cloud` is defined in Task 1 and used by Tasks 2, 4 and 5; `boxid.Identity` is kept through Task 3 and removed in Task 4, its last user; `regionHAEnabled` is defined in Task 3 and used by Tasks 6 and 7; `termfence.DescribeInstance` (Task 4) returns the `*ec2.DescribeInstancesOutput` that `boxvolumes.Observe` (Task 5) reads. The dry run applied the tasks in order to a clean tree and built each.
- **Review Focus.** Each of the five has a test in its owning task, named above, and each was watched to fail with its guard removed.
+
+## Copy waiting for Paul
+
+Controller ruling 6 took this out of Task 8 until Paul has reviewed it (the `copy-review` skill). Proposed for `infra/modules/region-host/README.md`, replacing the two paragraphs that begin "The root volume is deleted when the instance is terminated." and "Open item for region HA plan 1b":
+
+```markdown
+The root volume is deleted when the instance is terminated. The fence
+does not depend on that: a `terminated` read is enough. It decides only
+whether a dead box's local cleanup can be skipped automatically, so keep
+it, and keep Recycle Bin, Data Lifecycle Manager and AWS Backup rules off
+platform box volumes.
+
+The box role's `ec2:DescribeInstances` has no region condition, because
+the infrastructure inventory reads every region, and a second, narrower
+Allow could not take that away. Region HA keeps to the box's own region
+in code instead: every describe it makes for a fence or a volume record
+is pinned to the region the box was enrolled in.
+```