# Region HA 2b: Joining Mode and Automatic Enrolment Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Build spec 2 §5's box side and console side of joining, dormant behind `CARLOS_REGION_HA` and claim mode: the agent chooses hosting or joining at every start, a joining box publishes its identity record and waits behind a 503 health answer, the console confirms the box's group membership itself and then invites and enrolls it, root arms the box's guard, a fresh box installs its channel's release into both protected slots, and `host.env` is assembled from SSM parameters.

**Architecture:** The mode is chosen once, in `RunAgent`, before anything else starts (`hostingGate`). Joining is its own small run (`internal/host/joining.go`): the identity record loop, a 503-only health listener, the guard-ready report, and a marker that asks root (`carlos system join`, behind a path unit) to arm the guard with the invitation's enrollment. Hosting is main's claim agent, which reports hosting to the health answer only while its claim worker runs. The console is the only membership confirmer (H28): its enrolment pass, `hostactors.AutoEnroller` driven by `internal/console/enrollpass.go`, asks the cloud through `hostactors.MembershipReader`, its own read-only client, and `EnrollAutomatic` and the unexported `checkConfirmed` make that read themselves and build the confirmation inside `hostactors` where no caller can supply one. The pass binds the invitation's deadline to the confirmation `checkConfirmed` returns, and finds an invited box with the read-only `enrollmentNonce`, never `Invite`. The write each read allows runs on a store bound to the read's deadline (`writeBound`, the single enforcement point), so no store call of it is issued 60 seconds or more after the read's anchor, the describe's start, and the console waits at most 15 seconds for any call it issues. An outcome the console did not see is unknown, and a later pass settles it the ordinary way, by reading the roster. A cancelled write can still commit later, on the roster revision it was built against. They stand on main's `BeginEnrollmentAttempt`, `Invite`, `Replace` and `membership.go`, unchanged. A box its region's group launched is referenced by its group reference, `g-` and 20 hex digits of its account and instance's digest (`GroupBoxReference`): it publishes under it, `statusLoop` included (and publishes nothing while the reference cannot be read), and the console derives it again from the instance it proved, never trusting the key the box chose. The status reader drops reports no box has rewritten in a day before its key cap, so replaced boxes' history never hides the running ones. Each AWS part is an adapter named for AWS: the SSM parameter store (`internal/hostenv/awsssm.go`) and the Auto Scaling and EC2 describe calls (`internal/hostactors/awsmembership`, opened by the console in `internal/console/awsmembership.go`).

**Tech Stack:** Go 1.26, module `github.com/carlosframework/platform`; `internal/hostenv` (new), `internal/host`, `internal/hostactors`, `internal/console`, the `carlos` CLI (repo root); AWS SDK v2 SSM and Auto Scaling (new, v1.74.0, the versions built against the repo's `aws-sdk-go-v2 v1.44.0`), EC2 (already a dependency).

**Spec:** `docs/superpowers/specs/2026-10-15-region-ha-two-box-regions-design.md`, **rev 21** (rev 21 records the final review below): §5 (boot and enrolment, host.env's fixed path and checked write, the write deadline and its per-call timeout, unknown outcomes settled by the roster, group references, the status listing and the activation gates), §12 (spec 2's hand-off to spec 3), §4 (the SSM layout and IAM, for 2c's seams), §25 (the provider boundary), rulings H1 to H28 (H28: the console is the only membership confirmer), and platform-1e's census ruling region-ha-2b (RULED by the Plan 14 controller 2026-10-16, with its conditions). Plan 2a (`2026-10-16-region-ha-2a-core.md`, built and merged as 6834659c) for format and the seams it left.

**Review round 1.** Astra's review of the first draft (`CODEX-REVIEW-REGION-HA-2B-1.md`) found eight things; this revision answers all of them, with the claim owner's conditions on the first (C1 to C4):

| Finding | What changed |
|---|---|
| 1 (P1): box-written confirmations do not authenticate their writer | H28: no box writes membership evidence. The box-side confirmer, its wiring and the box's create grant and describe grant are gone. The console reads membership itself, and the confirmation exists only inside `hostactors` (Tasks 4, 5, 8; 2c's seams 3 and 5) |
| 2 (P2): install-channel leaves root-owned parents under `/data` | The directories it creates are handed to the service user with bootstrap's own chown, and the test runs the agent's next download after it (Task 3) |
| 3 (P2): 1,600 labels collide | Deferred in round 1 (a legacy-sealed unit); built in round 2 under platform-1e's ruling (below) |
| 4 (P2): racing seeds overwrite shared values | Shared values are written create-only and read back on a conflict (Task 2) |
| 5 (P2): the boot order runs a binary before fetching it | 2c's seam 1 and spec §5 now fetch and check the bootstrap binary first |
| 6 (P2): the console's new signing path escapes its census | Its construction, start and invocation are in the claim-authority census (Task 5) |
| 7 (P3): an unreadable confirmation supersedes a valid attempt | A failed read is unknown: the attempt stands and its wait does not grow (Tasks 4, 5) |
| 8 (P3): the join units' descriptions have no copy rows | RHB-C62 and RHB-C63 (Tasks 1, 7) |

**Review round 2.** Astra's second review (`CODEX-REVIEW-REGION-HA-2B-2.md`) closed six of round 1's findings, left one partly open and one open, and found two more. platform-1e approved the `statusLoop` change round 1 stopped at (census ruling region-ha-2b, binding):

| Finding | What changed |
|---|---|
| Round 1 #2, partly open: a failed handoff leaves root-owned parents a retry skips | Every directory on the cache path is reconciled on every run, not only the ones the run created; a symlinked parent is refused (S2, Task 3) |
| Round 1 #3, open: label collisions; activation must wait | Built: group references, under platform-1e's ruling (S3, Tasks 2, 4, 5, 6, 8). Automatic enrolment must not be switched on for a region before a release with them is on its boxes and its console ("Activation gates") |
| New #1 (P2): freshness is checked before intervening I/O, not at the write | Every membership read sets a write deadline 60 seconds after it; the invitation and the enrolling write (with `checkMembership`'s reads and `Replace`'s `Put`) run on a store bound to it, and nothing is issued past it (S1, Tasks 4, 5) |
| New #2 (P2): the seeding contract omits decryption | 2c's seam 4 adds `kms:Decrypt` with the `ViaService` and `PARAMETER_ARN` conditions; RHB-C61 names the read and decrypt permissions (S4) |
| platform-1e's C2 logging condition | Every membership log line carries the attempt id, the instance id and both request ids, also for refusals and failed reads (S5, Task 5) |

**Review round 3.** Astra's third review (`CODEX-REVIEW-REGION-HA-2B-3.md`) closed round 2's items but the `pendingTime` gate, and found two more; platform-1e ruled the census rows, approving them on conditions (binding); the claim owner added the read-back:

| Finding or condition | What changed |
|---|---|
| New #1 (P2): an unresolved group reference overwrites another box's status | `statusLoop`'s tick is `groupStatusTick`: a box with the group marker publishes only under its reference, resolved afresh each tick, and nothing at all until it resolves, whatever the claim and HA switches say; never under `status.Label` (T1, Task 6; `statusLoop` re-sealed again) |
| New #2 (P2): replaced boxes' reports exceed the status reader's 200-key cap | The reader drops reports whose object no box has rewritten in a day, from the listing's write times, before any read and before the cap; 2c adds a 30-day expiry on `status/boxes/` as hygiene (T2, Task 6) |
| The two redundant pre-checks | Removed: `enroll`'s callback (enroll.go is main's again) and the check before `Invite`. The bound store is the single enforcement point (T3, Tasks 4, 5) |
| The deadline's anchor and edge | Anchored at the describe's start, never later than its completion; a read 60 seconds old or more is refused; a call in flight at the deadline is cancelled and its outcome is unknown (Tasks 4, 5) |
| The claim owner's read-back | An enrolling write whose outcome is unknown is accepted, once it is seen to have landed, only on a fresh describe; a box no longer a member is logged and paged once (Task 5) |
| `runHostEnv` conditions 1 to 3 | No box write to the parameters (2c's negative check); an allowlist of names, safe values, an atomic write through a checked directory; the group marker and a group box's region cross-checked against the roster at the agent's use (T4, Tasks 2, 6) |
| The join trigger condition | Root's join reads nothing from its trigger file; hostile contents change nothing (Task 7) |

**Review round 4.** Astra's fourth review (`CODEX-REVIEW-REGION-HA-2B-4.md`) closed round 3's items but the dev check of `pendingTime`, which stays an activation gate, and found four more. The claim owner and platform-1e agreed two simplifications in answer (binding, U1 to U3):

| Finding or ruling | What changed |
|---|---|
| #1 (P2): a success that returns after the deadline escapes the bound | U2: every store call of a bound write runs under `deadlineStore.call`, which gives it a hard 15-second timeout (`storeCallTimeout`) on top of the 60-second issue bound. A store that ignores its context is cut off by the wrapper itself, and a call that answers, even successfully, only after the deadline is `ErrWriteDeadline`, unknown. Round 5 corrected what this row claimed: the 15 seconds bound how long the console waits, not when a write commits (Tasks 4, 5) |
| #2 (P2): the read-back accepts a stale describe; #3 (P2): a failed page discards the escalation | U1: the read-back, its in-memory table, its fresh describe and its page are removed, and with them RHB-C65 and RHB-C66. An unknown outcome is settled on the next pass by the roster read every pass makes: a box the roster lists is enrolled and done; one it does not list goes on with the same attempt, if it is still valid, and no backoff growth. A detached instance that keeps running is spec 3's: its regional worker reconciles roster membership against the group's (Activation notes, Open question 9) (Task 5) |
| #4 (P2): host.env's directory check leaves the write path open | U3: `host-env write` has no `--out`; the path is fixed at `/etc/carlos/host.env`. `writeHostEnv` (Linux) opens `/etc` and `/etc/carlos` by descriptor, `O_NOFOLLOW\|O_DIRECTORY`, and refuses either unless `fstat` shows a directory root owns that no group or other can write; it writes a temporary file 0600 inside the held directory (`openat`), fsyncs it, renames it within the directory and fsyncs the directory. Off Linux, `write` refuses. RHB-C67 is reworded and RHB-C68 added; no pending row named `--out` (Task 2) |

**Review round 5.** Astra's fifth review (`CODEX-REVIEW-REGION-HA-2B-5.md`) closed round 4's four findings and found one more; platform-1e accepted revision 4 with three items (binding, V1 to V4):

| Finding or ruling | What changed |
|---|---|
| New (P2): the timeout does not establish a 75-second commit ceiling | V1: the claim is withdrawn everywhere. The guarantee, stated as it is: every store call of the invite and the enrolling write is issued before the deadline (60 s from the start of the membership describe); the console waits at most 15 s for any call; a write whose result the console did not see is unknown and is settled on a later pass by reading the roster. A cancelled write can still commit later; such an enrolment rests on a membership read that was true when the write was issued, and is the same exposure as an instance detached after enrolment, which spec 3's worker reconciles. No mechanism is added. Task 5's `TestAWriteTheConsoleGaveUpOnCanCommitLater` documents the exposure: a delayed `Put` commits after the next pass found the box absent and refused it on a fresh describe (Tasks 4, 5) |
| platform-1e: the per-call context reaches the SDK | V2: Task 4's `TestTheBoundReachesTheSDKRequest` builds the console's stores as `claim_runtime.go` does, over `release.NewS3Store` with only the network replaced, and records each HTTP request's context deadline: 15 seconds from the call, or the earlier write deadline. A wrapper that drops the context fails it (Task 4; round 6 drives a successful `PUT` through every wrapper) |
| platform-1e: the two host.env rows | V3: `writeHostEnv` and `checkedDir` re-marked `census-ruling-region-ha-2b` (Task 2) |
| platform-1e: late commits land only where they were aimed | V4: every write in the invitation and enrolment paths is conditional, shown from main's code in "Global Constraints: Freshness bound" |

**Review round 6.** Astra's sixth review (`CODEX-REVIEW-REGION-HA-2B-6.md`) closed round 5's finding and judged the plan ready to build, with one P3 that platform-1e's acceptance of revision 5 also asks for (W1):

| Finding or ruling | What changed |
|---|---|
| P3: the SDK test misses successful writes through the wrapper stack; W1 (platform-1e) | `TestTheBoundReachesTheSDKRequest`'s fake bucket now honours S3's conditional headers and is seeded with a root-signed roster, so the conditional roster `PUT` succeeds through the deadline store, the mutation boundary, the conditional record store and the S3 store, and the test asserts that `PUT`'s deadline and its `If-Match`. Dropping the context where any wrapper forwards `Put` fails it (Task 4) |

**Final review of the build.** Astra's whole-branch review of the build (`CODEX-FINAL-REVIEW-REGION-HA-2B.md`) found two P2s and a P3, and plan text the build had overtaken. Answered in one fix wave, under the controller's rulings (binding):

| Finding | What changed |
|---|---|
| P2: a stalled membership read stops enrolment across all regions | `confirmMembership` gives each `Member` call its own `storeCallTimeout` (15 s) context; a read cut off by it, or by the pass's context, is `ErrMembershipUnknown`: the attempt stands, its wait does not grow, and the pass goes on to the next box. `TestAStalledMembershipReadIsCutOffAndThePassGoesOn` (Tasks 4, 5) |
| P2: accepted status overrides break the group-report contract | `CARLOS_STATUS_BUCKET`, `CARLOS_STATUS_PREFIX` and `CARLOS_STATUS_REGION` leave `hostenv.Optional`, refused up front ("Global Constraints", host.env, says why) (Task 2) |
| P3: the allowlist admits credentials embedded in webhook URLs | `CARLOS_ALERT_WEBHOOK` leaves `hostenv.Optional`, on the DNSimple token's rule. `TestStatusOverridesAndTheAlertWebhookAreRefusedByNameNotValue` refuses all four by name in `Assemble` and `Seed`, never printing a value (Task 2) |
| Plan text: the DNSimple token, `CheckConfirmed` | The `Optional` snippet is the built one, without `CARLOS_DNSIMPLE_TOKEN`. The admission check is the unexported `checkConfirmed`, which returns its confirmation; the pass binds the invitation's deadline to it, and finds an invited box with the read-only `enrollmentNonce` (Tasks 4, 5). The census overlay's comment lines are left as the overlay has them |
| Plan text: the bootstrap binary and 2c | 2c's seams: the bootstrap binary sits outside both protected slots; no launch lifecycle hook on the group; `control/box-enrollment/` leaves `infra/modules/deployment-buckets`; `host-env write` needs `--aws-region` or `AWS_REGION` |
| Before activation | "Activation gates" 3 (the guard's restart transition on dev) and 4 (carloku.com's host.env files against the allowlist); gate 2, stop/start versus reboot, stays |

The copy record and its gate need no change: no user-facing string changed. The new cut-off's text is in the pass's log line, which is not a row.

## Scope

Spec 2 is four plans. This is **2b**. What 2a left it, and what it leaves the others:

| Plan | 2b's relation |
|---|---|
| 2a (merged) | 2b takes over `lbhealth.Default.SetHosting`: 2a's interim `SetHosting(true)` in `ClaimWiring.startRegional` becomes the gate's real decision, cleared when the claim worker ends (Task 8). 2b is the first caller of `EnrollAutomatic`, so 2a's group-role inheritance (Task 3 there) is exercised end to end (Task 5) |
| 2c | Builds the start-up script, the tofu group mode, IAM and alarms. 2b gives it the commands the script runs (the bootstrap binary's `system host-env write`, `bootstrap` and `system install-channel`, then the agent) and the exact permissions they need. See "2c's seams" at the end |
| 2d | The §8 single-box fixes. Nothing in 2b depends on them |

**In 2b:** spec §5 steps 2 to 5 as code: the SSM layout and the seed command (Task 2), the channel-release install (Task 3), group membership read by the console (Task 4), the console's enrolment pass (Task 5), mode selection (Task 6), root's half of joining (Task 7), joining mode and the hosting flag (Task 8), and the CLI verbs and docs (Task 9).

**Not in 2b:** the start-up script and hardening (2c), the region-host module's group mode, IAM, KMS and alarms (2c), the §8 fixes (2d). A region's `GroupName` and `GroupARN` reach the signed roster through a `region-group` phase of `carlos system claims run`, a later, separate plan built on `feat/claim-activation-tools` once that merges (Open question 1).

## Global Constraints

- **Dormant.** With `CARLOS_HOST_CLAIM` or `CARLOS_REGION_HA` off, the agent starts exactly as on main: the gate returns "hosting" without reading anything, and nothing joins. The console's enrolment pass, and with it every membership read, starts only on a host-claim console with region HA on, beside 2a's capacity pass. `carlos system host-env`, `install-channel` and `join` are new commands nothing runs until 2c's start-up script does; `carlos-system-join.path` is armed on every box but idle, because only a joining agent touches its marker, and `carlos system join` does nothing with either switch off.
- **Mode selection (A2-4).** Hosting needs all three: `/etc/carlos/host-actor.json` naming this box; an active roster entry for that enrollment and instance; and the guard armed under the roster's floor with this process's binary one it admits (`hostfloor.Disk.Running`). And host.env's authority-selecting values agree with that entry (`crossCheck`): the group marker is set exactly when the console enrolled the box from a group, and a group box's `CARLOS_REGION` is the region it was enrolled in. For a box its region's group launched (the roster entry names a `Group`), one more: the instance is still the launch it was enrolled on (C3, `relaunched`). Anything else is joining, at one of five stages: no invitation, invited, guard installed, guard-ready reported, held. A fact that cannot be read is **unknown** (`errModeUnknown`): the gate waits and asks again, and never treats it as joining or hosting.
- **A stopped group box that is started again** (C3) never resumes hosting on its old enrollment. EC2 sets the identity document's `pendingTime` at every start, not at a reboot; when the current document's `pendingTime` differs from the one in the identity record the roster entry binds (by digest), the box is held, answers 503, and its group replaces it. It cannot prove membership again under the same reference while its roster entry stands; its replacement enrolls under its own reference.
- **Box references (S3; census ruling region-ha-2b).** A box its region's group launched is referenced by its group reference, `GroupBoxReference(account, instance)`: `g-` and the first 20 hex digits of `sha256("carlos-box-ref-v1|" + account + "|" + instance)`. Instance ids never repeat in an account, and the reference is a pseudonym, so the public `status/fleet.json` shows it and never an instance id.
  - **The box** takes it when its host.env says `CARLOS_BOX_REFERENCE=group`, which only `host-env write` adds: `metricsBoxLabel` answers it, so the identity record, mode selection, root's join and the edges file use it; `statusLoop`'s tick (`groupStatusTick`, one call, re-sealed) publishes under it; the joining report is written under it. An identity IMDS has not given is no reference, never a label in its place: such a box publishes no status report at all, never under `status.Label`, which may be another box's key (`WriteBox` replaces what is there), and asks IMDS again on the next tick. That holds whatever the claim and region HA switches say.
  - **The console** never trusts the key a box chose. The pass derives the reference from the instance the identity record proves and refuses a record under any other key; `checkConfirmed` and `EnrollAutomatic` refuse a request whose reference is not the instance's; `PrivateBoxReport` reads a group box's report under the reference derived from its pinned instance (the attempt's before enrolment, the roster's after), so every `VerifyReport` input for a group box comes from the console's own derivation, and a report another box wrote under that key fails on its instance and enrollment.
  - **Every other box**, fleet boxes included, keeps exactly today's label and status key.
- **A joining box** publishes its signed identity record, publishes no edges file (so it is never referenced, `references.go:63`), opens no registry, admits and serves nothing, and answers `/.carlos/lb-health` with 503. Only its guard-ready report and its identity record leave the box.
- **The health answer's hosting condition** is the gate's decision, set before the claim worker starts and cleared when it ends (2a deferred this).
- **Enrolment (A2-1, H28).** The pass begins an attempt for each published identity record whose box the roster does not list. It invites only after its own membership read of the attempt's instance (`checkConfirmed`, unexported), on the store bound to the confirmation that read returns, and enrolls only when the box's guard-ready report passes `hostactors.VerifyReport` (the check `membership.go:40` makes), through `EnrollAutomatic`, which reads membership again itself. The attempt is single-use (`Consumed`, unchanged), so a replay is refused. An invited box is found with `enrollmentNonce`, which only reads and verifies the invitation; `Invite`, which is get-or-create, runs only on the bound store after a membership read. The box takes its group's role in the enrolling write (2a). The pass enrolls into the console's own namespace and the `default` pool, and only in a deployment whose host floor is published.
- **Membership (H16, H28; C1, C2).** The console is the only membership confirmer. No box writes or contributes membership evidence, and no box role can describe the group or create under `control/box-enrollment/`.
  - `EnrollAutomatic` and `checkConfirmed` (unexported, `hostactors`' own) take a `MembershipReader` (the console's AWS client) and call `Member` themselves, each call cut off at `storeCallTimeout` (15 s) as unknown. The confirmation is built from that answer alone, by the unexported `confirmMembership`, and never leaves `hostactors`; no exported function takes a confirmation or a `Membership`. Tests use a fake reader.
  - A member is in the region's group, `Pending` or `InService`, `pending` or `running`, owned by the region's account, in its AWS region, with a request id from each of the two calls.
  - **Freshness bound: 60 seconds, enforced at the write (S1, T3, U2).** A fresh describe runs immediately before each write, the invitation and the enrolment. **The write deadline is anchored at the describe's start, which is never later than its completion, and a write 60 s or more after that anchor is refused. Each store call issued before it gets a hard timeout of 15 s (`storeCallTimeout`).** What that guarantees (V1): every store call of the invite and the enrolling write is issued before the deadline (60 s from the start of the membership describe); the console waits at most 15 s for any call; a write whose result the console did not see is unknown and is settled on a later pass by reading the roster. A cancelled write can still commit later; such an enrolment rests on a membership read that was true when the write was issued, and is the same exposure as an instance detached after enrolment, which spec 3's worker reconciles. `writeBound` is the single enforcement point. It gives the sealed writers (`Invite`, and `Replace` with the `checkMembership` it makes) a copy of the directory whose store refuses every call at or after the deadline, by the read's clock or the context's, and a context that ends at the deadline. What it guarantees: no store call, the roster `Put` and the invitation's `Put` included, is issued at or after the deadline; a call in flight then is cancelled; a call the store has not answered 15 s after it was issued is cut off by the wrapper itself (`deadlineStore.call` returns on its own and cancels the call's context, which the AWS SDK enforces at the HTTP layer); and a call that answers only after the deadline, even with success, is `ErrWriteDeadline`, unknown. The store is wrapped, not trusted: `release.S3Store` passes its context to the AWS SDK, which honours it, but `release.DirStore` ignores its context. The pass's S3 store is the console's claim data boundary, whose client the console shares; rather than a second client and a second boundary, the wrapper gives each call its own 15-second context, the same hard limit at the HTTP layer, and does not wait past it whatever the store does. Every write in the invitation and enrolment paths is conditional (V4; lines are main's, `6834659c`): the roster write is `Directory.Replace`'s compare-and-swap, `d.Store.Put(ctx, Key, wire, before.ETag)` (`internal/hostactors/roster.go:281`), which refuses an observation with no ETag (`roster.go:216`), and every roster write the pass makes goes through it (`enroll.go:80`, the enrolment `EnrollAutomatic` makes through `enroll`, and `enrollment_confirmation.go:158`, `BeginEnrollmentAttempt`'s); the invitation is created only if absent, `d.Store.Put(ctx, key, raw, "")` (`internal/hostactors/invitation.go:78`). `release.Store`'s contract makes `""` create-only (`If-None-Match: *`) and an ETag replace-only (`If-Match`) (`internal/release/store.go:40`), as `S3Store.Put` sends them (`store.go:795` to `:797`) and `DirStore.Put` checks them (`store.go:430` to `:440`). The pass's store keeps that: the observed mutation boundary re-reads the object and refuses a Put whose ETag no longer matches before forwarding the same condition (`internal/hostops/mutation_boundary.go:147` to `:162`), and the conditional record store passes it through for every key that is not an instance or sidecar record (`internal/hostops/record_writes.go:86` to `:88`), over the console's raw store (`internal/console/claim_runtime.go:42`, `:46`, `:60`). So a write that commits late lands only on the exact roster revision it was built against, or creates an invitation that did not exist; it never overwrites a newer revision or another enrolment. A missed deadline keeps the attempt and its wait. A describe that itself took 60 seconds or more is `ErrMembershipStale`, checked once in `checkConfirmed` after the describe, and counts against the attempt. Nothing else checks freshness: no callback in `enroll` and no check before `Invite`, which would only repeat the bound; between the describe and `Invite` sits nothing that waits.
  - **Unknown outcomes are settled by the roster (U1).** An outcome the bound cannot confirm (`ErrWriteDeadline`) is resolved the ordinary way on the next pass, which reads the signed roster as every pass does: a box the roster lists is enrolled, and the pass has nothing more to do for it; one it does not list goes on with the same attempt while it is still valid, and its wait does not grow. There is no read-back, no memory of unknown outcomes and no page. An instance that left its group but still runs after its enrolment landed keeps that enrolment until an operator removes it or spec 3's regional worker reconciles it ("Activation gates", note); a terminated one is kept from hosting and from capacity by main's termination fence (`internal/hostactors/fence_authority.go:14`, honoured at `internal/hostops/executor.go:204`), and a stopped one by the stale-edges rule (`internal/boxchoice/boxchoice.go:131`) and C3's restart hold (Task 6's `relaunched`).
  - **Audit (S5).** Main's roster wire has no field for request ids, on the attempt or on the box entry, so none is added: the console logs every membership read, with the box's reference, its attempt id, its instance id, both request ids (or "none" for a call that failed before the cloud answered) and the read's time, and what came of it: invited, enrolled, refused or unknown (`AutoEnroller.Logf`).
  - **Unknown is not invalid (#7).** A read that fails, or is not an answer (EC2 does not list the instance, a missing request id), is `ErrMembershipUnknown`: the pass asks again next time with the same attempt, which it neither supersedes nor counts. Only the cloud's positive answer that the instance is not a current member (`ErrNotMember`), or a read that went stale before its write (`ErrMembershipStale`), counts against the attempt: the pass keeps it, asking nothing more, for `Patience`, doubling with each refusal up to 32 times, then begins another. A record whose instance is gone costs one roster write and one describe an hour.
- **Predecessors until spec 3 (C3).** 2a's readers already exclude fenced and terminated boxes: every one goes through `ExecutorChooser.eligible` (`internal/hostops/executor.go:172`), which keeps only `active` roster entries (`:201`) and drops a box whose termination fence validates (`:204`, `TerminationFence`), and `boxchoice.Eligible` then requires a fresh edges file (`internal/boxchoice/boxchoice.go:131`) and drops the claim's lost boxes (`:143`), and `boxchoice.Placeable` drops headroom older than 15 minutes (`internal/boxchoice/policy.go:69`). `PlaceableBoxes` (`executor.go:103`, the create, move and no-room doors: `internal/console/apiinstances.go:1267`, `boxselect.go:204`, `nocapacity.go:171`), `Choose` and `Permits` (`executor.go:61`, `:85`) and `Capacity` (`executor.go:122`, the `EligibleBoxes` metric through `internal/console/regioncapacity.go:108`) all read that set. A terminated box is out as soon as its fence is written, and in any case once its edges file is 15 minutes old; a stopped box drops out by the same freshness rule. 2b adds no exclusion.
- **The channel install (A1-6, A2-2).** `carlos system install-channel` verifies the signed pointer (`verifyPointer`), refuses a release that does not pass the published host floor exactly as guard-arm will check it (`hostfloor.CheckImage`), fetches against the signed sha256, stages through `stageCandidate`, and puts the artifact in both `carlos` and `carlos.prev`. It keeps the release identity for guard-arm, and writes `applied.json` and `seen.json` as an apply would. On every run, each directory on the agent's cache path (`system`, `system/router` and the version's own) is created as bootstrap creates directories (`bootstrapMakeDir`, no symlink followed) and handed, alone, to the service user (`ownByService`: bootstrap's descriptor-anchored open, then `Fchown`), whether this run or an earlier failed one created it; a symlinked parent is refused; nothing else is chowned. The agent's next update can then download and ask. It refuses an armed guard, a box that already runs another release, a missing floor, and any read that fails.
- **host.env (§5 step 2).** SecureString parameters named for their variables, under `/carlos/<deployment>/<region>/` and, for the five shared variables, `/carlos/<deployment>/shared/`. Every variable §5 step 2 names is required. The file is written 0600 by root, sorted `KEY=value` lines, with values restricted to letters, digits and `_ . / : , @ + = % -` so systemd and a shell read it alike; anything else is refused, naming the variable and never the value. A variable in both scopes is refused. Only allowlisted names are accepted (`Required` and `Optional`, census condition 2): any other name, under either scope, is refused by name by `write` and by `seed`, and keys that would select a box's authority from the store alone (fleet or tunnel identity, address overrides, pools, directories, relay settings, tokens) are not on the list. Two more kinds are left off (final review). `CARLOS_DNSIMPLE_TOKEN` and `CARLOS_ALERT_WEBHOOK` are credentials, or can carry one: a webhook URL can embed its token. The status destination overrides, `CARLOS_STATUS_BUCKET`, `CARLOS_STATUS_PREFIX` and `CARLOS_STATUS_REGION`, would split a group box's reports from the console's reads: a group box reports to the deployment bucket, unprefixed, where the console reads its guard-ready report, while joining and once hosting. Each is refused by name, never by value, like any other name off the list. The path is fixed, `/etc/carlos/host.env`; `write` takes no flag to move it (U3). It is written only through `/etc` and `/etc/carlos` held by descriptor: each opened `O_NOFOLLOW|O_DIRECTORY` and refused unless `fstat` shows a directory root owns that no group or other can write (`/etc/carlos` is created 0755 first if missing, then opened and checked the same way); the new file is created 0600 inside the held directory, fsynced, renamed over `host.env` within it, and the directory fsynced, so a reader sees the old file or the new one. That code is Linux's (`write_linux.go`); elsewhere `write` refuses. `write` adds `CARLOS_BOX_REFERENCE=group`, which is never a parameter. The marker and `CARLOS_REGION`, which do select authority, are cross-checked against the signed roster at the agent's use (condition 3, Task 6), not at write: at boot the box has no roster entry to check against, and before enrolment neither grants anything, since the console derives a joining box's reference and region from its proven instance. `seed` writes a region's own values with overwrite and the shared ones create-only (`SetNew`, SSM `Overwrite: false`); when another seed got there first, it reads the value back and goes on only if it is the value it would have written, otherwise it refuses, naming the variable.
- **No roster field is added.** The roster wire is unchanged (`EnrollmentAttempts` and the region's group fields are main's), so 2b has no reader-ordering constraint. The only new objects are box-local (`/opt/carlos/system/join/`, `/opt/carlos/system/router/channel-identity.json`, `/data/system-join-requested`) or main's keys (invitations, status reports, identity records). Nothing is written under `control/box-enrollment/`: main's `ConfirmationKey` and the stored confirmation (`MembershipConfirmation`, `TerminatedEnrollment`) had no reader but `EnrollAutomatic` and its tests, so Task 4 removes them.
- **The provider boundary (§25).** Spec 2's core files import no `github.com/aws/...` package; AWS parts are adapters named for AWS; `region_ha_boundary_test.go` holds both. 2b builds no S3 client.
- **The status listing (T2).** Every replaced group box leaves a status report under its own reference, so `status/boxes/` keeps growing. `loadBoxesReport` (the reader behind `status.LoadBoxes` and `LoadBoxesComplete`) lists the prefix with each object's write time where the store gives one (`release.ModLister`: both real stores do), and drops reports no box has rewritten in a day before any read and before its 200-key cap, so the cap bounds current reports only. It is an optimisation, never authority: every reader still judges each report's own `GeneratedAt` as before (`BoxStale`, the console's `VerifyReport`), and a day keeps every box an operator would still look for as stale. A store without write times (a fleet's prefix store) is listed whole, as before. 2c adds a 30-day expiry on `status/boxes/` as hygiene; nothing depends on it, so a self-hosted store without lifecycle rules works the same.
- **Copy.** Every string a person reads is a pending row in `docs/superpowers/plans/2026-10-17-region-ha-2b-copy.md`, gated by `approved_region_ha_2b_copy_test.go`. Tasks 2, 3, 7 and 9 write rows and wait for Paul's approval. No em dashes.
- **Go commands:** `export GOFLAGS=-buildvcs=false`, with `GOCACHE` left at its default (`~/.cache/go-build`). Run tests with `nice -n 19` and a `-run` filter; never the whole `internal/host` suite (CI's `make test` is the full run). Tests that bind unix sockets fail inside the sandbox ("socket: operation not permitted"); that is the sandbox, not the change. A sandbox that cannot write the default cache needs the go commands run outside it, not a cache under `$TMPDIR`.
- **Census:** every task that changes a file under a package directory re-pins that directory's `@coverage` row in `internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv`, loaded last. Each task runs all three censuses: the record-write census (`TestPlan9B8RecordWriteCensus`), the Plan 14 root-read census (`TestPlan14RootReadCensus`) and the console claim-authority source census (`TestClaimAuthoritySourceCensus`).

## Activation gates

2b ships dormant. Automatic enrolment must not be switched on for any region until all four hold:

1. **Group references have shipped** (Astra round 2, #3). A release with all of 2b, Tasks 2, 4, 5, 6 and 8 in particular (the references, the status tick that publishes only under them, and the status listing that keeps replaced boxes' history from hiding the running ones), runs on every box of the region and on the console. Without them labels collide, and a region in its first build has no healthy target, so its group never replaces the box that cannot publish (spec §5's first-build exception); an alarm reports that stall and does not repair it. They ship in 2b, so this gate is that release being rolled.
2. **`pendingTime` is verified on dev** (Open question 7): verify on dev before the first region that a group box stopped and started is held and replaced, and that one rebooted hosts again.
3. **The guard's restart transition is seen on dev** (final review). On dev, a fresh group box goes guard-arm, then carlos-system-recover, then guard-exec across the joining-to-hosting restart, and comes up hosting. After the restart the guard drop-in requires `carlos-system-recover.service`, and a recover that fails leaves the edge down, since `Restart=always` does not fire again on a failed dependency. Static inspection cannot establish that systemd transition.
4. **carloku.com's host.env files are checked against the allowlist** (final review), which now excludes the status destination overrides and the alert webhook ("Global Constraints", host.env), before the first seed. A box that carries an excluded name has to drop it, or its region cannot seed.

**Activation note: detached instances are spec 3's.** 2b removes nothing from the roster. An instance detached from its group but still running keeps its enrolment until an operator removes it. Spec 3's requirements carry it: **the regional worker reconciles roster membership against Auto Scaling group membership, including detached-but-running instances** (spec §12, Open question 9).

## Census items for platform-1e (census ruling region-ha-2b)

**RULED by the Plan 14 controller 2026-10-16 as census-ruling-region-ha-2b**, approved on conditions, all built here (round 3). The overlay's first line says so. One legacy-sealed unit changes, as ruled: `statusLoop`. Every other legacy-sealed unit 2b touches is called, never edited: `runAgent`, `edgesFileLoop`, `ExecutorChooser.eligible`, `stageCandidate`, `Directory.Invite`, `Directory.BeginEnrollmentAttempt`, `Directory.checkMembership`, `Directory.Replace`, `readIdentityRecord`, `status.CollectBox`, `status.BuildFleet` and `RunSystemGuardArm`. The gate sits in `RunAgent`, which is not a census unit, so `runAgent` keeps its digest. Rows that changed in round 5 are marked, with their old values; the new values are platform-1e's to confirm. The seal does not move in round 5.

**Census rows.**

| Task | Census | Unit | Class (ruled) | Basis, change and the ruling's conditions | Seal |
|---|---|---|---|---|---|
| 2 | root-read | `internal/hostenv/cli.go:runHostEnv` | authority, A,J4 | New; **changed in round 4** (kinds `file:ReadFile@1500`, digest `df447017...`; were `file:Lstat@1495,file:ReadFile@1930`, `b1cdca1b...`). `write` runs as root, takes only allowlisted names with safe values, and hands the file to `writeHostEnv` at its fixed path, so its `Lstat` of the directory goes; `seed`'s read site is its `--from` file. Conditions: no box write to the parameters (2c seam 3), the allowlist and atomic, checked write (built, U3), authority-selecting values cross-checked against the roster at the agent's use (Task 6) | stands |
| 2 | root-read | `internal/hostenv/write_linux.go:writeHostEnv` | authority, A,J4 (`census-ruling-region-ha-2b`; **re-marked in round 5**, was `PROPOSED-region-ha-2b-r4`) | New in round 4 (kinds `file:Open@68,file:Openat@509`, digest `1245fc0f...`). Writes `/etc/carlos/host.env` only through `/etc` and `/etc/carlos` held by descriptor and checked by `checkedDir`: a temporary file created 0600 (`O_EXCL\|O_NOFOLLOW`) inside the held directory, fsynced, renamed over `host.env` within it, the directory fsynced. Its read sites open the root and the temporary file. Ruled with `runHostEnv`, as the checked parent its condition 2 asks for | stands |
| 2 | root-read | `internal/hostenv/write_linux.go:checkedDir` | authority, A,J4 (`census-ruling-region-ha-2b`; **re-marked in round 5**, was `PROPOSED-region-ha-2b-r4`) | New in round 4 (kinds `file:Fstat@576,file:Openat@168,file:Openat@372`, digest `012257ac...`). Opens one directory under one already held, `O_NOFOLLOW\|O_DIRECTORY`, creating it 0755 first only when asked and missing, and refuses it unless `fstat` shows a directory owned by root (this process's user, which `write` requires to be root) that no group or other can write | stands |
| 3 | root-read | `internal/host/installchannel.go:channelInstall.run` | authority, F,J4 | New. Its one read site opens root's apply lock (`systemApplier.lockPath`); the install goes through `verifyPointer`, `hostfloor.CheckImage` and `stageCandidate`, unchanged; cache directories root-created, symlinks refused, as ruled | stands |
| 4 | record-write (B8) | `internal/hostactors/membershipread.go:deadlineStore.Put` | nonrecord (`def:Put,sel:Put`) | New; **digest changed in round 4** (`848f2b52...`, was `e0f34b1d...`): it runs under `deadlineStore.call`, the 15-second hard timeout, and a success that arrives after the deadline is unknown. It forwards `Put` and refuses it at or after the deadline; it names no record key | n/a |
| 5 | root-read | `internal/hostactors/autoenroll.go:AutoEnroller.inputs` | accepted-legacy, J4 root-input | New, unchanged in rounds 3 and 4 (U1 removed the read-back, not this unit). The signed roster and the host floor, in the console | moves |
| 5 | claim-authority | Sites `enrollpass.go\|startEnrollPass\|newAutoEnroller(s.cfg.ClaimAuthority, members)`, `enrollpass.go\|startEnrollPass\|runEnrollPass(ctx, e, enrollPassInterval, log.Printf)`, `enrollpass.go\|runEnrollPass\|e.EnrollPass(ctx)` | reviewed authority sites | New, unchanged in rounds 3 and 4 | n/a |
| 6 | root-read | `internal/host/agent.go:statusLoop` | accepted-legacy, re-sealed: `accepted-legacy;rev3=census-ruling-region-ha-2b` | Unchanged in round 4. Re-sealed again in round 3 (digest `666ddbc4...`, was `eb4850f8...`; kinds back to main's `whole:Route@6154,@6181,@7423,@9437`): main's body with one call changed, its tick through `groupStatusTick`. A box with the group marker publishes only under its group reference, resolved each tick, and nothing until it resolves; every other box gets `statusTick` with today's label and key. The seal moves to `fc584e11...` (was `78fb616c...`) | moves |
| 7 | root-read | `internal/host/units/carlos-system-join.path` and `carlos-system-join.service` (`external`) | authority, F,J4 | New, unchanged files. Condition: the path unit's file is only a trigger; root's join reads nothing from it and takes no parameter from it, and decides from the signed invitation, the published record, the signed floor and IMDS (tested) | stands |
| 9 | root-read | `system.go:runSystem`, `system.go:systemVerb` | authority, B,C,D,F,J4 (seed overlay, kept) | Re-digest only: three verbs added; class, basis and (empty) kinds unchanged | stands |

**Changed code no census selects**, listed so the ruling covers everything 2b changes:

| Task | Unit | Change | Why no census row |
|---|---|---|---|
| 2 | `internal/hostenv` (`Fetch`, `Assemble`, `Seed`, `sameShared`, `Allowed`, `notAllowed`; `write_other.go:writeHostEnv`) | The group-box marker; create-only shared values; the allowlist; off Linux, `write` refuses | No root-read site; `SetNew` and `Set` are not `Put`. Round 4 removed `ownedByEUID` with its files |
| 3 | `channelInstall.cacheDir`, `ownByService`, `ownDir` | Every cache parent made and handed to the service user, each run; symlinks refused | They call `bootstrapMakeDir`, `bootstrapOpenDir` and `bootstrapOwner` (main's, unchanged) |
| 4 | `Directory.EnrollAutomatic`, `checkConfirmed`, `confirmMembership`, `writeBound`, `deadlineStore.call`, `deadlineStore`'s other methods | The console's own read; the write bound, the single enforcement point, with each call's 15-second hard timeout; the group-reference check | Not root-read units; `deadlineStore.Put` is the B8 row above. `Directory.enroll` is main's again (round 3) |
| 4 | `Directory.PrivateBoxReport` (`private.go`) | A group box's report is read under the reference derived from its pinned instance | Not a census unit. It feeds `VerifyReport` (unchanged) for `checkMembership`, `Census` and activation; non-group boxes unchanged |
| 4 | `GroupBoxReference` (`groupref.go`) | New: the reference function | No I/O |
| 5 | `AutoEnroller.EnrollPass`, `step`, `confirmAndWrite`, `logRead`, `logOutcome` | The pass, its write bound, its reference check and its log lines (round 4 removed `readBack` and `pageNotMember`) | Not root-read units; its construction and invocation are the claim-authority sites above |
| 6 | `metricsBoxLabel`, `boxRecordLoop`, `productionModeFacts`, `crossCheck`, `groupReferenced`, `groupBoxReference`, `groupStatusTick` | A group box names itself by its group reference and publishes status only under it; the marker and region cross-checked against the roster | Not census units; `statusLoop` is the row above, and `statusTick` (sealed) is called unchanged |
| 6 | `internal/status/fleet.go:loadBoxesReport`, `listBoxKeys` | Reports no box has rewritten in a day dropped from the listing before the key cap | Not census units (no read site of the census's kinds); `BuildFleet` (sealed) is unchanged and calls it as before |
| 8 | `productionJoining`'s report | The guard-ready report under the group reference | Not a census unit |

**Record-write census (B8): one entry, `deadlineStore.Put` (Task 4).** Invitations are still written by main's `Invite`, roster writes by main's `BeginEnrollmentAttempt` and `Replace` (through `EnrollAutomatic`), and the parameter store's write methods are `Set` and `SetNew`. **Console claim-authority census: three sites added (Task 5).** The census would go red if the construction, the start or the invocation were removed or duplicated; the dry run proved each through `go test -overlay`.

**Build base.** The values below were computed on main `6834659c`. If main moves before the build, recompute every `@coverage` row, digest, `@legacy` fingerprint and seal there with the tooling below; the rows to write stay the same.

## Census tooling

Use plan 2a's tooling (its "Census tooling" section: `coverage.sh`, `unitdigest`, `legacyrow.py`), created under `$TMPDIR/census` and deleted afterwards, with two additions:

- **Embedded unit files.** `coverage.sh` counts only the census's file extensions, but `internal/host`'s `//go:embed` also brings in `units/*.mount` and `units/*.slice`, which the census counts. For `internal/host/units` (Task 7) compute the row over every file in the directory:

````bash
# coverage-all.sh <dir>: the @coverage row of a directory whose every file is embedded.
dir=$1
hash=$(for f in "$dir"/*; do [ -f "$f" ] && printf '%s\0%s\n' "$f" "$(sha256sum "$f" | cut -d' ' -f1)"; done | LC_ALL=C sort | head -c -1 | sha256sum | cut -d' ' -f1)
printf '@coverage\t%s\t%s\n' "$dir" "$hash"
````

- **The seal.** `seal.py` prints the seal over every overlay `TestPlan14RootReadCensus` loads, in its order, plus the ones named:

````python
import hashlib, re, sys, os
# seal.py <repo root> [extra overlay ...]: the Plan 14 legacy seal after every
# overlay TestPlan14RootReadCensus loads, in its order.
root=sys.argv[1]
t=open(os.path.join(root,'internal/provision/plan14_root_reads_test.go')).read()
body=t[t.index('func TestPlan14RootReadCensus('):t.index('func TestPlan14RootReadCensusRejectsDrift')]
files=re.findall(r'"internal/provision/testdata/(plan14-root-reads[^"]*\.tsv)"',body)
if not os.path.exists(os.path.join(root,'internal/host/movesheldrelease_production.go')):
    files=[f for f in files if f!='plan14-root-reads-moves.tsv']
files+= [f for f in sys.argv[2:] if f not in files]
fps=set()
for f in files:
    for line in open(os.path.join(root,'internal/provision/testdata',f)):
        c=line.rstrip('\n').split('\t')
        if c[0]=='@legacy':
            fps.add(c[1])
print(hashlib.sha256('\n'.join(sorted(fps)).encode()).hexdigest())
````

A new unit's kinds are the ones `TestPlan14RootReadCensus` prints for it as "unclassified root read"; its digest is `unitdigest`'s (for an `external` unit, the file's sha256). The overlay is tab-separated: keep the tabs in every row this plan gives, or the loader refuses the row.

The census checks every task runs:

````bash
nice -n 19 go test ./internal/provision/ -run 'TestPlan9B8RecordWriteCensus|TestPlan14RootReadCensus$' -count=1
nice -n 19 go test ./internal/console/ -run 'TestClaimAuthoritySourceCensus' -count=1
````

## Review Focus

1. **An enrolled box whose roster cannot be read at start must not fall back to joining**, or a bucket blip would take a hosting box out of service and send it through enrolment again. Task 6's `TestSelectModeIsUnknownWhenAFactCannotBeRead` and Task 8's `TestDecideAgentMode` (a roster that fails twice, then reads: the gate waits, logs once, and hosts).
2. **A joining box must never look healthy or referenced.** Task 8's `TestJoiningServes503UntilItCanHostAndPublishesNoEdgesFile`: 503 naming `hosting` throughout, nothing but the health path served, no `control/edges/` object written, and a stale `hosting` flag cleared at the start.
3. **The health answer must drop when the claim worker stops.** Task 8's `TestClaimWiringHostingFollowsTheModeAndTheWorker`.
4. **No enrolment without the guard-ready report.** Task 5's `TestAutomaticEnrollmentFromRecordToRoster` holds an invitation and an unready report together and expects no enrolment and no membership read; with the `VerifyReport` gate removed the pass enrolls and the test fails.
5. **Only the console's own read makes a box a member (H28, C1).** Task 4's `TestEnrollAutomaticReadsMembershipItself`, `TestEnrollAutomaticRefusesANonMember` and `TestNoExportedPathTakesAConfirmation`, and Task 5's `TestAnInstanceOutsideTheGroupIsNeverInvitedOrEnrolled`: an instance in the deployment's account that the cloud places in another group, or none, is never invited or enrolled however long it waits, and one that leaves the group after its invitation is not enrolled on its guard-ready report.
6. **No store call of a write is issued 60 seconds or more after its read (C2, S1, T3).** Task 4's `TestEnrollAutomaticRefusesAReadOlderThanAMinuteAtTheWrite` (a describe of exactly 60 seconds, and a read that reaches its deadline before the roster CAS), and Task 5's `TestAnInvitationNeedsAReadUnderAMinuteOldAtTheWrite`, `TestASlowReadInsideInviteWritesNothing` and `TestASlowReadInsideTheRosterCheckWritesNothing` (a bucket read inside the sealed `Invite` or `checkMembership` that answers exactly at the deadline leaves no `Put` issued), and `TestAPutInFlightAtTheDeadlineIsUnknown` (a `Put` still in flight at the deadline is cancelled; the outcome is unknown, the attempt and its wait stand, and a write that did land is found by the next pass's ordinary read).
7. **A failed read is unknown (#7).** Task 4's `TestAMembershipReadThatIsNotAnAnswerIsUnknown` and Task 5's `TestAMembershipReadThatFailsKeepsTheAttempt`: ten hours of failed reads keep the same attempt and the same wait.
8. **Root arms the guard only for this box's own invitation and record.** Task 7's `TestRootJoinRefusesAnInvitationThatIsNotThisBoxs` (another instance, another record, another release key, another deployment, an unreadable store).
9. **Both slots hold the channel's artifact, nothing below the floor is installed, and the agent can still update.** Task 3's tests; a store that cannot be read is a refusal, never "no floor"; the directories install-channel creates go to the service user, and the agent then downloads and requests the next release.
10. **Records of boxes that are gone must not churn the roster or the cloud.** Task 5's `TestARefusedAttemptIsBegunAgainWithADoublingWait` pins the doubling wait, its cap, and no describe while an attempt waits.
11. **host.env never assembles from a partial read, never prints a secret, and two seeds cannot both win.** Task 2's `TestFetchRefusesAFailedReadAndAConflict`, `TestRenderRefusesAnUnsafeValueWithoutPrintingIt` and `TestSeedRacesForASharedValueAndOnlyOneWins`.
12. **A group box started again never hosts on its old enrolment (C3).** Task 6's `TestSelectModeHoldsAGroupBoxStartedAgain` and `TestRelaunchedComparesTheLaunchTheRosterBinds`.
13. **Group references (S3, platform-1e's four tests).** Two group boxes never collide (Task 4's `TestGroupBoxReferencesDoNotCollide`, 200,000 instances); a box can't satisfy another box's report check or enrol under a key it chose (Task 5's `TestABoxCannotStandInForAnother`, Task 4's `TestEnrollAutomaticRefusesAReferenceTheBoxChose` and `TestPrivateBoxReportDerivesAGroupBoxsKey`); an existing non-group box keeps its label and status key byte for byte (Task 6's `TestABoxThatIsNotAGroupBoxKeepsItsLabelAndKey`); and a group box names itself by its reference everywhere, `statusLoop` included (Task 6's `TestAGroupBoxTakesItsGroupReference`).
14. **Ownership is reconciled on a retry (S2).** Task 3's `TestInstallChannelReconcilesOwnershipOnARetry` and `TestInstallChannelRefusesASymlinkedParent`.
15. **A group box never publishes status under a key it doesn't own (T1).** Task 6's `TestAGroupBoxPublishesOnlyUnderItsReference`, through `statusLoop`'s own tick and `WriteBox`: while IMDS fails, another box's report under the colliding label is untouched and nothing is written, with the switches on or off; the reference is asked again each tick, and on recovery the box publishes under it. `TestABoxThatIsNotAGroupBoxKeepsItsLabelAndKey` compares every other box's keys with `statusTick`'s.
16. **Replaced boxes' history never hides the running ones (T2).** Task 6's `TestStaleReportsDoNotHideLiveBoxes`: 250 day-old reports and two live boxes: both live boxes found, and the reading complete.
17. **No store call is issued past its bound, the console waits no longer than its timeout, and an unknown outcome is settled by the roster (U1, U2, V1, V2).** Task 4's `TestTheBoundReachesTheSDKRequest`: through the console's production stores, a successful conditional roster `PUT` and every other SDK request carry 15 seconds from their call, or the earlier write deadline. Task 5's `TestAWriteTheConsoleGaveUpOnCanCommitLater`, the accepted exposure: a `Put` the console gave up on commits after the next pass found the box absent and refused it on a fresh describe without writing, and the pass after that has nothing to do. Task 5's `TestAStoreThatIgnoresItsContextIsCutOffAtTheHardTimeout`: a store that ignores its context and never answers is cut off at the hard timeout, the outcome unknown, the attempt and its wait unchanged. `TestAnUnknownEnrolmentIsSettledByTheRoster`: an enrolling write that did not land goes on with the same attempt and enrols next pass; one that landed, whether cut off in flight or answering success only after the deadline, is unknown, and the next pass, reading the roster, has nothing left to do: no step and no describe.
18. **host.env takes nothing that could change a box's authority unchecked (runHostEnv conditions, U3).** Task 2's `TestWriteTakesOnlyAllowedNamesAndSafeValues`, `TestHostEnvCommand` (no flag moves the path) and `TestWriteGoesOnlyThroughDirectoriesRootAloneCanWrite` (a group-writable `etc/carlos`, a world-writable `etc`, an `etc/carlos` another user owns, a symlinked `etc/carlos` and a symlinked `etc` each refused with nothing written; a normal write lands 0600, replaces the old file by rename, never in place, and leaves no temporary file); Task 6's `TestSelectModeHoldsAStoreValueTheRosterDisagreesWith`; Task 7's `TestRootJoinTakesNothingFromItsTrigger`.

## Dry run

Every code block below was written into this branch on main `6834659c` (plan 2a merged), built and vetted for linux, darwin and windows, and its tests run as each task's steps say, with `nice -n 19` and a `-run` filter; the whole `internal/host` suite was never run. The plan was then replayed from a clean base, task by task in order, by applying its own code blocks: every intermediate state built on all three platforms, vetted, passed its task's tests and all three censuses, and the census values here are those runs'. The copy gate was checked without approving anything: with every row pending, `TestRegionHA2bCopyRecordIsComplete`, `TestRegionHA2bCopyHasNoEmDash` and `TestRegionHA2bBuiltCopyIsApplied` pass, and `TestRegionHA2bBuiltCopyIsApproved` fails naming exactly the built ids (21 after Task 2, 66 after Task 9). With a scratch record that marks every row approved, the applied check passes for all 66 rows, and fails for a row whose text was edited.

Rounds 1 to 5 were dry-run the same way, each from a clean base for every task. Round 6 changed only Task 4's `TestTheBoundReachesTheSDKRequest`, and was dry-run from a clean base through Task 4. Round 4 ran 51 mutations through `go test -overlay` from `$TMPDIR`, never editing source in place, and every one was caught by the test named: 37 kept from rounds 1 to 3 and run again on round 4's code (the group check and `EnrollAutomatic`'s own read; a failed read counted against the attempt or reported as a non-member; no backoff; enrolment without the guard-ready report; seed overwriting shared values or not reading one back; install-channel not handing its directories over, handing over only the ones it created, or following a symlinked parent; the relaunch hold, or `relaunched` ignoring `pendingTime`; joining marking the box hosting; the bound store never refusing; `Invite` or `enroll` run unbound; a missed deadline counted against the attempt; a group box keeping its label, or every box taken for one; `PrivateBoxReport`, the pass or `checkConfirmed` trusting a chosen key; the reference without the account; `Fetch` without the marker; a log line without the attempt id; the report read under another key; a group box's status tick falling back to its label, or resolving its reference once; the status listing ignoring write times, or keeping history; the bound with no deadline context; a read of exactly 60 seconds taken as fresh, and a store call exactly at the deadline issued; no allowlist; no cross-check against the roster; and root's join reading its trigger), and 14 for U1 to U3 (no hard per-call timeout; a success after the deadline accepted; an unanswered call reported as success; a cut-off call taken at the store's word; an unknown counted against the attempt, or the attempt forgotten; and for host.env, symlinks followed, `etc` opened unchecked, a group- or world-writable directory accepted, a directory another user owns accepted, the file rewritten in place, the temporary file left behind, `--out` accepted again, and a write that bypasses the held directory). Round 5 changed only a comment in production code, so those stand; it added 9 mutations, all caught. For V1, `TestAWriteTheConsoleGaveUpOnCanCommitLater` catches a cut-off write reported as success and an absent box enrolled without a fresh describe. For V2, `TestTheBoundReachesTheSDKRequest` catches the bound passing its own context instead of the per-call one, the bound dropping the context, `S3Store.Get` or `S3Store.Put` dropping it, the mutation boundary's `Get` or its `Put` pre-read dropping it, and the conditional record store's `Get` dropping it. Round 6 (W1) re-ran it with the successful conditional `PUT` through every wrapper, and 8 more mutations were caught: the deadline store's `Put`, the mutation boundary's `Put` forwarding and the conditional record store's `Put` forwarding each dropping the context, the bound passing its own context, the boundary's pre-read, `S3Store.Put` and `S3Store.Get` dropping it, and the roster `PUT` sent without `If-Match`. The fsyncs are not observable in a test and were not mutated. The claim-authority census reads source with `go/parser`, which an overlay does not reach, so in round 4 its four mutations overlaid the census test instead, to parse a mutated `enrollpass.go`: with the pass's start removed, its construction duplicated, its invocation removed, or its start duplicated, the census went red each time, and unmutated it stayed green; round 5 does not touch that file. With a scratch record marking every row approved, the copy gate passed, and went red with RHB-C67's text edited. `internal/hostenv` and `internal/hostactors/...` passed in full on the final tree, the new tests also under `-race`, and the console's enrolment and authority-census tests on their own. The code was then removed, leaving the plan, its copy record and the spec.

---

### Task 1: The copy record and its gate

**Files:**
- Create: `docs/superpowers/plans/2026-10-17-region-ha-2b-copy.md`
- Create: `approved_region_ha_2b_copy_test.go`

**Interfaces:**
- Consumes: the run and moves gates' helpers at the repo root (`parseCopyTable`, `checkMovesInventory`, `checkMovesNoEmDash`, `checkRunBuilt`, `checkRunApplied`, `builtRows`, `reportAll`, `copyRow`), as plan 2a's gate does.
- Produces: `regionHA2bCopyBuilt []string`, which Tasks 2, 3, 7 and 9 extend; the record's ids `RHB-C1` to `RHB-C64`, `RHB-C67` and `RHB-C68` (RHB-C62 to RHB-C64 were added in review round 1, RHB-C67 in round 3 and RHB-C68 in round 4, each in its task's table; RHB-C65 and RHB-C66, round 3's operator alert, were withdrawn in round 4 with the read-back, and their ids are not reused).

Every user-facing string 2b writes is drafted here first, all `pending`: the output and refusals of the three new commands, host.env's first line, the descriptions systemd shows for the two join units, the `carlos system` usage lines and the docs. Log lines are not rows (2a's rule). The gate is plan 2a's over this record: an id in `regionHA2bCopyBuilt` must be an approved row whose text is in the file it names.

- [ ] **Step 1: Write the record**

`docs/superpowers/plans/2026-10-17-region-ha-2b-copy.md`:

````markdown
# Region HA plan 2b: copy record (pending Paul's review)

Every user-facing string plan 2b (`2026-10-17-region-ha-2b-joining.md`)
writes: the output and refusals of `carlos system host-env`,
`install-channel` and `join`, the first line of the host.env file it
writes, the operator alert the console's enrolment pass sends, the
descriptions systemd shows for the two join units, the
`carlos system` usage lines, and the docs prose. The gate is
`approved_region_ha_2b_copy_test.go` at the repo root, which reuses the
moves gate's parser, so the format is the moves record's: one table per
heading, columns id, file, text, status.

The rules:

- A row is `pending` until Paul approves it in the copy review (the
  `copy-review` skill). His edits are applied to the text here verbatim
  and the row becomes `approved`.
- No pending string reaches code, CLI help or docs. The task that writes
  a row's text adds its id to `regionHA2bCopyBuilt` in the gate in the
  same commit, and from then on the gate fails unless the row is
  `approved` and its text is in the file it names. Tasks 2, 3, 7 and 9
  therefore wait for their rows' approval.
- In a Go file the text is one string literal, or one `fmt.Sprintf`,
  `fmt.Errorf` or `errors.New` format, equal to the row with a format
  verb where the row has a `<placeholder>`. A row whose file cell says
  "a fragment" is a piece of a larger literal (a usage text).
- Command syntax is exempt: the `usage:` lines, flag names and the
  commands in the docs' code blocks are not rows.
- Log lines are not rows, as in plan 2a: the joining agent's and the
  console enrollment pass's.
- Ids are not renumbered: a row added in review takes the next free id
  and goes in its task's table (RHB-C62 to RHB-C64, round 1; RHB-C65 to
  RHB-C67, round 3; RHB-C68, round 4). A withdrawn row's id is not
  reused: RHB-C65 and RHB-C66, the operator alert of round 3, went with
  the read-back they belonged to in round 4.
- No em dashes. A full stop and a new sentence instead. Refusals start
  lower case, as the CLI's others do.

Placeholders: `<reason>` the text of the error underneath; `<path>` a
file; `<region>` a placement code; `<deployment>` a deployment's name;
`<scope>` a region or `shared`; `<prefix>` a parameter path such as
`/carlos/prod/ie/`; `<n>` a count; `<name>` and `<names>` one or more
variable names; `<variable>` and `<value>` a variable and its value;
`<version>` a platform release such as v0.31.0; `<tag>` its release tag;
`<release>` the host floor's minimum release; `<channel>` a channel such
as stable; `<platform>` such as linux-arm64; `<box>` a box's reference.

## host.env from the deployment's parameters (Task 2)

| id | file | text | status |
|---|---|---|---|
| RHB-C1 | internal/hostenv/hostenv.go | # Written by carlos system host-env write from the deployment's parameters. Do not edit by hand. | pending |
| RHB-C2 | internal/hostenv/hostenv.go | required values are missing | pending |
| RHB-C3 | internal/hostenv/hostenv.go | values conflict | pending |
| RHB-C4 | internal/hostenv/hostenv.go | a name or value host.env cannot hold | pending |
| RHB-C5 | internal/hostenv/hostenv.go | <reason>: <deployment> and <scope> must be lower-case labels | pending |
| RHB-C6 | internal/hostenv/hostenv.go | <reason>: <names> set both for the region and shared | pending |
| RHB-C7 | internal/hostenv/hostenv.go | <reason>: line <n> is not KEY=value | pending |
| RHB-C8 | internal/hostenv/hostenv.go | <reason>: <name> is set twice | pending |
| RHB-C9 | internal/hostenv/hostenv.go | <reason>: <region> is not a region | pending |
| RHB-C10 | internal/hostenv/hostenv.go | parameters under <prefix>: <reason> | pending |
| RHB-C11 | internal/hostenv/hostenv.go | <reason>: the shared <names> already differ | pending |
| RHB-C12 | internal/hostenv/cli.go (a fragment of the usage) | build host.env from the deployment's parameters (root) | pending |
| RHB-C13 | internal/hostenv/cli.go (a fragment of the usage) | copy a box's host.env into the deployment's parameters | pending |
| RHB-C14 | internal/hostenv/cli.go | carlos system host-env write runs as root: it writes a root-owned file | pending |
| RHB-C15 | internal/hostenv/cli.go | host.env was not written: <reason> | pending |
| RHB-C16 | internal/hostenv/cli.go | wrote <path> from the parameters for <region> | pending |
| RHB-C17 | internal/hostenv/cli.go | the parameters were not all written: <reason> | pending |
| RHB-C18 | internal/hostenv/cli.go | wrote <n> parameters under <prefix> and <n> under <prefix> | pending |
| RHB-C64 | internal/hostenv/hostenv.go | already set by another seed | pending |
| RHB-C67 | internal/hostenv/write_linux.go | <path> is not a directory only root can write | pending |
| RHB-C68 | internal/hostenv/write_other.go | carlos system host-env write runs only on Linux | pending |

## carlos system install-channel (Task 3)

| id | file | text | status |
|---|---|---|---|
| RHB-C19 | internal/host/installchannel.go (a fragment of the usage) | Install the release this box's channel names, as root, on a box that has none yet. | pending |
| RHB-C20 | internal/host/installchannel.go | carlos system install-channel runs as root: it installs the platform binary | pending |
| RHB-C21 | internal/host/installchannel.go | <variable>=<value> is not a channel, so nothing was installed | pending |
| RHB-C22 | internal/host/installchannel.go | <version> (<tag>) from the <channel> channel is already installed | pending |
| RHB-C23 | internal/host/installchannel.go | installed <version> (<tag>) from the <channel> channel as carlos and carlos.prev | pending |
| RHB-C24 | internal/host/installchannel.go | an update is running on this box, so nothing was installed: <reason> | pending |
| RHB-C25 | internal/host/installchannel.go | the <channel> channel could not be read, so nothing was installed: <reason> | pending |
| RHB-C26 | internal/host/installchannel.go | the <channel> channel names <version> with no artifact for <platform>, so nothing was installed | pending |
| RHB-C27 | internal/host/installchannel.go | this box's host lifecycle guard is armed, so carlos system update installs its releases | pending |
| RHB-C28 | internal/host/installchannel.go | this box's host lifecycle guard could not be read, so nothing was installed: <reason> | pending |
| RHB-C29 | internal/host/installchannel.go | this box's install record could not be read, so nothing was installed: <reason> | pending |
| RHB-C30 | internal/host/installchannel.go | this box already runs <version>, so carlos system update installs its releases | pending |
| RHB-C31 | internal/host/installchannel.go | <version> could not be fetched, so nothing was installed: <reason> | pending |
| RHB-C32 | internal/host/installchannel.go | <version> did not verify, so nothing was installed: <reason> | pending |
| RHB-C33 | internal/host/installchannel.go | <path> does not hold <version> after the copy | pending |
| RHB-C34 | internal/host/installchannel.go | no host floor is published, so this box could not arm its guard and nothing was installed | pending |
| RHB-C35 | internal/host/installchannel.go | the host floor could not be read, so nothing was installed: <reason> | pending |
| RHB-C36 | internal/host/installchannel.go | the host floor does not verify, so nothing was installed: <reason> | pending |
| RHB-C37 | internal/host/installchannel.go | the <channel> channel names <version> with no release tag, so nothing was installed | pending |
| RHB-C38 | internal/host/installchannel.go | the release identity for <tag> could not be read, so nothing was installed: <reason> | pending |
| RHB-C39 | internal/host/installchannel.go | <version> (<tag>) is below the host floor <release> or is not the release its identity names, so nothing was installed | pending |

## carlos system join (Task 7)

| id | file | text | status |
|---|---|---|---|
| RHB-C40 | internal/host/joinroot.go (a fragment of the usage) | Arm the host lifecycle guard of a joining box, as root, once the console has invited it. | pending |
| RHB-C41 | internal/host/joinroot.go | carlos system join runs as root: it arms the host lifecycle guard | pending |
| RHB-C42 | internal/host/joinroot.go | CARLOS_HOST_CLAIM or CARLOS_REGION_HA is off, so this box does not join | pending |
| RHB-C43 | internal/host/joinroot.go | the invitation for <box> could not be read: <reason> | pending |
| RHB-C44 | internal/host/joinroot.go | the invitation for <box> does not verify, so the guard was not armed | pending |
| RHB-C45 | internal/host/joinroot.go | the identity record for <box> could not be read: <reason> | pending |
| RHB-C46 | internal/host/joinroot.go | the invitation for <box> is for another identity record, so the guard was not armed | pending |
| RHB-C47 | internal/host/joinroot.go | the identity record for <box> is not this instance's, so the guard was not armed | pending |
| RHB-C48 | internal/host/joinroot.go | the host floor could not be read: <reason> | pending |
| RHB-C49 | internal/host/joinroot.go | the host floor does not verify, so the guard was not armed: <reason> | pending |
| RHB-C62 | internal/host/units/carlos-system-join.path | carlos joining: arm the host lifecycle guard on agent request | pending |
| RHB-C63 | internal/host/units/carlos-system-join.service | carlos joining (root: arm the host lifecycle guard) | pending |

## The carlos system usage and the docs (Task 9)

| id | file | text | status |
|---|---|---|---|
| RHB-C50 | system.go (a fragment of the usage) | A box a region's group launches (region HA): | pending |
| RHB-C51 | system.go (a fragment of the usage) | build host.env from the deployment's parameters (root) | pending |
| RHB-C52 | system.go (a fragment of the usage) | copy a box's host.env into the deployment's parameters | pending |
| RHB-C53 | system.go (a fragment of the usage) | install the release this box's channel names (root) | pending |
| RHB-C54 | system.go (a fragment of the usage) | (root oneshot behind carlos-system-join.service; not for operators) | pending |
| RHB-C55 | docs/site/cli.md | Boxes a region's group launches | pending |
| RHB-C56 | docs/site/cli.md | In a region with HA on, the boxes its Auto Scaling group launches set themselves up with no hand steps. A box's start-up runs two commands as root before it starts the agent. | pending |
| RHB-C57 | docs/site/cli.md | carlos system host-env write builds /etc/carlos/host.env from the deployment's parameters: SecureStrings in AWS SSM under /carlos/<deployment>/<region>/ and, for the values every region shares, /carlos/<deployment>/shared/. If a required value is missing, it stops and writes nothing. | pending |
| RHB-C58 | docs/site/cli.md | carlos system install-channel installs the release the box's channel names, as both carlos and carlos.prev, once it passes the published host floor. A box that already runs a release is refused: carlos system update moves it. | pending |
| RHB-C59 | docs/site/cli.md | The agent then joins. It publishes the box's identity record and hosts nothing. Once the console has checked with AWS that the new box belongs to the region's group, it invites the box. The box arms its host lifecycle guard, the console checks the group again and enrolls it, and the box restarts as a hosting box. Only then does the load balancer send it traffic. | pending |
| RHB-C60 | docs/site/cli.md | carlos system host-env seed copies a box's existing host.env into those parameters, once per region. It writes the region's own values and any shared value not yet set. It refuses to change a shared value another region has set. | pending |
| RHB-C61 | docs/site/cli.md | Seeding needs ssm:GetParametersByPath and ssm:PutParameter on the deployment's two parameter paths, and kms:Encrypt and kms:Decrypt on its configuration key. | pending |
````

- [ ] **Step 2: Write the gate**

`approved_region_ha_2b_copy_test.go`:

````go
// approved_region_ha_2b_copy_test.go
package main

import (
	"os"
	"regexp"
	"strconv"
	"testing"
)

// The copy gate of region HA plan 2b (Task 1), plan 2a's gate over plan
// 2b's record. The record is a markdown table in the moves record's
// format, read with the moves gate's parser and checked with the run
// gate's helpers, so "approved" and "applied" mean what they mean there:
//
//   - TestRegionHA2bCopyRecordIsComplete: the record names exactly
//     regionHA2bCopyIDs;
//   - TestRegionHA2bCopyHasNoEmDash;
//   - TestRegionHA2bBuiltCopyIsApproved: an id in regionHA2bCopyBuilt is an
//     approved row. No pending string reaches code, CLI help or docs;
//   - TestRegionHA2bBuiltCopyIsApplied: a built row's text is in the file
//     it names.
const regionHA2bCopyRecordPath = "docs/superpowers/plans/2026-10-17-region-ha-2b-copy.md"

var regionHA2bCopyIDPattern = regexp.MustCompile(`^RHB-C[0-9]+$`)

// regionHA2bCopyIDs is the record's inventory, pinned: RHB-C1 to RHB-C64,
// RHB-C67 and RHB-C68. RHB-C65 and RHB-C66 were withdrawn in review and
// are not reused.
var regionHA2bCopyIDs = func() []string {
	ids := make([]string, 0, 66)
	for i := 1; i <= 64; i++ {
		ids = append(ids, "RHB-C"+strconv.Itoa(i))
	}
	return append(ids, "RHB-C67", "RHB-C68")
}()

// regionHA2bCopyBuilt is every id whose text the code or docs carry. The
// task that writes a row's text adds its ids here in the same commit, once
// Paul has approved the rows.
var regionHA2bCopyBuilt = []string{}

func mustParseRegionHA2bRecord(t *testing.T) []copyRow {
	t.Helper()
	raw, err := os.ReadFile(regionHA2bCopyRecordPath)
	if err != nil {
		t.Fatal(err)
	}
	rows, err := parseCopyTable(string(raw), regionHA2bCopyRecordPath, regionHA2bCopyIDPattern, "an RHB-C<n>")
	if err != nil {
		t.Fatal(err)
	}
	return rows
}

func TestRegionHA2bCopyRecordIsComplete(t *testing.T) {
	reportAll(t, checkMovesInventory(mustParseRegionHA2bRecord(t), regionHA2bCopyIDs))
}

func TestRegionHA2bCopyHasNoEmDash(t *testing.T) {
	reportAll(t, checkMovesNoEmDash(mustParseRegionHA2bRecord(t)))
}

func TestRegionHA2bBuiltCopyIsApproved(t *testing.T) {
	reportAll(t, checkRunBuilt(mustParseRegionHA2bRecord(t), regionHA2bCopyBuilt))
}

func TestRegionHA2bBuiltCopyIsApplied(t *testing.T) {
	reportAll(t, checkRunApplied(builtRows(mustParseRegionHA2bRecord(t), regionHA2bCopyBuilt), "."))
}
````

- [ ] **Step 3: Run it**

Run: `nice -n 19 go test . -run 'RegionHA2b' -count=1`
Expected: PASS (nothing is built yet).

- [ ] **Step 4: Put the record in front of Paul**

Run the `copy-review` skill over the record's rows. Apply his edits verbatim to the record, mark each row he approves `approved`, and commit that on its own. Tasks 2, 3, 7 and 9 wire their rows only once they are approved, with Paul's text. Tasks 4, 5, 6 and 8 do not wait.

- [ ] **Step 5: Commit**

```bash
git add docs/superpowers/plans/2026-10-17-region-ha-2b-copy.md approved_region_ha_2b_copy_test.go
git commit -m "region HA 2b: the copy record and its gate"
```

No package envelope changes: neither file is a non-test Go file.

---

### Task 2: host.env from the deployment's parameters (`internal/hostenv`)

**Files:**
- Create: `internal/hostenv/hostenv.go`, `internal/hostenv/cli.go`, `internal/hostenv/write_linux.go`, `internal/hostenv/write_other.go`, `internal/hostenv/awsssm.go`, `internal/hostenv/hostenv_test.go`, `internal/hostenv/write_linux_test.go`, `internal/hostenv/write_other_test.go`
- Modify: `go.mod`, `go.sum` (`github.com/aws/aws-sdk-go-v2/service/ssm v1.74.0`)
- Modify: `region_ha_boundary_test.go`, `approved_region_ha_2b_copy_test.go`
- Create: `internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv`; Modify: `internal/provision/plan14_root_reads_test.go` (load it last)

**Interfaces:**
- Consumes: `golang.org/x/sys/unix` (already required) for the descriptor-held write.
- Produces (`internal/hostenv`): `const Path = "/etc/carlos/host.env"`, `const SharedScope = "shared"`; `const BoxReference = "CARLOS_BOX_REFERENCE"`, `const GroupReference = "group"` (Task 6 reads them); `var Required, Optional, SharedKeys []string`; `Allowed(name string) bool`; `type Params interface { ByPath(ctx, prefix string) (map[string]string, error); Set(ctx, name, value string) error; SetNew(ctx, name, value string) error }`; `ErrMissing`, `ErrConflict`, `ErrValue`, `ErrExists`; `Prefix(deployment, scope string) (string, error)`; `ValidKey(string) bool`; `Assemble(shared, regional map[string]string) (map[string]string, error)`; `Missing(map[string]string) []string`; `Render(map[string]string) ([]byte, error)`; `Parse([]byte) (map[string]string, error)`; `Split(map[string]string) (shared, regional map[string]string)`; `Fetch(ctx, Params, deployment, region string) ([]byte, error)`; `type Seeded`; `Seed(ctx, Params, deployment, region string, env map[string]string) (Seeded, error)`; `RunSystemHostEnv([]string)` (wired into `carlos system` in Task 9). Unexported: `writeHostEnv(root string, body []byte) error` and `checkedDir` (Linux, `write_linux.go`; elsewhere `writeHostEnv` refuses, `write_other.go`); the test seams `var hostEnvRoot = "/"` (the root the fixed path is under) and `var hostEnvOwner = os.Geteuid`.
- Produces (adapter, `awsssm.go`): `openSSMParams(ctx, awsRegion, kmsKey string) (Params, error)` over SSM `GetParametersByPath` (decrypted, paged; any failed page fails the read) and `PutParameter` (SecureString, the deployment's KMS key): `Set` with `Overwrite: true`, `SetNew` with `Overwrite: false`, SSM's `ParameterAlreadyExists` mapped to `ErrExists`.
- Produces: the census overlay every later task edits, and its load in `TestPlan14RootReadCensus`.

This task writes rows RHB-C1 to RHB-C18, RHB-C64, RHB-C67 and RHB-C68 of the copy record. Build it only once Paul has approved them, and where his text differs from a draft, use his in `internal/hostenv` too: the strings in the code below are the drafts. Adding the ids to `regionHA2bCopyBuilt` makes the gate hold the code to the approved text.

The core treats host.env as a file and names no provider (§25): `Fetch` assembles it from a `Params`, `Seed` writes a box's existing file into one. The SSM adapter is the only file that imports AWS. The parameter store's write methods are `Set` and `SetNew`, not `Put`, so the record-write census (which matches the name `Put`) has nothing to say about them; `SetNew` is not `Create` either, which the root-read census reads as a file open.

Shared values are written create-only (R4). Two seeds can both read a shared variable as unset and both try to write it; `SetNew` lets only the first succeed, and the second reads the value back and goes on only when it is the value it would have written, otherwise it refuses with `ErrConflict`, naming the variable and never a value. A region's own values keep `Set`, which overwrites.

`Fetch` adds `CARLOS_BOX_REFERENCE=group` (S3, read in Task 6): `write` runs only in a group box's start-up, so a box whose host.env it wrote is a group box, and takes its group reference. `Seed` never writes that line as a parameter, and `Assemble` ignores a parameter of that name.

**The ruling's conditions on `runHostEnv` (census ruling region-ha-2b).**
1. No box writes the parameters it reads: the box role has no `ssm:PutParameter` or `ssm:DeleteParameter` on either path; only the operator's (and the console's) principal writes. That is 2c's to grant and to prove with a negative check (2c's seam 3).
2. `write` takes only allowlisted names (`Required` and `Optional`; `Allowed`): any other name, under either scope, is refused by name, and `seed` refuses a file that carries one. Values are restricted to the safe set, so a newline, a carriage return, a NUL, whitespace, quotes or a `$` (anything that could start a second key or change how systemd reads the line) is refused, naming the variable and never the value. The path is fixed at `/etc/carlos/host.env` (U3): `write` has no `--out`, and tests reach the code through the unexported `hostEnvRoot`, never a flag. `writeHostEnv` holds `/etc` and then `/etc/carlos` by descriptor, each opened `O_NOFOLLOW|O_DIRECTORY` under the one before (`checkedDir`), so a symlink at either is refused and neither is looked up by name again; each must be a directory, owned by root (`write` already refuses any other user), and writable by no group or other, or `write` refuses it (RHB-C67). A missing `/etc/carlos` is created 0755, then opened and checked like the rest. The file is created 0600, `O_EXCL|O_NOFOLLOW`, under a random name inside the held directory (`openat`), written and fsynced, renamed over `host.env` within the same directory, and the directory fsynced: a reader sees the old file or the new one, never part of either, and no other user can swap the file or the directory under it. The descriptor calls are Linux's (`//go:build linux`); elsewhere `writeHostEnv` refuses (`write_other.go`, RHB-C68), so darwin and windows build.
3. The values that select authority, the group marker and `CARLOS_REGION`, are cross-checked against the signed roster at the agent's use, in mode selection (Task 6), not here: at boot there is no roster entry for the box to check against, and before enrolment neither value grants anything. Keys that would select authority with no roster to check them by are kept off the allowlist (fleet and tunnel identity, address overrides, pools, directories, relay settings, tokens), and so are the alert webhook and the status destination overrides ("Global Constraints", host.env, says why).

- [ ] **Step 1: Write the failing tests**

`internal/hostenv/hostenv_test.go`:

````go
package hostenv

import (
	"bytes"
	"context"
	"errors"
	"os"
	"path/filepath"
	"reflect"
	"runtime"
	"slices"
	"strings"
	"testing"
)

// fakeParams is a parameter store in memory. failPrefix makes ByPath fail
// for one prefix, as an unreachable or refused SSM call would. staleOnce
// makes the next ByPath of that prefix answer as it stood before every
// value now under it was written: a seed that read just before another
// seed wrote.
type fakeParams struct {
	vals       map[string]string
	sets       []string
	failPrefix string
	staleOnce  string
}

func (f *fakeParams) ByPath(_ context.Context, prefix string) (map[string]string, error) {
	if prefix == f.failPrefix {
		return nil, errors.New("AccessDeniedException")
	}
	if prefix == f.staleOnce {
		f.staleOnce = ""
		return map[string]string{}, nil
	}
	out := map[string]string{}
	for name, v := range f.vals {
		if rest, ok := strings.CutPrefix(name, prefix); ok && !strings.Contains(rest, "/") {
			out[rest] = v
		}
	}
	return out, nil
}

func (f *fakeParams) Set(_ context.Context, name, value string) error {
	f.vals[name] = value
	f.sets = append(f.sets, name)
	return nil
}

func (f *fakeParams) SetNew(_ context.Context, name, value string) error {
	if _, ok := f.vals[name]; ok {
		return ErrExists
	}
	f.vals[name] = value
	f.sets = append(f.sets, name)
	return nil
}

// today is a hosting box's host.env as an operator has it today.
func today() map[string]string {
	return map[string]string{
		"CARLOS_ALERT_SNS_TOPIC_ARN": "arn:aws:sns:eu-west-1:123456789012:carlos-alerts",
		"CARLOS_DEPLOYMENT_BUCKET":   "deploy-bucket",
		"CARLOS_HIBERNATE_BUCKET":    "data-ie",
		"CARLOS_HIBERNATE_REGION":    "eu-west-1",
		"CARLOS_HOST_CLAIM":          "1",
		"CARLOS_HOST_CLAIM_ORIGINS":  "1",
		"CARLOS_REGION":              "ie",
		"CARLOS_REGION_HA":           "1",
		"CARLOS_RELEASE_PUBKEY":      "q3J0bXlwdWJsaWNrZXk+/=",
		"CARLOS_SECRETS_KEY":         "c2VjcmV0c2tleQ==",
		"CARLOS_SYSTEM_CHANNEL":      "stable",
		"CARLOS_HIBERNATE_PREFIX":    "fleet",
	}
}

func TestSeedThenFetchIsTodaysFile(t *testing.T) {
	ctx := context.Background()
	p := &fakeParams{vals: map[string]string{}}
	s, err := Seed(ctx, p, "flagship", "ie", today())
	if err != nil {
		t.Fatal(err)
	}
	if s.Shared != len(SharedKeys) || s.Regional != len(today())-len(SharedKeys) || s.SharedPrefix != "/carlos/flagship/shared/" || s.RegionPrefix != "/carlos/flagship/ie/" {
		t.Fatalf("seeded %+v", s)
	}
	if p.vals["/carlos/flagship/shared/CARLOS_RELEASE_PUBKEY"] != today()["CARLOS_RELEASE_PUBKEY"] || p.vals["/carlos/flagship/ie/CARLOS_REGION"] != "ie" {
		t.Fatalf("layout %v", p.vals)
	}
	// Another region's parameter, and a non-variable under shared, are not
	// this region's host.env.
	p.vals["/carlos/flagship/uk/CARLOS_REGION"] = "uk"
	p.vals["/carlos/flagship/shared/bootstrap-sha256"] = strings.Repeat("a", 64)
	body, err := Fetch(ctx, p, "flagship", "ie")
	if err != nil {
		t.Fatal(err)
	}
	got, err := Parse(body)
	if err != nil {
		t.Fatal(err)
	}
	// A box write assembles host.env for is one its group launched: it
	// takes its group reference (BoxReference), which is never a parameter.
	want := today()
	want[BoxReference] = GroupReference
	if !reflect.DeepEqual(got, want) {
		t.Fatalf("round trip:\n got %v\nwant %v", got, want)
	}
	if !bytes.HasPrefix(body, []byte("# ")) || !bytes.Contains(body, []byte("\nCARLOS_ALERT_SNS_TOPIC_ARN=arn:aws:sns:eu-west-1:123456789012:carlos-alerts\nCARLOS_BOX_REFERENCE=group\nCARLOS_DEPLOYMENT_BUCKET=deploy-bucket\n")) {
		t.Fatalf("not sorted KEY=value lines:\n%s", body)
	}
}

func TestFetchRefusesAMissingRequiredValue(t *testing.T) {
	ctx := context.Background()
	for _, k := range Required {
		p := &fakeParams{vals: map[string]string{}}
		env := today()
		if _, err := Seed(ctx, p, "flagship", "ie", env); err != nil {
			t.Fatal(err)
		}
		for name := range p.vals {
			if strings.HasSuffix(name, "/"+k) {
				delete(p.vals, name)
			}
		}
		_, err := Fetch(ctx, p, "flagship", "ie")
		if !errors.Is(err, ErrMissing) || !strings.Contains(err.Error(), k) {
			t.Fatalf("without %s: %v", k, err)
		}
	}
}

func TestFetchRefusesAFailedReadAndAConflict(t *testing.T) {
	ctx := context.Background()
	p := &fakeParams{vals: map[string]string{}}
	if _, err := Seed(ctx, p, "flagship", "ie", today()); err != nil {
		t.Fatal(err)
	}
	// A refused read is never an empty scope: nothing is assembled.
	p.failPrefix = "/carlos/flagship/shared/"
	if _, err := Fetch(ctx, p, "flagship", "ie"); err == nil || errors.Is(err, ErrMissing) {
		t.Fatalf("failed read: %v", err)
	}
	p.failPrefix = ""
	// A variable in both scopes is one of them wrong.
	p.vals["/carlos/flagship/ie/CARLOS_RELEASE_PUBKEY"] = "other"
	if _, err := Fetch(ctx, p, "flagship", "ie"); !errors.Is(err, ErrConflict) || !strings.Contains(err.Error(), "CARLOS_RELEASE_PUBKEY") {
		t.Fatalf("conflict: %v", err)
	}
}

func TestRenderRefusesAnUnsafeValueWithoutPrintingIt(t *testing.T) {
	env := today()
	env["CARLOS_SECRETS_KEY"] = "abc$(reboot)"
	_, err := Render(env)
	if !errors.Is(err, ErrValue) || !strings.Contains(err.Error(), "CARLOS_SECRETS_KEY") || strings.Contains(err.Error(), "reboot") {
		t.Fatalf("unsafe value: %v", err)
	}
	for _, v := range []string{"a b", "a'b", `a"b`, "a\\b", "a\nb", "a#b", "a;b"} {
		if _, err := Render(map[string]string{"CARLOS_X": v}); !errors.Is(err, ErrValue) {
			t.Fatalf("%q rendered: %v", v, err)
		}
	}
}

func TestSeedNeverChangesASharedValue(t *testing.T) {
	ctx := context.Background()
	p := &fakeParams{vals: map[string]string{}}
	if _, err := Seed(ctx, p, "flagship", "ie", today()); err != nil {
		t.Fatal(err)
	}
	// A second region with the same shared values writes only its own.
	uk := today()
	uk["CARLOS_REGION"], uk["CARLOS_HIBERNATE_BUCKET"] = "uk", "data-uk"
	p.sets = nil
	s, err := Seed(ctx, p, "flagship", "uk", uk)
	if err != nil {
		t.Fatal(err)
	}
	if s.Shared != 0 {
		t.Fatalf("rewrote shared values: %v", p.sets)
	}
	// A third region whose file disagrees on a shared value writes nothing.
	au := today()
	au["CARLOS_REGION"], au["CARLOS_SECRETS_KEY"] = "au", "b3RoZXI="
	p.sets = nil
	if _, err := Seed(ctx, p, "flagship", "au", au); !errors.Is(err, ErrConflict) || len(p.sets) != 0 {
		t.Fatalf("conflicting shared value: %v, wrote %v", err, p.sets)
	}
	// A group box's own host.env seeds no BoxReference.
	grp := today()
	grp[BoxReference] = GroupReference
	p.sets = nil
	if _, err := Seed(ctx, p, "flagship", "ie", grp); err != nil || slices.ContainsFunc(p.sets, func(n string) bool { return strings.HasSuffix(n, BoxReference) }) {
		t.Fatalf("seeded %v: %v", p.sets, err)
	}
	// An incomplete file writes nothing either.
	partial := today()
	delete(partial, "CARLOS_REGION_HA")
	p.sets = nil
	if _, err := Seed(ctx, p, "flagship", "au", partial); !errors.Is(err, ErrMissing) || len(p.sets) != 0 {
		t.Fatalf("incomplete file: %v, wrote %v", err, p.sets)
	}
}

// R4: two seeds racing for a shared value. The second read before the
// first wrote, so both try to create it; the second's SetNew is refused,
// it reads the value back, and it goes on only if the value is its own.
func TestSeedRacesForASharedValueAndOnlyOneWins(t *testing.T) {
	ctx := context.Background()
	p := &fakeParams{vals: map[string]string{}}
	if _, err := Seed(ctx, p, "flagship", "ie", today()); err != nil {
		t.Fatal(err)
	}
	// The same shared values: nothing shared is written, the region's are.
	uk := today()
	uk["CARLOS_REGION"] = "uk"
	p.staleOnce, p.sets = "/carlos/flagship/shared/", nil
	s, err := Seed(ctx, p, "flagship", "uk", uk)
	if err != nil || s.Shared != 0 || s.Regional != len(today())-len(SharedKeys) {
		t.Fatalf("same values: %+v %v, wrote %v", s, err, p.sets)
	}
	// A different secrets key: refused, naming the variable and never
	// either value, and the first seed's value stands.
	au := today()
	au["CARLOS_REGION"], au["CARLOS_SECRETS_KEY"] = "au", "b3RoZXI="
	p.staleOnce, p.sets = "/carlos/flagship/shared/", nil
	_, err = Seed(ctx, p, "flagship", "au", au)
	if !errors.Is(err, ErrConflict) || !strings.Contains(err.Error(), "CARLOS_SECRETS_KEY") || strings.Contains(err.Error(), "b3RoZXI=") || strings.Contains(err.Error(), today()["CARLOS_SECRETS_KEY"]) {
		t.Fatalf("racing seed: %v", err)
	}
	if p.vals["/carlos/flagship/shared/CARLOS_SECRETS_KEY"] != today()["CARLOS_SECRETS_KEY"] || len(p.sets) != 0 {
		t.Fatalf("the second seed changed or wrote %v", p.sets)
	}
}

func TestPrefixAndParse(t *testing.T) {
	if _, err := Prefix("flagship", "../x"); !errors.Is(err, ErrValue) {
		t.Fatalf("bad scope: %v", err)
	}
	if _, err := Fetch(context.Background(), &fakeParams{}, "flagship", SharedScope); !errors.Is(err, ErrValue) {
		t.Fatalf("shared as a region: %v", err)
	}
	got, err := Parse([]byte("# comment\n\nexport CARLOS_REGION=\"ie\"\nCARLOS_HOST_CLAIM='1'\n"))
	if err != nil || !reflect.DeepEqual(got, map[string]string{"CARLOS_REGION": "ie", "CARLOS_HOST_CLAIM": "1"}) {
		t.Fatalf("parse: %v %v", got, err)
	}
	for _, body := range []string{"CARLOS_REGION=ie\nCARLOS_REGION=uk\n", "not an assignment\n", "lower=x\n"} {
		if _, err := Parse([]byte(body)); !errors.Is(err, ErrValue) {
			t.Fatalf("%q parsed: %v", body, err)
		}
	}
}

// The command: write refuses without root and leaves no file on a failed
// assembly; with root it writes 0600; seed reads the named file.
func TestHostEnvCommand(t *testing.T) {
	ctx := context.Background()
	dir := withHostEnvRoot(t)
	out := filepath.Join(dir, "etc", "carlos", "host.env")
	p := &fakeParams{vals: map[string]string{}}
	open := func(context.Context, string, string) (Params, error) { return p, nil }
	var stdout bytes.Buffer
	args := []string{"write", "--deployment", "flagship", "--region", "ie"}
	if err := runHostEnv(ctx, args, &stdout, 1000, open); err == nil || !strings.Contains(err.Error(), "root") {
		t.Fatalf("non-root write: %v", err)
	}
	if err := runHostEnv(ctx, args, &stdout, 0, open); !errors.Is(err, ErrMissing) {
		t.Fatalf("write with no parameters: %v", err)
	}
	if _, err := os.Stat(out); !os.IsNotExist(err) {
		t.Fatalf("a failed write left a file: %v", err)
	}
	from := filepath.Join(dir, "today.env")
	body, _ := Render(today())
	if err := os.WriteFile(from, body, 0o600); err != nil {
		t.Fatal(err)
	}
	seed := []string{"seed", "--deployment", "flagship", "--region", "ie", "--kms-key", "alias/carlos-flagship-config", "--from", from}
	if err := runHostEnv(ctx, seed, &stdout, 1000, open); err != nil {
		t.Fatal(err)
	}
	if !strings.Contains(stdout.String(), "wrote 7 parameters under /carlos/flagship/ie/ and 5 under /carlos/flagship/shared/\n") {
		t.Fatalf("output %q", stdout.String())
	}
	err := runHostEnv(ctx, args, &stdout, 0, open)
	if runtime.GOOS != "linux" {
		if err == nil || !strings.Contains(err.Error(), "runs only on Linux") {
			t.Fatalf("write off Linux: %v", err)
		}
	} else {
		if err != nil {
			t.Fatal(err)
		}
		fi, err := os.Stat(out)
		if err != nil || fi.Mode().Perm() != 0o600 {
			t.Fatalf("host.env: %v %v", fi, err)
		}
		if !strings.Contains(stdout.String(), "wrote "+out+" from the parameters for ie\n") {
			t.Fatalf("output %q", stdout.String())
		}
	}
	// The path is fixed: there is no flag to move it.
	for _, bad := range [][]string{nil, {"seed", "--deployment", "flagship", "--region", "ie"}, {"write", "--region", "ie"}, {"read"}, append(args, "--out", filepath.Join(dir, "elsewhere"))} {
		if err := runHostEnv(ctx, bad, &stdout, 0, open); err == nil || !strings.HasPrefix(err.Error(), "usage: carlos system host-env") {
			t.Fatalf("%v: %v", bad, err)
		}
	}
}

// runHostEnv's census conditions (platform-1e, region-ha-2b): write takes
// only allowlisted names, refuses values that could start a second line,
// end the file or change how systemd reads it, and writes through no
// symlinked directory. A refusal names the variable, never the value.
func TestWriteTakesOnlyAllowedNamesAndSafeValues(t *testing.T) {
	ctx := context.Background()
	p := &fakeParams{vals: map[string]string{}}
	if _, err := Seed(ctx, p, "flagship", "ie", today()); err != nil {
		t.Fatal(err)
	}
	// A name off the allowlist, under either scope, is refused by name.
	for _, name := range []string{"/carlos/flagship/ie/CARLOS_PRIVATE_IP", "/carlos/flagship/shared/CARLOS_FLEET"} {
		p.vals[name] = "10.0.0.9"
		_, err := Fetch(ctx, p, "flagship", "ie")
		short := name[strings.LastIndex(name, "/")+1:]
		if !errors.Is(err, ErrValue) || !strings.Contains(err.Error(), short) || strings.Contains(err.Error(), "10.0.0.9") {
			t.Fatalf("%s: %v", name, err)
		}
		delete(p.vals, name)
	}
	// Seeding a file with such a name writes nothing.
	odd := today()
	odd["CARLOS_POOLS"] = "gold"
	p.sets = nil
	if _, err := Seed(ctx, p, "flagship", "uk", odd); !errors.Is(err, ErrValue) || !strings.Contains(err.Error(), "CARLOS_POOLS") || len(p.sets) != 0 {
		t.Fatalf("seed with an unknown name: %v, wrote %v", err, p.sets)
	}
	// Values that would break a line, end the file early or change how
	// systemd reads it.
	for _, v := range []string{"a\nCARLOS_REGION=uk", "a\x00b", "a\rb", "$(id)", "a b", "\"quoted\""} {
		p.vals["/carlos/flagship/ie/CARLOS_HIBERNATE_PREFIX"] = v
		if _, err := Fetch(ctx, p, "flagship", "ie"); !errors.Is(err, ErrValue) || strings.Contains(err.Error(), v) {
			t.Fatalf("%q: %v", v, err)
		}
	}
}

// withHostEnvRoot points write's fixed path at a directory of the test's
// own, with an etc in it as a box has one.
func withHostEnvRoot(t *testing.T) string {
	t.Helper()
	root := t.TempDir()
	if err := os.Mkdir(filepath.Join(root, "etc"), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.Chmod(filepath.Join(root, "etc"), 0o755); err != nil {
		t.Fatal(err)
	}
	was := hostEnvRoot
	hostEnvRoot = root
	t.Cleanup(func() { hostEnvRoot = was })
	return root
}
````

The checked write's own tests, Linux's and the refusal elsewhere:

`internal/hostenv/write_linux_test.go`:

````go
//go:build linux

package hostenv

import (
	"os"
	"path/filepath"
	"strings"
	"testing"
)

// U3 (census condition 2 on runHostEnv): write's path is fixed, and the
// file is written only through etc and etc/carlos held by descriptor,
// each a real directory this user owns and nobody else can write.
func TestWriteGoesOnlyThroughDirectoriesRootAloneCanWrite(t *testing.T) {
	read := func(root string) string {
		b, _ := os.ReadFile(filepath.Join(root, "etc", "carlos", "host.env"))
		return string(b)
	}
	t.Run("lands whole, and replaces whole", func(t *testing.T) {
		root := withHostEnvRoot(t)
		if err := writeHostEnv(root, []byte("CARLOS_REGION=ie\n")); err != nil {
			t.Fatal(err)
		}
		fi, err := os.Stat(filepath.Join(root, "etc", "carlos"))
		if err != nil || fi.Mode().Perm()&0o022 != 0 || !fi.IsDir() {
			t.Fatalf("created directory %v %v", fi, err)
		}
		first, err := os.Stat(filepath.Join(root, "etc", "carlos", "host.env"))
		if err != nil {
			t.Fatal(err)
		}
		if err := writeHostEnv(root, []byte("CARLOS_REGION=uk\n")); err != nil {
			t.Fatal(err)
		}
		f, err := os.Stat(filepath.Join(root, "etc", "carlos", "host.env"))
		if err != nil || f.Mode().Perm() != 0o600 || read(root) != "CARLOS_REGION=uk\n" {
			t.Fatalf("host.env %v %v %q", f, err, read(root))
		}
		// Replaced by rename, not rewritten in place: a reader holding the
		// old file never sees part of the new one.
		if os.SameFile(first, f) {
			t.Fatal("host.env was rewritten in place")
		}
		ents, _ := os.ReadDir(filepath.Join(root, "etc", "carlos"))
		if len(ents) != 1 {
			t.Fatalf("left behind: %v", ents)
		}
	})
	for name, spoil := range map[string]func(t *testing.T, root string){
		"a group-writable etc/carlos": func(t *testing.T, root string) {
			dir := filepath.Join(root, "etc", "carlos")
			if err := os.Mkdir(dir, 0o755); err != nil {
				t.Fatal(err)
			}
			if err := os.Chmod(dir, 0o775); err != nil {
				t.Fatal(err)
			}
		},
		"a world-writable etc": func(t *testing.T, root string) {
			if err := os.Chmod(filepath.Join(root, "etc"), 0o757); err != nil {
				t.Fatal(err)
			}
		},
		"an etc/carlos another user owns": func(t *testing.T, root string) {
			if err := os.Mkdir(filepath.Join(root, "etc", "carlos"), 0o755); err != nil {
				t.Fatal(err)
			}
			was := hostEnvOwner
			hostEnvOwner = func() int { return was() + 1 }
			t.Cleanup(func() { hostEnvOwner = was })
		},
		"a symlinked etc/carlos": func(t *testing.T, root string) {
			elsewhere := safeDir(t)
			if err := os.Symlink(elsewhere, filepath.Join(root, "etc", "carlos")); err != nil {
				t.Fatal(err)
			}
		},
		"a symlinked etc, an ancestor": func(t *testing.T, root string) {
			elsewhere := safeDir(t)
			if err := os.Mkdir(filepath.Join(elsewhere, "carlos"), 0o755); err != nil {
				t.Fatal(err)
			}
			if err := os.Remove(filepath.Join(root, "etc")); err != nil {
				t.Fatal(err)
			}
			if err := os.Symlink(elsewhere, filepath.Join(root, "etc")); err != nil {
				t.Fatal(err)
			}
		},
	} {
		t.Run(name, func(t *testing.T) {
			root := withHostEnvRoot(t)
			spoil(t, root)
			err := writeHostEnv(root, []byte("CARLOS_REGION=ie\n"))
			if err == nil || !strings.Contains(err.Error(), "is not a directory only root can write") {
				t.Fatalf("%v", err)
			}
			if got := read(root); got != "" {
				t.Fatalf("wrote %q", got)
			}
		})
	}
}

// safeDir is a directory that passes every check but the one under test:
// this user's, 0755 whatever the umask, so a symlink to it is refused only
// for being a symlink.
func safeDir(t *testing.T) string {
	t.Helper()
	dir := t.TempDir()
	if err := os.Chmod(dir, 0o755); err != nil {
		t.Fatal(err)
	}
	return dir
}
````

`internal/hostenv/write_other_test.go`:

````go
//go:build !linux

package hostenv

import "testing"

// U3: off Linux there is no checked write, so write refuses and leaves
// nothing behind.
func TestWriteRefusesOffLinux(t *testing.T) {
	root := t.TempDir()
	err := writeHostEnv(root, []byte("CARLOS_REGION=ie\n"))
	if err == nil || err.Error() != "carlos system host-env write runs only on Linux" {
		t.Fatalf("%v", err)
	}
}
````

- [ ] **Step 2: Run them to see them fail**

Run: `nice -n 19 go test ./internal/hostenv/ -count=1`
Expected: FAIL, the package does not build (`undefined: Seed`, `undefined: Params`, ...).

- [ ] **Step 3: Add the SSM SDK**

The module's AWS core is v1.44.0; ssm v1.74.0 is the release built against it, so nothing else moves. Its sums match sum.golang.org.

`go.mod`:

````diff
--- a/go.mod
+++ b/go.mod
@@ -15,6 +15,7 @@
 	github.com/aws/aws-sdk-go-v2/service/route53 v1.64.1
 	github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0
 	github.com/aws/aws-sdk-go-v2/service/sesv2 v1.66.6
+	github.com/aws/aws-sdk-go-v2/service/ssm v1.74.0
 	github.com/aws/aws-sdk-go-v2/service/sts v1.45.6
 	github.com/aws/smithy-go v1.28.1
 	github.com/benbjohnson/litestream v0.5.14
````

`go.sum`:

````diff
--- a/go.sum
+++ b/go.sum
@@ -68,6 +68,8 @@
 github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU=
 github.com/aws/aws-sdk-go-v2/service/sns v1.43.0 h1:VPYjwn0BoX34hb44OT8T+Ikgn4NzsN7fHetaHaevsDc=
 github.com/aws/aws-sdk-go-v2/service/sns v1.43.0/go.mod h1:I1vnLPvvi9KBqxddu8nJ4vktoPJvaIG05UmjBD9sqm8=
+github.com/aws/aws-sdk-go-v2/service/ssm v1.74.0 h1:11o/Z2uQatZTTWrkwN4MMPjhcVGnVQtLra5Md1tSSq0=
+github.com/aws/aws-sdk-go-v2/service/ssm v1.74.0/go.mod h1:uF4ifVWSlieuvF0ZSy9m6flJJojvWuEu80pJIO3UJ0U=
 github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 h1:aM/Q24rIlS3bRAhTyFurowU8A0SMyGDtEOY/l/s/1Uw=
 github.com/aws/aws-sdk-go-v2/service/sso v1.30.8/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg=
 github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE=
````

- [ ] **Step 4: Write the package**

`internal/hostenv/hostenv.go`:

````go
// Package hostenv is a box's /etc/carlos/host.env as a parameter layout
// (region HA spec 2 §5 step 2, §4). Every value a hosting box reads from
// host.env is one parameter, named for its variable, under
// /carlos/<deployment>/<region>/, or under /carlos/<deployment>/shared/
// for the values every region of the deployment holds alike. A box's
// start-up assembles host.env from the two; an operator seeds them from a
// box's existing file.
//
// Where the parameters live is an adapter's business (awsssm.go, AWS SSM
// SecureStrings). This file names no provider (§25): host.env is a file,
// and Params is all the core asks of a parameter store.
package hostenv

import (
	"bufio"
	"bytes"
	"context"
	"errors"
	"fmt"
	"regexp"
	"slices"
	"sort"
	"strings"
)

// Path is the file a box's units read their environment from.
const Path = "/etc/carlos/host.env"

// SharedScope is the scope of the values every region holds alike.
const SharedScope = "shared"

// Required are the variables a hosting box cannot run without (§5 step 2):
// the buckets and their region, the release and secrets keys, the box's
// region and channel, the alert topic, and the claim and region HA
// switches. Assembling host.env refuses without any of them.
var Required = []string{
	"CARLOS_ALERT_SNS_TOPIC_ARN",
	"CARLOS_DEPLOYMENT_BUCKET",
	"CARLOS_HIBERNATE_BUCKET",
	"CARLOS_HIBERNATE_REGION",
	"CARLOS_HOST_CLAIM",
	"CARLOS_HOST_CLAIM_ORIGINS",
	"CARLOS_REGION",
	"CARLOS_REGION_HA",
	"CARLOS_RELEASE_PUBKEY",
	"CARLOS_SECRETS_KEY",
	"CARLOS_SYSTEM_CHANNEL",
}

// Optional are the other variables a box its region's group launched may
// carry, from the agent's config surface (README's host.env list and the
// variables the agent reads). Together with Required they are the
// allowlist: any other name, under either scope, is refused by name,
// never by value, by write (Assemble) and by seed. The list leaves out
// the keys that select a box's authority (a fleet's or tunnel's identity,
// an address override, a pool, the data, opt and deployment directories,
// the relay's settings) and every token or API credential, except the
// deployment's own required secrets, CARLOS_SECRETS_KEY and
// CARLOS_RELEASE_PUBKEY. CARLOS_ALERT_WEBHOOK is left out on the same
// rule: a webhook URL can carry a credential. So are the status
// destination overrides, CARLOS_STATUS_BUCKET, CARLOS_STATUS_PREFIX and
// CARLOS_STATUS_REGION: a group box reports to the deployment bucket,
// unprefixed, where the console reads its guard-ready report, while it is
// joining and once it is hosting. An endpoint override such as
// CARLOS_S3_ENDPOINT stays, because it selects no box authority and only
// operators can write the parameter paths. CARLOS_REGION and the group
// marker, which do select, are cross-checked against the signed roster
// where the agent uses them (internal/host's mode selection). Adding a
// variable is a code change, reviewed.
var Optional = []string{
	"CARLOS_ACME_DELEGATION_ZONE",
	"CARLOS_ACME_DIRECTORY",
	"CARLOS_ALERT_CPU_PCT",
	"CARLOS_ALERT_DISK_PCT",
	"CARLOS_ALERT_MEM_PCT",
	"CARLOS_AWS_REGION",
	"CARLOS_BACKHAUL_MAX_INFLIGHT",
	"CARLOS_DNS_PROVIDER",
	"CARLOS_EDGE_GZIP",
	"CARLOS_HIBERNATE_IDLE",
	"CARLOS_HIBERNATE_PREFIX",
	"CARLOS_HOST_CLAIM_RETIRED_LOCATIONS",
	"CARLOS_HOST_METRICS",
	"CARLOS_LAYOUT_DEFAULT",
	"CARLOS_MOVES_PHASE1",
	"CARLOS_MOVE_READY_DELTA_BYTES",
	"CARLOS_REQUIRE_POINTER_DIGEST",
	"CARLOS_S3_ENDPOINT",
	"CARLOS_S3_QUIRKS",
	"CARLOS_STEERING_ZONE_ID",
	"CARLOS_SWAP",
	"CARLOS_UNIT_SETTLE",
	"CARLOS_VERSIONS_GRACE",
	"CARLOS_WILDCARD_DOMAINS",
	"STATUS_BOX_REGION",
}

// Allowed reports whether name is a variable host.env may carry from the
// parameters: Required or Optional.
func Allowed(name string) bool {
	return slices.Contains(Required, name) || slices.Contains(Optional, name)
}

// notAllowed lists, sorted, the names in env that are not Allowed.
func notAllowed(env map[string]string) []string {
	var out []string
	for k := range env {
		if k != BoxReference && !Allowed(k) {
			out = append(out, k)
		}
	}
	sort.Strings(out)
	return out
}

// SharedKeys are the variables that are the same in every region of a
// deployment, so they are kept once, under the shared scope. Everything
// else is the region's own.
var SharedKeys = []string{
	"CARLOS_DEPLOYMENT_BUCKET",
	"CARLOS_HOST_CLAIM",
	"CARLOS_HOST_CLAIM_ORIGINS",
	"CARLOS_RELEASE_PUBKEY",
	"CARLOS_SECRETS_KEY",
}

// BoxReference and GroupReference are the one line host.env carries that
// is not a parameter. write runs only in the start-up of a box its
// region's group launched, so a host.env it wrote marks such a box, and
// the box takes the reference its account and instance derive
// (hostactors.GroupBoxReference) instead of a status label. Every other
// box's host.env lacks it and keeps its label.
const (
	BoxReference   = "CARLOS_BOX_REFERENCE"
	GroupReference = "group"
)

// Params is a store of named, encrypted parameters.
type Params interface {
	// ByPath returns every parameter directly under prefix, keyed by its
	// name after the prefix, decrypted. A read that fails or stops part
	// way is an error, never a partial map.
	ByPath(ctx context.Context, prefix string) (map[string]string, error)
	// Set writes one parameter, encrypted, replacing any value it had.
	Set(ctx context.Context, name, value string) error
	// SetNew writes one parameter, encrypted, only if it does not exist:
	// ErrExists when it does, and the value is not changed.
	SetNew(ctx context.Context, name, value string) error
}

var (
	// ErrMissing is a required variable with no value.
	ErrMissing = errors.New("required values are missing")
	// ErrConflict is a variable held both by the region and shared, or a
	// shared value seeding would change.
	ErrConflict = errors.New("values conflict")
	// ErrValue is a name or value host.env cannot hold safely.
	ErrValue = errors.New("a name or value host.env cannot hold")
	// ErrExists is SetNew's answer for a parameter that is already set:
	// another seed wrote it first.
	ErrExists = errors.New("already set by another seed")
)

var (
	scopeName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`)
	keyName   = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`)
	safeValue = regexp.MustCompile(`^[A-Za-z0-9_./:,@+=%-]*$`)
)

// Prefix is the parameter path of one scope: a region's placement code or
// SharedScope. Both names are refused unless they are lower-case labels.
func Prefix(deployment, scope string) (string, error) {
	if !scopeName.MatchString(deployment) || !scopeName.MatchString(scope) {
		return "", fmt.Errorf("%w: %q and %q must be lower-case labels", ErrValue, deployment, scope)
	}
	return "/carlos/" + deployment + "/" + scope + "/", nil
}

// ValidKey reports whether name is a variable host.env carries. Other
// parameters under a scope (the bootstrap object's sha256, for one) are
// not variables and are skipped.
func ValidKey(name string) bool { return keyName.MatchString(name) }

// Assemble merges the shared and the region's values. A variable in both
// is a conflict, never resolved by precedence: one of the two is wrong.
// Every Required variable must have a value.
func Assemble(shared, regional map[string]string) (map[string]string, error) {
	env := map[string]string{}
	var both []string
	for _, src := range []map[string]string{shared, regional} {
		for k, v := range src {
			if !ValidKey(k) || k == BoxReference {
				continue
			}
			if _, dup := env[k]; dup {
				both = append(both, k)
				continue
			}
			env[k] = v
		}
	}
	if len(both) > 0 {
		sort.Strings(both)
		return nil, fmt.Errorf("%w: %s set both for the region and shared", ErrConflict, strings.Join(both, ", "))
	}
	if bad := notAllowed(env); len(bad) > 0 {
		return nil, fmt.Errorf("%w: %s", ErrValue, strings.Join(bad, ", "))
	}
	if missing := Missing(env); len(missing) > 0 {
		return nil, fmt.Errorf("%w: %s", ErrMissing, strings.Join(missing, ", "))
	}
	return env, nil
}

// Missing lists the Required variables env has no value for, sorted.
func Missing(env map[string]string) []string {
	var out []string
	for _, k := range Required {
		if strings.TrimSpace(env[k]) == "" {
			out = append(out, k)
		}
	}
	return out
}

// renderedHeader is the first line of a host.env Render writes.
const renderedHeader = "# Written by carlos system host-env write from the deployment's parameters. Do not edit by hand."

// Render is host.env's bytes: one KEY=value line per variable, sorted.
// Values are written bare, so the file means the same to systemd's
// EnvironmentFile and to a shell that sources it. A value outside the safe
// set (letters, digits and _ . / : , @ + = % -) is refused rather than
// quoted; the error names the variables, never their values, which may be
// secrets.
func Render(env map[string]string) ([]byte, error) {
	keys := make([]string, 0, len(env))
	var bad []string
	for k, v := range env {
		if !ValidKey(k) || !safeValue.MatchString(v) {
			bad = append(bad, k)
			continue
		}
		keys = append(keys, k)
	}
	if len(bad) > 0 {
		sort.Strings(bad)
		return nil, fmt.Errorf("%w: %s", ErrValue, strings.Join(bad, ", "))
	}
	sort.Strings(keys)
	var b bytes.Buffer
	b.WriteString(renderedHeader + "\n")
	for _, k := range keys {
		b.WriteString(k + "=" + env[k] + "\n")
	}
	return b.Bytes(), nil
}

// Parse reads an existing host.env: KEY=value lines, with blank lines,
// comments and a leading "export " ignored, and one layer of matching
// quotes removed from a value. A variable set twice, or a line that is
// not an assignment, is refused.
func Parse(body []byte) (map[string]string, error) {
	env := map[string]string{}
	sc := bufio.NewScanner(bytes.NewReader(body))
	for n := 1; sc.Scan(); n++ {
		line := strings.TrimSpace(sc.Text())
		if line == "" || strings.HasPrefix(line, "#") {
			continue
		}
		line = strings.TrimPrefix(line, "export ")
		k, v, ok := strings.Cut(line, "=")
		k = strings.TrimSpace(k)
		if !ok || !ValidKey(k) {
			return nil, fmt.Errorf("%w: line %d is not KEY=value", ErrValue, n)
		}
		v = strings.TrimSpace(v)
		if len(v) >= 2 && (v[0] == '"' || v[0] == '\'') && v[len(v)-1] == v[0] {
			v = v[1 : len(v)-1]
		}
		if _, dup := env[k]; dup {
			return nil, fmt.Errorf("%w: %s is set twice", ErrValue, k)
		}
		env[k] = v
	}
	if err := sc.Err(); err != nil {
		return nil, err
	}
	return env, nil
}

// Split divides env into the shared variables and the region's own.
func Split(env map[string]string) (shared, regional map[string]string) {
	shared, regional = map[string]string{}, map[string]string{}
	for k, v := range env {
		if slices.Contains(SharedKeys, k) {
			shared[k] = v
		} else {
			regional[k] = v
		}
	}
	return shared, regional
}

// Fetch assembles host.env for one region of a deployment from p, for a
// box the region's group launched: it adds BoxReference.
func Fetch(ctx context.Context, p Params, deployment, region string) ([]byte, error) {
	if region == SharedScope {
		return nil, fmt.Errorf("%w: %q is not a region", ErrValue, region)
	}
	sharedPrefix, err := Prefix(deployment, SharedScope)
	if err != nil {
		return nil, err
	}
	regionPrefix, err := Prefix(deployment, region)
	if err != nil {
		return nil, err
	}
	shared, err := p.ByPath(ctx, sharedPrefix)
	if err != nil {
		return nil, fmt.Errorf("parameters under %s: %w", sharedPrefix, err)
	}
	regional, err := p.ByPath(ctx, regionPrefix)
	if err != nil {
		return nil, fmt.Errorf("parameters under %s: %w", regionPrefix, err)
	}
	env, err := Assemble(shared, regional)
	if err != nil {
		return nil, err
	}
	env[BoxReference] = GroupReference
	return Render(env)
}

// Seeded is what Seed wrote: the parameters under each prefix.
type Seeded struct {
	SharedPrefix, RegionPrefix string
	Shared, Regional           int
}

// Seed writes env, a box's existing host.env, into the layout for region.
// The region's variables are written over whatever is there. A shared
// variable is written only when it is not yet set, and create-only
// (SetNew): one region's file never changes a value every region reads.
// A shared value that differs is a conflict, and nothing at all is
// written. Two seeds racing for the same shared variable cannot both
// win: the one SetNew refuses reads the value back and goes on only if
// it is the value it would have written. env must hold every Required
// variable, in a form Render accepts. BoxReference is never seeded: it
// is write's.
func Seed(ctx context.Context, p Params, deployment, region string, env map[string]string) (Seeded, error) {
	if region == SharedScope {
		return Seeded{}, fmt.Errorf("%w: %q is not a region", ErrValue, region)
	}
	sharedPrefix, err := Prefix(deployment, SharedScope)
	if err != nil {
		return Seeded{}, err
	}
	regionPrefix, err := Prefix(deployment, region)
	if err != nil {
		return Seeded{}, err
	}
	if missing := Missing(env); len(missing) > 0 {
		return Seeded{}, fmt.Errorf("%w: %s", ErrMissing, strings.Join(missing, ", "))
	}
	if bad := notAllowed(env); len(bad) > 0 {
		return Seeded{}, fmt.Errorf("%w: %s", ErrValue, strings.Join(bad, ", "))
	}
	if _, err := Render(env); err != nil {
		return Seeded{}, err
	}
	shared, regional := Split(env)
	delete(regional, BoxReference)
	existing, err := p.ByPath(ctx, sharedPrefix)
	if err != nil {
		return Seeded{}, fmt.Errorf("parameters under %s: %w", sharedPrefix, err)
	}
	var differ []string
	for k, v := range shared {
		if have, ok := existing[k]; ok && have != v {
			differ = append(differ, k)
		}
	}
	if len(differ) > 0 {
		sort.Strings(differ)
		return Seeded{}, fmt.Errorf("%w: the shared %s already differ", ErrConflict, strings.Join(differ, ", "))
	}
	out := Seeded{SharedPrefix: sharedPrefix, RegionPrefix: regionPrefix}
	for _, k := range sortedKeys(shared) {
		if _, ok := existing[k]; ok {
			continue
		}
		err := p.SetNew(ctx, sharedPrefix+k, shared[k])
		if errors.Is(err, ErrExists) {
			// Another seed set it since the read above: go on only if it
			// set what this one would have.
			if err := sameShared(ctx, p, sharedPrefix, k, shared[k]); err != nil {
				return out, err
			}
			continue
		}
		if err != nil {
			return out, fmt.Errorf("%s%s: %w", sharedPrefix, k, err)
		}
		out.Shared++
	}
	for _, k := range sortedKeys(regional) {
		if err := p.Set(ctx, regionPrefix+k, regional[k]); err != nil {
			return out, fmt.Errorf("%s%s: %w", regionPrefix, k, err)
		}
		out.Regional++
	}
	return out, nil
}

// sameShared reads the shared variable k back after SetNew found it set,
// and refuses unless it holds want. The error names the variable, never
// either value.
func sameShared(ctx context.Context, p Params, sharedPrefix, k, want string) error {
	now, err := p.ByPath(ctx, sharedPrefix)
	if err != nil {
		return fmt.Errorf("parameters under %s: %w", sharedPrefix, err)
	}
	have, ok := now[k]
	if !ok {
		return fmt.Errorf("%s%s: %w", sharedPrefix, k, ErrExists)
	}
	if have != want {
		return fmt.Errorf("%w: the shared %s already differ", ErrConflict, k)
	}
	return nil
}

func sortedKeys(m map[string]string) []string {
	keys := make([]string, 0, len(m))
	for k := range m {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	return keys
}
````

`internal/hostenv/cli.go`:

````go
package hostenv

import (
	"context"
	"errors"
	"flag"
	"fmt"
	"io"
	"os"
	"path/filepath"
)

// carlos system host-env write|seed (region HA spec 2 §5 step 2).
//
//   - write is the start-up script's step: as root, it assembles host.env
//     from the deployment's parameters and writes it 0600. A missing
//     required value stops it, and the file is left as it was.
//   - seed is the operator's: it writes a box's existing host.env into
//     the parameter layout, once per region.

const hostEnvUsage = `usage: carlos system host-env write --deployment <name> --region <code> [--aws-region <region>]
       carlos system host-env seed --deployment <name> --region <code> --kms-key <key> [--aws-region <region>] [--from <path>]

  write  build host.env from the deployment's parameters (root)
  seed   copy a box's host.env into the deployment's parameters
`

// hostEnvRoot is the root write's fixed path is under: "/" on a box.
// Tests point it at a directory of their own; there is no flag.
var hostEnvRoot = "/"

// openParams is the parameter store: the AWS adapter's (awsssm.go).
// kmsKey is the key seed encrypts with; write passes "".
var openParams = openSSMParams

// RunSystemHostEnv is `carlos system host-env`.
func RunSystemHostEnv(args []string) {
	if err := runHostEnv(context.Background(), args, os.Stdout, os.Geteuid(), openParams); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}

func runHostEnv(ctx context.Context, args []string, out io.Writer, euid int, open func(ctx context.Context, awsRegion, kmsKey string) (Params, error)) error {
	if len(args) == 0 || (args[0] != "write" && args[0] != "seed") {
		return usageError()
	}
	verb := args[0]
	fs := flag.NewFlagSet("system host-env "+verb, flag.ContinueOnError)
	fs.SetOutput(io.Discard)
	deployment := fs.String("deployment", "", "")
	region := fs.String("region", "", "")
	awsRegion := fs.String("aws-region", "", "")
	kmsKey := fs.String("kms-key", "", "")
	from := fs.String("from", Path, "")
	if err := fs.Parse(args[1:]); err != nil || fs.NArg() != 0 || *deployment == "" || *region == "" {
		return usageError()
	}
	switch verb {
	case "write":
		if euid != 0 {
			return errors.New("carlos system host-env write runs as root: it writes a root-owned file")
		}
		p, err := open(ctx, *awsRegion, "")
		if err != nil {
			return err
		}
		body, err := Fetch(ctx, p, *deployment, *region)
		if err != nil {
			return fmt.Errorf("host.env was not written: %w", err)
		}
		// The path is fixed, /etc/carlos/host.env, and the file is written
		// only through directories proved to be root's alone, then
		// replaced whole (writeHostEnv).
		if err := writeHostEnv(hostEnvRoot, body); err != nil {
			return fmt.Errorf("host.env was not written: %w", err)
		}
		fmt.Fprintln(out, wroteFileLine(filepath.Join(hostEnvRoot, Path), *region))
		return nil
	default:
		if *kmsKey == "" {
			return usageError()
		}
		body, err := os.ReadFile(*from)
		if err != nil {
			return err
		}
		env, err := Parse(body)
		if err != nil {
			return fmt.Errorf("%s: %w", *from, err)
		}
		p, err := open(ctx, *awsRegion, *kmsKey)
		if err != nil {
			return err
		}
		s, err := Seed(ctx, p, *deployment, *region, env)
		if err != nil {
			return fmt.Errorf("the parameters were not all written: %w", err)
		}
		fmt.Fprintln(out, seededLine(s))
		return nil
	}
}

func usageError() error { return errors.New(hostEnvUsage) }

// wroteFileLine is what write says once host.env is in place.
func wroteFileLine(path, region string) string {
	return fmt.Sprintf("wrote %s from the parameters for %s", path, region)
}

// seededLine is what seed says once the parameters are written.
func seededLine(s Seeded) string {
	return fmt.Sprintf("wrote %d parameters under %s and %d under %s", s.Regional, s.RegionPrefix, s.Shared, s.SharedPrefix)
}
````

`internal/hostenv/write_linux.go`:

````go
//go:build linux

package hostenv

import (
	"crypto/rand"
	"errors"
	"fmt"
	"os"
	"path/filepath"

	"golang.org/x/sys/unix"
)

// writeHostEnv writes body as <root>/etc/carlos/host.env, 0600, owned by
// the process's user (root on a box). Every directory on the way is held
// by descriptor, never looked up again by name: etc and etc/carlos are
// opened with O_NOFOLLOW|O_DIRECTORY, so a symlink at either is refused,
// and each must be owned by this user and writable by no one else
// (checkedDir), so no other user can swap the file or the directory under
// it. etc/carlos is created 0755 if it is missing, then opened and
// checked like the rest. The new file is written beside the old one in
// that directory, synced, renamed over it, and the directory synced, so a
// reader sees the old file or the new one, never part of either.
func writeHostEnv(root string, body []byte) error {
	rootFD, err := unix.Open(root, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
	if err != nil {
		return err
	}
	defer unix.Close(rootFD)
	etc, err := checkedDir(rootFD, filepath.Join(root, "etc"), "etc", false)
	if err != nil {
		return err
	}
	defer unix.Close(etc)
	dir, err := checkedDir(etc, filepath.Join(root, "etc", "carlos"), "carlos", true)
	if err != nil {
		return err
	}
	defer unix.Close(dir)
	tmp := ".host.env." + rand.Text()
	fd, err := unix.Openat(dir, tmp, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0o600)
	if err != nil {
		return err
	}
	defer unix.Unlinkat(dir, tmp, 0) // gone once renamed
	f := os.NewFile(uintptr(fd), tmp)
	if err := f.Chmod(0o600); err != nil {
		f.Close()
		return err
	}
	if _, err := f.Write(body); err != nil {
		f.Close()
		return err
	}
	if err := f.Sync(); err != nil {
		f.Close()
		return err
	}
	if err := f.Close(); err != nil {
		return err
	}
	if err := unix.Renameat(dir, tmp, dir, "host.env"); err != nil {
		return err
	}
	return unix.Fsync(dir)
}

// checkedDir opens name under the directory parent as a real directory,
// creating it 0755 first when create is set and it is missing, and refuses
// one that is a symlink, is not a directory, is not owned by this
// process's user (hostEnvOwner), or is writable by group or others. path
// is its name for the refusal.
func checkedDir(parent int, path, name string, create bool) (int, error) {
	const how = unix.O_RDONLY | unix.O_DIRECTORY | unix.O_NOFOLLOW | unix.O_CLOEXEC
	fd, err := unix.Openat(parent, name, how, 0)
	if errors.Is(err, unix.ENOENT) && create {
		if err := unix.Mkdirat(parent, name, 0o755); err != nil && !errors.Is(err, unix.EEXIST) {
			return -1, err
		}
		fd, err = unix.Openat(parent, name, how, 0)
	}
	if errors.Is(err, unix.ELOOP) || errors.Is(err, unix.ENOTDIR) {
		return -1, notRootsDir(path)
	}
	if err != nil {
		return -1, err
	}
	var st unix.Stat_t
	if err := unix.Fstat(fd, &st); err != nil {
		unix.Close(fd)
		return -1, err
	}
	if st.Mode&unix.S_IFMT != unix.S_IFDIR || int(st.Uid) != hostEnvOwner() || st.Mode&0o022 != 0 {
		unix.Close(fd)
		return -1, notRootsDir(path)
	}
	return fd, nil
}

// hostEnvOwner is the user etc and etc/carlos must belong to: this
// process's, which runHostEnv has already required to be root. A var so a
// test can stand for a directory another user owns.
var hostEnvOwner = os.Geteuid

func notRootsDir(path string) error {
	return fmt.Errorf("%s is not a directory only root can write", path)
}
````

`internal/hostenv/write_other.go`:

````go
//go:build !linux

package hostenv

import "errors"

// writeHostEnv is Linux's: a box that writes host.env runs Linux, and only
// there are its directories opened and checked by descriptor.
func writeHostEnv(root string, body []byte) error {
	return errors.New("carlos system host-env write runs only on Linux")
}
````

`internal/hostenv/awsssm.go`:

````go
package hostenv

import (
	"context"
	"errors"
	"strings"

	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/service/ssm"
	ssmtypes "github.com/aws/aws-sdk-go-v2/service/ssm/types"
)

// The AWS adapter for Params (region HA spec 2 §25): SSM Parameter Store
// SecureStrings, encrypted with the deployment's configuration key (§4).
// A box reads its two paths with the role's ssm:GetParametersByPath and
// kms:Decrypt; an operator seeding them needs ssm:PutParameter and
// kms:Encrypt on that key.

type ssmParams struct {
	client *ssm.Client
	kmsKey string
}

// openSSMParams opens SSM in awsRegion ("" is the SDK's default region).
// kmsKey is the key Set encrypts with.
func openSSMParams(ctx context.Context, awsRegion, kmsKey string) (Params, error) {
	var opts []func(*config.LoadOptions) error
	if awsRegion != "" {
		opts = append(opts, config.WithRegion(awsRegion))
	}
	cfg, err := config.LoadDefaultConfig(ctx, opts...)
	if err != nil {
		return nil, err
	}
	return &ssmParams{client: ssm.NewFromConfig(cfg), kmsKey: kmsKey}, nil
}

// ByPath reads every parameter one level under prefix, decrypted, page by
// page. Any failed page fails the whole read.
func (p *ssmParams) ByPath(ctx context.Context, prefix string) (map[string]string, error) {
	out := map[string]string{}
	pages := ssm.NewGetParametersByPathPaginator(p.client, &ssm.GetParametersByPathInput{
		Path:           aws.String(strings.TrimSuffix(prefix, "/")),
		Recursive:      aws.Bool(false),
		WithDecryption: aws.Bool(true),
	})
	for pages.HasMorePages() {
		page, err := pages.NextPage(ctx)
		if err != nil {
			return nil, err
		}
		for _, param := range page.Parameters {
			name, ok := strings.CutPrefix(aws.ToString(param.Name), prefix)
			if !ok || name == "" || strings.Contains(name, "/") {
				continue
			}
			out[name] = aws.ToString(param.Value)
		}
	}
	return out, nil
}

// Set writes one SecureString with the deployment's key, replacing it:
// a region's own values.
func (p *ssmParams) Set(ctx context.Context, name, value string) error {
	in := &ssm.PutParameterInput{
		Name:      aws.String(name),
		Value:     aws.String(value),
		Type:      ssmtypes.ParameterTypeSecureString,
		Overwrite: aws.Bool(true),
	}
	if p.kmsKey != "" {
		in.KeyId = aws.String(p.kmsKey)
	}
	_, err := p.client.PutParameter(ctx, in)
	return err
}

// SetNew writes one SecureString with the deployment's key only if it does
// not exist (Overwrite false). SSM's ParameterAlreadyExists is ErrExists.
func (p *ssmParams) SetNew(ctx context.Context, name, value string) error {
	in := &ssm.PutParameterInput{
		Name:      aws.String(name),
		Value:     aws.String(value),
		Type:      ssmtypes.ParameterTypeSecureString,
		Overwrite: aws.Bool(false),
	}
	if p.kmsKey != "" {
		in.KeyId = aws.String(p.kmsKey)
	}
	_, err := p.client.PutParameter(ctx, in)
	var exists *ssmtypes.ParameterAlreadyExists
	if errors.As(err, &exists) {
		return ErrExists
	}
	return err
}
````

- [ ] **Step 5: Run the tests**

Run: `nice -n 19 go test ./internal/hostenv/ -count=1`
Expected: PASS.

- [ ] **Step 6: The provider boundary**

List the core files, the adapter and the package:

`region_ha_boundary_test.go`:

````diff
--- a/region_ha_boundary_test.go
+++ b/region_ha_boundary_test.go
@@ -30,6 +30,10 @@
 	"internal/console/nocapacity.go",
 	"internal/hostops/chosen.go",
 	"internal/console/regioncapacity.go",
+	"internal/hostenv/hostenv.go",
+	"internal/hostenv/cli.go",
+	"internal/hostenv/write_linux.go",
+	"internal/hostenv/write_other.go",
 }
 
 // regionHAAdapterFiles are spec 2's AWS adapters. Each is named for AWS so
@@ -37,6 +41,7 @@
 var regionHAAdapterFiles = []string{
 	"internal/host/awsaddr.go",
 	"internal/console/awsregionmetric.go",
+	"internal/hostenv/awsssm.go",
 }
 
 // regionHAPackages are the packages spec 2 touches. Every S3 client built
@@ -49,6 +54,7 @@
 	"internal/edgesfile",
 	"internal/host",
 	"internal/hostactors",
+	"internal/hostenv",
 	"internal/hostops",
 	"internal/lbhealth",
 }
````

Run: `nice -n 19 go test . -run 'TestRegionHA(Core|Adapters|S3)' -count=1`
Expected: PASS.

- [ ] **Step 7: Mark the rows built**

Add RHB-C1 to RHB-C18, RHB-C64, RHB-C67 and RHB-C68 to `regionHA2bCopyBuilt`, in the same commit as their text:

`approved_region_ha_2b_copy_test.go`:

````diff
--- a/approved_region_ha_2b_copy_test.go
+++ b/approved_region_ha_2b_copy_test.go
@@ -38,7 +38,7 @@
 // regionHA2bCopyBuilt is every id whose text the code or docs carry. The
 // task that writes a row's text adds its ids here in the same commit, once
 // Paul has approved the rows.
-var regionHA2bCopyBuilt = []string{}
+var regionHA2bCopyBuilt = []string{"RHB-C1", "RHB-C2", "RHB-C3", "RHB-C4", "RHB-C5", "RHB-C6", "RHB-C7", "RHB-C8", "RHB-C9", "RHB-C10", "RHB-C11", "RHB-C12", "RHB-C13", "RHB-C14", "RHB-C15", "RHB-C16", "RHB-C17", "RHB-C18", "RHB-C64", "RHB-C67", "RHB-C68"}
 
 func mustParseRegionHA2bRecord(t *testing.T) []copyRow {
 	t.Helper()
````

Run: `nice -n 19 go test . -run 'RegionHA2b' -count=1`
Expected: PASS once the rows are approved. While any built row is pending, `TestRegionHA2bBuiltCopyIsApproved` fails naming each one, and nothing else fails.

- [ ] **Step 8: Census**

Create the overlay and load it last. `runHostEnv` is a new root-read unit (its `seed` path reads the `--from` file), ruled A,J4 by census ruling region-ha-2b. `writeHostEnv` and `checkedDir` are new in round 4 (their read sites open the root, `etc`, `etc/carlos` and the temporary file); platform-1e ruled them A,J4 with `runHostEnv` (`census-ruling-region-ha-2b`, round 5). No legacy row is added, so the seal stands.

`internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv`:

````text
# Plan 14 census overlay region-ha-2b (region HA spec 2, plan 2b: docs/superpowers/plans/2026-10-17-region-ha-2b-joining.md), loaded last. RULED by the Plan 14 controller 2026-10-16 as census-ruling-region-ha-2b.
# Task 2: host.env from the deployment's parameters (internal/hostenv: hostenv.go, the layout, assembly and rendering, with an allowlist of names, shared values written create-only and the group-box marker added on write; cli.go, carlos system host-env write and seed; write_linux.go, the write through directories held by descriptor, and write_other.go, its refusal off Linux; awsssm.go, the SSM adapter) and the SSM SDK in go.mod. Three new root-read units. runHostEnv: seed's --from file. Ruled authority A,J4 on conditions (no box write to the parameters; allowlisted names, safe values, atomic write, checked parent; authority-selecting values cross-checked against the roster at the agent's use). writeHostEnv and checkedDir (Linux): write's fixed path, /etc/carlos/host.env, reached only through /etc and /etc/carlos opened O_NOFOLLOW|O_DIRECTORY and checked by fstat (root's, writable by no group or other), the file written 0600 in that directory, synced, renamed and the directory synced; ruled A,J4 under census-ruling-region-ha-2b, as the checked parent runHostEnv's condition asks for. The legacy seal stands.
@coverage	.	be7c96bb2ff00b5b018b436f976f0d33bbcc8765c17e357baa6cd0290f59f42d
@coverage	internal/hostenv	a3f3274a86cdf8194afb871301c1ba3289dfbdc9b703dd547178273df151cdaa
internal/hostenv/cli.go	runHostEnv	authority	A,J4	rev3=census-ruling-region-ha-2b;basis=carlos system host-env: write runs as root at a box's start-up and writes /etc/carlos/host.env, 0600, whole (temporary file and rename), from allowlisted parameters with safe values only, refusing a missing required value; its one read site is seed's --from file, an operator's host.env whose values go only to the parameter store; write's path is fixed and reached only through writeHostEnv	file:ReadFile@1500	df4470176b229b311927ff0ac4f268d354c3a2357cac4300ac0992d2ebb90899
internal/hostenv/write_linux.go	checkedDir	authority	A,J4	rev3=census-ruling-region-ha-2b;basis=carlos system host-env write (root, Linux): opens one directory under one already held, O_NOFOLLOW|O_DIRECTORY, creating it 0755 first only when asked and missing, and refuses it unless fstat shows a directory owned by this process's user (root, as write requires) that no group or other can write	file:Fstat@576,file:Openat@168,file:Openat@372	012257ac0866f3385044785d8abae9c31e61134928c394e22a2aeaef39ec9f3c
internal/hostenv/write_linux.go	writeHostEnv	authority	A,J4	rev3=census-ruling-region-ha-2b;basis=carlos system host-env write (root, Linux): writes /etc/carlos/host.env only through /etc and /etc/carlos held by descriptor and checked by checkedDir; the new file is created 0600, O_EXCL|O_NOFOLLOW, in that directory, synced, renamed over host.env within it, and the directory synced; its read sites open the root and the temporary file	file:Open@68,file:Openat@509	1245fc0f819ba73fb45545ffdcf92d5d4941802175e73bac209ab4d4238adff1
````

`internal/provision/plan14_root_reads_test.go`:

````diff
--- a/internal/provision/plan14_root_reads_test.go
+++ b/internal/provision/plan14_root_reads_test.go
@@ -806,6 +806,17 @@
 		t.Fatal(err)
 	}
 	seal = "8198674d8809ef90ecebac55324bfd75e69fb3d73bc27b942f29ca2f43e3fe8d"
+	if errs := checkRootReadLegacy(inv, seal); len(errs) != 0 {
+		t.Fatal(errs)
+	}
+	// Census overlay region-ha-2b (region HA spec 2, plan 2b), loaded last.
+	// Its header says what each plan task added. The rows it adds are
+	// census ruling region-ha-2b's (platform-1e); its accepted-legacy rows
+	// move the seal.
+	if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv"), &inv, true); err != nil {
+		t.Fatal(err)
+	}
+	seal = "8198674d8809ef90ecebac55324bfd75e69fb3d73bc27b942f29ca2f43e3fe8d"
 	errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...)
 	errs = append(errs, checkRootReadDeclarations(got, inv)...)
 	if len(errs) > 0 {
````

Run: `nice -n 19 go test ./internal/provision/ -run 'TestPlan9B8RecordWriteCensus|TestPlan14RootReadCensus$' -count=1 && nice -n 19 go test ./internal/console/ -run TestClaimAuthoritySourceCensus -count=1`
Expected: PASS.

- [ ] **Step 9: Commit**

```bash
git add internal/hostenv go.mod go.sum region_ha_boundary_test.go approved_region_ha_2b_copy_test.go internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv internal/provision/plan14_root_reads_test.go
git commit -m "region HA 2b: host.env from the deployment's parameters"
```

---

### Task 3: `carlos system install-channel`

**Files:**
- Create: `internal/host/installchannel.go`, `internal/host/installchannel_linux.go`, `internal/host/installchannel_other.go`, `internal/host/installchannel_test.go`
- Modify: `region_ha_boundary_test.go`, `approved_region_ha_2b_copy_test.go`, the overlay

**Interfaces:**
- Consumes: `systemApplier.verifyPointer` and `stageCandidate` (unchanged), `systemCacheHolds`, `copyFileDurable`, `writeJSONDurable`, `readSystemApplied`, `readSystemSeen`, `flockExclusiveNonblock`, `systemGuardDir`, `systemCachePath`, `systemCacheParts`, `systemArtifactLimit`; bootstrap's `bootstrapMakeDir`, `bootstrapOpenDir`, `bootstrapOwner` and `serviceUser`; the agent's `systemLeg.fetch` and `writeRequest` (in the test); `hostfloor.Verify`, `CheckImage`, `Durable`, `Digest`; `release.FetchToFile`, `IdentityKey`.
- Produces: `RunSystemInstallChannel([]string)` (wired in Task 9); `channelIdentityPath(optDir string) string` (`/opt/carlos/system/router/channel-identity.json`), which Task 7 hands guard-arm; `channelInstall{a *systemApplier; bucket string; fetchTo func(...); own func(path string) error}` and its `run` and `cacheDir`; `ownByService` and `ownDir` (linux; elsewhere `ownByService` refuses); `channelInstalled`; `installedLine`.

This task writes rows RHB-C19 to RHB-C39 of the copy record. Build it only once Paul has approved them, and where his text differs from a draft, use his in `installchannel.go` too: the strings in the code below are the drafts. Adding the ids to `regionHA2bCopyBuilt` makes the gate hold the code to the approved text.

A group box boots from whatever build the deployment last uploaded as its bootstrap object. This puts its neighbours' release in place instead, the one its signed channel pointer names, through `carlos system update`'s own path: `verifyPointer` (the signature, with `CARLOS_RELEASE_PUBKEY`), the artifact fetched against the signed sha256 into the agent's cache, and `stageCandidate` (copied into root's staging, hashed again, asked its version). Before anything is fetched, the release must pass the published host floor exactly as guard-arm will check it (`hostfloor.CheckImage` over the release identity), so a release below the floor never lands. Both `carlos` and `carlos.prev` become the artifact (A2-2), `.prev` first, and the identity is kept for guard-arm. `applied.json` and `seen.json` are written as an apply writes them, so the first real update treats the binary as known good. The bootstrap binary itself is the start-up script's to delete (2c).

Install-channel runs as root after `carlos bootstrap` has handed `/data` to the service user. The cache entry it fetches into is the agent's (`/data/system/router/<version>/carlos`). On every run it makes each directory on that path, `system`, `system/router` and the version's own, as bootstrap makes directories (`bootstrapMakeDir`: every component opened with no symlink followed, so a symlinked parent is refused), and hands each one, alone, to the service user (`ownByService`: bootstrap's `bootstrapOwner`, then the directory opened as `bootstrapOpenDir` opens one and changed with `Fchown` on that descriptor). It does that whether this run created the directory or an earlier run did, so a run whose handoff failed after it made a parent leaves nothing a retry skips (S2, Astra round 2). Nothing else is chowned. Only the version directory is removed afterwards; `/data/system` and `/data/system/router` stay, owned by the agent, which downloads its next release and writes `requested.json` there. The test's `own` seam records each call; the tests fail one handoff and retry, refuse a symlinked parent, and then run the agent's own `systemLeg.fetch` and `writeRequest` for the next release in what install-channel left.

- [ ] **Step 1: Write the failing tests**

The fixture is the apply tests' `newApplyFixture` with its binary removed: a fresh box.

`internal/host/installchannel_test.go`:

````go
package host

import (
	"bytes"
	"context"
	"crypto/ed25519"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"os"
	"path/filepath"
	"reflect"
	"strings"
	"testing"

	"github.com/carlosframework/platform/internal/hostfloor"
	"github.com/carlosframework/platform/internal/release"
)

// channelFixture is a fresh box (applyFixture with no binary on it) whose
// stable channel names v1.4.0, whose identity is published, and whose
// deployment has a host floor.
type channelFixture struct {
	*applyFixture
	in      channelInstall
	body    []byte
	fetches int
	owned   []string
}

func newChannelFixture(t *testing.T, floor string) *channelFixture {
	t.Helper()
	f := &channelFixture{applyFixture: newApplyFixture(t, "stable", "bootstrap")}
	if err := os.Remove(systemBinaryPath(f.a.optDir)); err != nil {
		t.Fatal(err)
	}
	f.body = []byte("#!/bin/sh\necho carlos v1.4.0\n")
	f.publish(t, "v1.4.0", f.body)
	if floor != "" {
		if err := hostfloor.Publish(context.Background(), f.store, release.RootSigner(f.priv), "bucket", hostfloor.Floor{Min: 1, MinRelease: floor}); err != nil {
			t.Fatal(err)
		}
	}
	f.in = channelInstall{a: f.a, bucket: "bucket", fetchTo: func(ctx context.Context, s release.Store, key, dst, sha string) error {
		f.fetches++
		return release.FetchToFile(ctx, s, key, dst, systemArtifactLimit, sha)
	}, own: func(path string) error {
		f.owned = append(f.owned, path)
		return nil
	}}
	return f
}

// publish promotes version on stable with body as its linux-arm64
// artifact and publishes its release identity.
func (f *channelFixture) publish(t *testing.T, version string, body []byte) {
	t.Helper()
	ctx := context.Background()
	sum := sha256.Sum256(body)
	rel := release.SystemRelease{Version: version, ReleaseTag: version, MovesProtocol: 1, Artifacts: map[string]release.SystemArtifact{
		"linux-arm64": {Key: release.SystemArtifactKey(version, "linux-arm64"), SHA256: hex.EncodeToString(sum[:])}}}
	if _, err := f.store.Put(ctx, rel.Artifacts["linux-arm64"].Key, body, ""); err != nil {
		t.Fatal(err)
	}
	if _, err := release.PromoteSystem(ctx, f.store, release.RootSigner(f.priv), "router", "stable", rel, release.SystemPromoteOptions{}); err != nil {
		t.Fatal(err)
	}
	wire, _, err := f.store.Get(ctx, release.SystemPointerKey("router", "stable"))
	if err != nil {
		t.Fatal(err)
	}
	if err := release.PublishSystemIdentity(ctx, f.store, f.pub, wire); err != nil {
		t.Fatal(err)
	}
}

func (f *channelFixture) slots(t *testing.T) (cur, prev []byte) {
	t.Helper()
	cur, _ = os.ReadFile(systemBinaryPath(f.a.optDir))
	prev, _ = os.ReadFile(systemPrevPath(f.a.optDir))
	return cur, prev
}

func TestInstallChannelPutsTheChannelReleaseInBothSlots(t *testing.T) {
	f := newChannelFixture(t, "v1.2.0")
	got, err := f.in.run(context.Background())
	if err != nil {
		t.Fatal(err)
	}
	if got.Version != "v1.4.0" || got.Tag != "v1.4.0" || got.Already {
		t.Fatalf("installed %+v", got)
	}
	cur, prev := f.slots(t)
	if !bytes.Equal(cur, f.body) || !bytes.Equal(prev, f.body) {
		t.Fatalf("slots hold %q and %q, not the channel's artifact", cur, prev)
	}
	// The identity guard-arm will be given is the channel release's.
	held, err := os.ReadFile(channelIdentityPath(f.a.optDir))
	if err != nil {
		t.Fatal(err)
	}
	p, err := release.VerifySystemIdentity(f.pub, held)
	if err != nil || p.Current.Version != "v1.4.0" {
		t.Fatalf("identity %v %v", p, err)
	}
	// An update later finds the binary on disk known good, and has seen
	// this promote.
	applied, err := readSystemApplied(f.a.optDir)
	if err != nil || applied == nil || applied.Version != "v1.4.0" || applied.Channel != "stable" {
		t.Fatalf("applied %+v %v", applied, err)
	}
	seen, err := readSystemSeen(f.a.optDir)
	if err != nil || seen["stable"].IsZero() {
		t.Fatalf("seen %v %v", seen, err)
	}
	// Nothing is left in staging or the agent's cache.
	if _, err := os.Stat(f.a.stagingDir()); !os.IsNotExist(err) {
		t.Fatalf("staging left: %v", err)
	}
	if _, err := os.Stat(filepath.Dir(systemCachePath(f.a.dataDir, "v1.4.0"))); !os.IsNotExist(err) {
		t.Fatalf("cache left: %v", err)
	}
	// The candidate was asked its version from staging before it was put
	// in place.
	if !f.called("run carlos version") {
		t.Fatalf("calls %v", f.calls)
	}
	// R2, S2: every directory on the cache's path is handed to the
	// service user, and nothing else, so the agent's own update can
	// download the next release beside them and ask for it.
	if want := cachePathDirs(f.a.dataDir, "v1.4.0"); !reflect.DeepEqual(f.owned, want) {
		t.Fatalf("handed to the service user: %v, want %v", f.owned, want)
	}
	// Run again: nothing to do, nothing fetched.
	again, err := f.in.run(context.Background())
	if err != nil || !again.Already || f.fetches != 1 {
		t.Fatalf("second run %+v %v, %d fetches", again, err, f.fetches)
	}
	if line := installedLine(again); line != "v1.4.0 (v1.4.0) from the stable channel is already installed" {
		t.Fatalf("line %q", line)
	}
	// The agent, its next release promoted, downloads it and asks for it
	// in the directories install-channel left.
	leg := &systemLeg{dataDir: f.a.dataDir, fetchTo: func(ctx context.Context, key, dst, sha string) error {
		return release.FetchToFile(ctx, f.store, key, dst, systemArtifactLimit, sha)
	}}
	next := []byte("#!/bin/sh\necho carlos v1.5.0\n")
	f.publish(t, "v1.5.0", next)
	sum := sha256.Sum256(next)
	pending := &systemPending{version: "v1.5.0", artifact: release.SystemArtifact{Key: release.SystemArtifactKey("v1.5.0", "linux-arm64"), SHA256: hex.EncodeToString(sum[:])}}
	if err := leg.fetch(context.Background(), pending); err != nil {
		t.Fatalf("the agent's next download: %v", err)
	}
	if err := leg.writeRequest(pending, f.now); err != nil {
		t.Fatalf("the agent's next request: %v", err)
	}
}

// cachePathDirs are the directories install-channel hands the service
// user for version: system, system/router and the version's own.
func cachePathDirs(dataDir, version string) []string {
	return []string{filepath.Join(dataDir, "system"), filepath.Join(dataDir, "system", "router"), filepath.Join(dataDir, "system", "router", version)}
}

// S2: a run whose ownership handoff fails installs nothing; the next run
// reconciles every directory on the path, including the ones the failed
// run created, not only the ones it creates itself.
func TestInstallChannelReconcilesOwnershipOnARetry(t *testing.T) {
	f := newChannelFixture(t, "v1.2.0")
	own := f.in.own
	f.in.own = func(string) error { return errors.New("chown: operation not permitted") }
	if _, err := f.in.run(context.Background()); err == nil {
		t.Fatal("a failed handoff installed")
	}
	if cur, prev := f.slots(t); cur != nil || prev != nil || f.fetches != 0 {
		t.Fatalf("a failed handoff wrote %q %q after %d fetches", cur, prev, f.fetches)
	}
	// The failed run created system, as root, and never handed it over.
	if _, err := os.Stat(filepath.Join(f.a.dataDir, "system")); err != nil {
		t.Fatalf("the failed run's parent: %v", err)
	}
	f.in.own = own
	if _, err := f.in.run(context.Background()); err != nil {
		t.Fatal(err)
	}
	if want := cachePathDirs(f.a.dataDir, "v1.4.0"); !reflect.DeepEqual(f.owned, want) {
		t.Fatalf("the retry handed %v, want %v", f.owned, want)
	}
}

// S2: a symlinked parent is refused before anything is fetched, and never
// followed; the real handoff opens each directory without following one.
func TestInstallChannelRefusesASymlinkedParent(t *testing.T) {
	f := newChannelFixture(t, "v1.2.0")
	elsewhere := t.TempDir()
	if err := os.Symlink(elsewhere, filepath.Join(f.a.dataDir, "system")); err != nil {
		t.Fatal(err)
	}
	if _, err := f.in.run(context.Background()); err == nil || f.fetches != 0 || len(f.owned) != 0 {
		t.Fatalf("symlinked parent: %v, %d fetches, owned %v", err, f.fetches, f.owned)
	}
	if ents, _ := os.ReadDir(elsewhere); len(ents) != 0 {
		t.Fatalf("followed the symlink: %v", ents)
	}
	if err := ownDir(filepath.Join(f.a.dataDir, "system"), os.Getuid(), os.Getgid()); err == nil {
		t.Fatal("the handoff followed a symlink")
	}
	if err := ownDir(elsewhere, os.Getuid(), os.Getgid()); err != nil {
		t.Fatalf("the handoff of a real directory: %v", err)
	}
}

func TestInstallChannelRefusesBelowTheFloorAndWithoutOne(t *testing.T) {
	for _, c := range []struct{ name, floor, want string }{
		{"below the floor", "v2.0.0", "below the host floor v2.0.0"},
		{"no floor", "", "no host floor is published"},
	} {
		t.Run(c.name, func(t *testing.T) {
			f := newChannelFixture(t, c.floor)
			_, err := f.in.run(context.Background())
			if err == nil || !strings.Contains(err.Error(), c.want) {
				t.Fatalf("err %v", err)
			}
			cur, prev := f.slots(t)
			if cur != nil || prev != nil || f.fetches != 0 {
				t.Fatalf("refused install wrote %q %q after %d fetches", cur, prev, f.fetches)
			}
		})
	}
}

func TestInstallChannelRefusesAnUnreadableStoreAndABadArtifact(t *testing.T) {
	f := newChannelFixture(t, "v1.2.0")
	f.a.openStore = func(context.Context) (release.Store, ed25519.PublicKey, error) {
		return nil, nil, errors.New("no route to bucket")
	}
	if _, err := f.in.run(context.Background()); err == nil || !strings.Contains(err.Error(), "could not be read") {
		t.Fatalf("unreachable store: %v", err)
	}
	g := newChannelFixture(t, "v1.2.0")
	// The object no longer matches the signed sha256.
	_, etag, _ := g.store.Get(context.Background(), release.SystemArtifactKey("v1.4.0", "linux-arm64"))
	if _, err := g.store.Put(context.Background(), release.SystemArtifactKey("v1.4.0", "linux-arm64"), []byte("#!/bin/sh\necho carlos v0.0.1\n"), etag); err != nil {
		t.Fatal(err)
	}
	if _, err := g.in.run(context.Background()); err == nil || !strings.Contains(err.Error(), "could not be fetched") {
		t.Fatalf("swapped artifact: %v", err)
	}
	if cur, prev := g.slots(t); cur != nil || prev != nil {
		t.Fatalf("swapped artifact installed %q %q", cur, prev)
	}
}

func TestInstallChannelLeavesAnInstalledBoxToUpdates(t *testing.T) {
	// An armed guard.
	f := newChannelFixture(t, "v1.2.0")
	ctx := context.Background()
	raw, _, _ := f.store.Get(ctx, hostfloor.Key)
	auth, _, _ := f.store.Get(ctx, hostfloor.AuthKey)
	policy, _ := json.Marshal(hostfloor.Policy{Schema: 1, DeploymentBucket: "bucket", Mode: "armed", Floor: raw, Authorization: auth})
	if err := os.MkdirAll(systemGuardDir(f.a.optDir), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(systemGuardDir(f.a.optDir), "policy.json"), policy, 0o644); err != nil {
		t.Fatal(err)
	}
	if _, err := f.in.run(ctx); err == nil || !strings.Contains(err.Error(), "guard is armed") {
		t.Fatalf("armed guard: %v", err)
	}
	// A box an earlier install or update put another release on.
	g := newChannelFixture(t, "v1.2.0")
	if _, err := g.in.run(ctx); err != nil {
		t.Fatal(err)
	}
	g.publish(t, "v1.5.0", []byte("#!/bin/sh\necho carlos v1.5.0\n"))
	if _, err := g.in.run(ctx); err == nil || !strings.Contains(err.Error(), "already runs v1.4.0") {
		t.Fatalf("installed box: %v", err)
	}
	if cur, _ := g.slots(t); !bytes.Equal(cur, g.body) {
		t.Fatalf("installed box changed to %q", cur)
	}
}
````

- [ ] **Step 2: Run them to see them fail**

Run: `nice -n 19 go test ./internal/host/ -run TestInstallChannel -count=1`
Expected: FAIL, the package does not build (`undefined: channelInstall`).

- [ ] **Step 3: Write the command**

`internal/host/installchannel.go`:

````go
package host

import (
	"context"
	"crypto/ed25519"
	"errors"
	"flag"
	"fmt"
	"os"
	"path/filepath"
	"runtime"

	"github.com/carlosframework/platform/internal/cliflag"
	"github.com/carlosframework/platform/internal/hostfloor"
	"github.com/carlosframework/platform/internal/release"
	"github.com/carlosframework/platform/internal/version"
)

// carlos system install-channel (region HA spec 2 §5 step 3, A1-6, A2-2).
//
// A group box boots from a bootstrap object, which is whatever build the
// deployment last uploaded there, not its neighbours' release. This puts
// the release the box's signed channel pointer names in place instead,
// through the same path `carlos system update` takes: the pointer verified
// with CARLOS_RELEASE_PUBKEY (verifyPointer), the artifact fetched against
// the signed sha256 into the agent's cache, then copied into root's
// staging, checked again and asked its version (stageCandidate).
//
// Both protected slots, carlos and carlos.prev, become that artifact
// (A2-2): guard-arm checks both against the release identity and refuses
// a box with any other build in either. The release identity is kept
// beside them (channelIdentityPath) for the join step's guard-arm, and the
// release must pass the published host floor (hostfloor.CheckImage, the
// check guard-arm makes) before anything is fetched.
//
// It is for a fresh box. A box whose guard is armed, or that already runs
// another release this way or through an update, is refused: its releases
// come through `carlos system update`. Run again on a box it has already
// installed, it changes nothing.

const installChannelUsage = "usage: carlos system install-channel\n\n" +
	"Install the release this box's channel names, as root, on a box that has none yet.\n"

const installChannelNotRoot = "carlos system install-channel runs as root: it installs the platform binary"

// channelIdentityPath is where the installed release's signed identity is
// kept for guard-arm (§5 step 5.3).
func channelIdentityPath(optDir string) string {
	return filepath.Join(systemRootDir(optDir), "channel-identity.json")
}

// channelInstall is one run. fetchTo is release.FetchToFile over the
// pointer's store, and own hands a directory tree to the service user
// (ownByService); both are seams for tests.
type channelInstall struct {
	a       *systemApplier
	bucket  string
	fetchTo func(ctx context.Context, s release.Store, key, dst, sha string) error
	own     func(path string) error
}

// channelInstalled is what a run did.
type channelInstalled struct {
	Version, Tag, Channel string
	Already               bool
}

// RunSystemInstallChannel is `carlos system install-channel`.
func RunSystemInstallChannel(args []string) {
	fs := flag.NewFlagSet("system install-channel", flag.ExitOnError)
	fs.Usage = cliflag.Usage(fs, installChannelUsage)
	_ = fs.Parse(args)
	if fs.NArg() != 0 {
		fs.Usage()
		os.Exit(2)
	}
	if os.Geteuid() != 0 {
		fmt.Fprintln(os.Stderr, installChannelNotRoot)
		os.Exit(1)
	}
	channel, invalid := systemChannel(os.Getenv)
	if invalid != "" {
		fmt.Fprintln(os.Stderr, fmt.Sprintf("%s=%q is not a channel, so nothing was installed", systemChannelEnv, invalid))
		os.Exit(1)
	}
	in := channelInstall{
		a: &systemApplier{ops: productionSysOps(), openStore: openSystemStore, channel: channel, optDir: release.OptDir(),
			dataDir: dataDir(), etcDir: "/etc", build: version.Build, platform: runtime.GOOS + "-" + runtime.GOARCH},
		bucket: os.Getenv("CARLOS_DEPLOYMENT_BUCKET"),
		fetchTo: func(ctx context.Context, s release.Store, key, dst, sha string) error {
			return release.FetchToFile(ctx, s, key, dst, systemArtifactLimit, sha)
		},
		own: ownByService,
	}
	got, err := in.run(context.Background())
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
	fmt.Println(installedLine(got))
}

func installedLine(got channelInstalled) string {
	if got.Already {
		return fmt.Sprintf("%s (%s) from the %s channel is already installed", got.Version, got.Tag, got.Channel)
	}
	return fmt.Sprintf("installed %s (%s) from the %s channel as carlos and carlos.prev", got.Version, got.Tag, got.Channel)
}

// run takes the apply lock, so no update runs beside it, and installs.
func (in channelInstall) run(ctx context.Context) (channelInstalled, error) {
	a := in.a
	if err := os.MkdirAll(a.rootDir(), 0o755); err != nil {
		return channelInstalled{}, err
	}
	lock, err := os.OpenFile(a.lockPath(), os.O_CREATE|os.O_RDWR, 0o600)
	if err != nil {
		return channelInstalled{}, err
	}
	defer lock.Close()
	if err := flockExclusiveNonblock(lock); err != nil {
		return channelInstalled{}, fmt.Errorf("an update is running on this box, so nothing was installed: %w", err)
	}
	return in.install(ctx)
}

func (in channelInstall) install(ctx context.Context) (channelInstalled, error) {
	a := in.a
	var pub ed25519.PublicKey
	open := a.openStore
	a.openStore = func(ctx context.Context) (release.Store, ed25519.PublicKey, error) {
		s, k, err := open(ctx)
		pub = k
		return s, k, err
	}
	p, err := a.verifyPointer(ctx)
	if err != nil {
		return channelInstalled{}, fmt.Errorf("the %s channel could not be read, so nothing was installed: %w", a.channel, err)
	}
	got := channelInstalled{Version: p.Current.Version, Tag: p.Current.ReleaseTag, Channel: a.channel}
	art, ok := p.Current.Artifacts[a.platform]
	if !ok {
		return got, fmt.Errorf("the %s channel names %s with no artifact for %s, so nothing was installed", a.channel, p.Current.Version, a.platform)
	}
	// A box whose guard is armed takes releases only through an update.
	guard := hostfloor.Disk{Dir: systemGuardDir(a.optDir), Bucket: in.bucket, PublicKey: pub}
	if policy, _, err := guard.Load(); err == nil && policy.Mode != "unpublished" {
		return got, errors.New("this box's host lifecycle guard is armed, so carlos system update installs its releases")
	} else if err != nil && !errors.Is(err, os.ErrNotExist) {
		return got, fmt.Errorf("this box's host lifecycle guard could not be read, so nothing was installed: %w", err)
	}
	applied, err := readSystemApplied(a.optDir)
	if err != nil {
		return got, fmt.Errorf("this box's install record could not be read, so nothing was installed: %w", err)
	}
	if applied != nil && applied.Version != p.Current.Version {
		return got, fmt.Errorf("this box already runs %s, so carlos system update installs its releases", applied.Version)
	}
	wire, err := in.checkFloor(ctx, p, art, pub)
	if err != nil {
		return got, err
	}
	if applied != nil && systemCacheHolds(systemBinaryPath(a.optDir), art.SHA256) && systemCacheHolds(systemPrevPath(a.optDir), art.SHA256) &&
		systemCacheHolds(channelIdentityPath(a.optDir), hostfloor.Digest(wire)) {
		got.Already = true
		return got, nil
	}
	cache := systemCachePath(a.dataDir, p.Current.Version)
	defer os.RemoveAll(filepath.Dir(cache))
	defer os.RemoveAll(a.stagingDir())
	if err := in.cacheDir(p.Current.Version); err != nil {
		return got, err
	}
	if err := in.fetchTo(ctx, a.store, art.Key, cache, art.SHA256); err != nil {
		return got, fmt.Errorf("%s could not be fetched, so nothing was installed: %w", p.Current.Version, err)
	}
	staged, err := a.stageCandidate(ctx, p, systemRequest{Version: p.Current.Version, PromotedAt: p.PromotedAt})
	if err != nil {
		return got, fmt.Errorf("%s did not verify, so nothing was installed: %w", p.Current.Version, err)
	}
	// carlos.prev first: a run cut off between the two copies leaves the
	// new build in .prev and none in the current slot, which the next run
	// replaces, never the other way round.
	for _, slot := range []string{systemPrevPath(a.optDir), systemBinaryPath(a.optDir)} {
		if err := copyFileDurable(staged, slot, 0o755); err != nil {
			return got, err
		}
		if !systemCacheHolds(slot, art.SHA256) {
			return got, fmt.Errorf("%s does not hold %s after the copy", slot, p.Current.Version)
		}
	}
	if err := hostfloor.Durable(channelIdentityPath(a.optDir), wire, 0o644); err != nil {
		return got, err
	}
	seen, err := readSystemSeen(a.optDir)
	if err != nil {
		return got, err
	}
	seen[a.channel] = p.PromotedAt
	if err := writeJSONDurable(a.file("seen.json"), seen, 0o644); err != nil {
		return got, err
	}
	if err := writeJSONDurable(a.file("applied.json"), systemApplied{Version: p.Current.Version, Channel: a.channel, At: a.ops.now().UTC()}, 0o644); err != nil {
		return got, err
	}
	return got, nil
}

// checkFloor reads the published host floor and the release's signed
// identity, and returns the identity once the release passes the floor
// exactly as guard-arm will check it. No floor, or one that cannot be read,
// refuses: a joining box could not arm its guard without one, and a read
// that failed says nothing about where the floor is.
func (in channelInstall) checkFloor(ctx context.Context, p *release.SystemPointer, art release.SystemArtifact, pub ed25519.PublicKey) ([]byte, error) {
	s := in.a.store
	raw, _, err := s.Get(ctx, hostfloor.Key)
	if errors.Is(err, os.ErrNotExist) {
		return nil, errors.New("no host floor is published, so this box could not arm its guard and nothing was installed")
	}
	if err != nil {
		return nil, fmt.Errorf("the host floor could not be read, so nothing was installed: %w", err)
	}
	auth, _, err := s.Get(ctx, hostfloor.AuthKey)
	if err != nil {
		return nil, fmt.Errorf("the host floor could not be read, so nothing was installed: %w", err)
	}
	floor, err := hostfloor.Verify(pub, in.bucket, raw, auth)
	if err != nil {
		return nil, fmt.Errorf("the host floor does not verify, so nothing was installed: %w", err)
	}
	key, err := release.IdentityKey(p.Current.ReleaseTag)
	if err != nil {
		return nil, fmt.Errorf("the %s channel names %s with no release tag, so nothing was installed", in.a.channel, p.Current.Version)
	}
	wire, _, err := s.Get(ctx, key)
	if err != nil {
		return nil, fmt.Errorf("the release identity for %s could not be read, so nothing was installed: %w", p.Current.ReleaseTag, err)
	}
	if err := hostfloor.CheckImage(pub, floor, wire, p.Current.Version, in.a.platform, art.SHA256); err != nil {
		return nil, fmt.Errorf("%s (%s) is below the host floor %s or is not the release its identity names, so nothing was installed", p.Current.Version, p.Current.ReleaseTag, floor.Floor.MinRelease)
	}
	return wire, nil
}

// cacheDir makes the agent's cache entry for version and reconciles the
// ownership of every directory on its path below the data dir, on every
// run: system, system/router and system/router/<version> are created as
// bootstrap creates directories (bootstrapMakeDir: each component opened
// with no symlink followed, so a symlinked parent is refused) and each is
// handed to the service user, as bootstrap's chown of /data does
// (privilegedPlan), whether this run created it or an earlier run that
// failed before its handoff did. Nothing else is chowned. The agent
// (User=carlos) later downloads releases beside the entry and writes its
// update requests there. Only the entry itself is removed after the
// install; its parents stay, owned by the agent.
func (in channelInstall) cacheDir(version string) error {
	parts := systemCacheParts(version)
	dir := in.a.dataDir
	for _, part := range parts[:len(parts)-1] {
		dir = filepath.Join(dir, part)
		if err := bootstrapMakeDir(dir, 0o755); err != nil {
			return err
		}
		if err := in.own(dir); err != nil {
			return err
		}
	}
	return nil
}
````

`internal/host/installchannel_linux.go`:

````go
//go:build linux

package host

import "golang.org/x/sys/unix"

// ownByService hands one directory, and nothing beneath it, to the service
// user and its group, as bootstrap's ownership step does
// (bootstrapOwner). It opens the directory as bootstrap opens one
// (bootstrapOpenDir: no symlink anywhere on the path, not a symlink
// itself) and changes the open descriptor, so a path swapped for a
// symlink is refused, never followed.
func ownByService(path string) error {
	uid, gid, err := bootstrapOwner(serviceUser, serviceUser)
	if err != nil {
		return err
	}
	return ownDir(path, uid, gid)
}

// ownDir is ownByService for a given owner.
func ownDir(path string, uid, gid int) error {
	fd, err := bootstrapOpenDir(path)
	if err != nil {
		return err
	}
	defer unix.Close(fd)
	return unix.Fchown(fd, uid, gid)
}
````

`internal/host/installchannel_other.go`:

````go
//go:build !linux

package host

import "errors"

// errOwnNotLinux is every ownership handoff off Linux: carlos system
// install-channel runs only on a Linux box.
var errOwnNotLinux = errors.New("install-channel's ownership handoff requires Linux")

func ownByService(path string) error { return errOwnNotLinux }

func ownDir(path string, uid, gid int) error { return errOwnNotLinux }
````

- [ ] **Step 4: Run the tests**

Run: `nice -n 19 go test ./internal/host/ -run TestInstallChannel -count=1`
Expected: PASS (on linux: staging opens the cache with `openat`, which only the linux build has).

- [ ] **Step 5: The provider boundary**

`region_ha_boundary_test.go`:

````diff
--- a/region_ha_boundary_test.go
+++ b/region_ha_boundary_test.go
@@ -34,6 +34,7 @@
 	"internal/hostenv/cli.go",
 	"internal/hostenv/write_linux.go",
 	"internal/hostenv/write_other.go",
+	"internal/host/installchannel.go",
 }
 
 // regionHAAdapterFiles are spec 2's AWS adapters. Each is named for AWS so
````

Run: `nice -n 19 go test . -run 'TestRegionHA(Core|Adapters|S3)' -count=1`
Expected: PASS.

- [ ] **Step 6: Mark the rows built**

Add RHB-C19 to RHB-C39 to `regionHA2bCopyBuilt`, in the same commit as their text:

`approved_region_ha_2b_copy_test.go`:

````diff
--- a/approved_region_ha_2b_copy_test.go
+++ b/approved_region_ha_2b_copy_test.go
@@ -38,7 +38,7 @@
 // regionHA2bCopyBuilt is every id whose text the code or docs carry. The
 // task that writes a row's text adds its ids here in the same commit, once
 // Paul has approved the rows.
-var regionHA2bCopyBuilt = []string{"RHB-C1", "RHB-C2", "RHB-C3", "RHB-C4", "RHB-C5", "RHB-C6", "RHB-C7", "RHB-C8", "RHB-C9", "RHB-C10", "RHB-C11", "RHB-C12", "RHB-C13", "RHB-C14", "RHB-C15", "RHB-C16", "RHB-C17", "RHB-C18", "RHB-C64", "RHB-C67", "RHB-C68"}
+var regionHA2bCopyBuilt = []string{"RHB-C1", "RHB-C2", "RHB-C3", "RHB-C4", "RHB-C5", "RHB-C6", "RHB-C7", "RHB-C8", "RHB-C9", "RHB-C10", "RHB-C11", "RHB-C12", "RHB-C13", "RHB-C14", "RHB-C15", "RHB-C16", "RHB-C17", "RHB-C18", "RHB-C64", "RHB-C67", "RHB-C68", "RHB-C19", "RHB-C20", "RHB-C21", "RHB-C22", "RHB-C23", "RHB-C24", "RHB-C25", "RHB-C26", "RHB-C27", "RHB-C28", "RHB-C29", "RHB-C30", "RHB-C31", "RHB-C32", "RHB-C33", "RHB-C34", "RHB-C35", "RHB-C36", "RHB-C37", "RHB-C38", "RHB-C39"}
 
 func mustParseRegionHA2bRecord(t *testing.T) []copyRow {
 	t.Helper()
````

Run: `nice -n 19 go test . -run 'RegionHA2b' -count=1`
Expected: PASS once the rows are approved. While any built row is pending, `TestRegionHA2bBuiltCopyIsApproved` fails naming each one, and nothing else fails.

- [ ] **Step 7: Census**

`channelInstall.run` is a new root-read unit: its one read site opens the apply lock. Census ruling region-ha-2b is asked to class it as `RunSystemGuardArm` is. The seal stands.

`internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv`:

````diff
--- a/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv
+++ b/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv
@@ -1,7 +1,10 @@
 # Plan 14 census overlay region-ha-2b (region HA spec 2, plan 2b: docs/superpowers/plans/2026-10-17-region-ha-2b-joining.md), loaded last. RULED by the Plan 14 controller 2026-10-16 as census-ruling-region-ha-2b.
 # Task 2: host.env from the deployment's parameters (internal/hostenv: hostenv.go, the layout, assembly and rendering, with an allowlist of names, shared values written create-only and the group-box marker added on write; cli.go, carlos system host-env write and seed; write_linux.go, the write through directories held by descriptor, and write_other.go, its refusal off Linux; awsssm.go, the SSM adapter) and the SSM SDK in go.mod. Three new root-read units. runHostEnv: seed's --from file. Ruled authority A,J4 on conditions (no box write to the parameters; allowlisted names, safe values, atomic write, checked parent; authority-selecting values cross-checked against the roster at the agent's use). writeHostEnv and checkedDir (Linux): write's fixed path, /etc/carlos/host.env, reached only through /etc and /etc/carlos opened O_NOFOLLOW|O_DIRECTORY and checked by fstat (root's, writable by no group or other), the file written 0600 in that directory, synced, renamed and the directory synced; ruled A,J4 under census-ruling-region-ha-2b, as the checked parent runHostEnv's condition asks for. The legacy seal stands.
+# Task 3: carlos system install-channel (internal/host installchannel.go, installchannel_linux.go): a fresh box installs its signed channel release into both protected slots through verifyPointer, hostfloor.CheckImage and stageCandidate, all unchanged, and on every run hands each directory on the agent's cache path (system, system/router and the version's) to the service user through bootstrap's own descriptor-anchored open, refusing a symlinked parent. One new root-read unit, channelInstall.run, whose one read site opens root's apply lock (systemApplier's lockPath). Ruled authority F,J4. stageCandidate, accepted-legacy, is called and not changed. The legacy seal stands.
 @coverage	.	be7c96bb2ff00b5b018b436f976f0d33bbcc8765c17e357baa6cd0290f59f42d
 @coverage	internal/hostenv	a3f3274a86cdf8194afb871301c1ba3289dfbdc9b703dd547178273df151cdaa
 internal/hostenv/cli.go	runHostEnv	authority	A,J4	rev3=census-ruling-region-ha-2b;basis=carlos system host-env: write runs as root at a box's start-up and writes /etc/carlos/host.env, 0600, whole (temporary file and rename), from allowlisted parameters with safe values only, refusing a missing required value; its one read site is seed's --from file, an operator's host.env whose values go only to the parameter store; write's path is fixed and reached only through writeHostEnv	file:ReadFile@1500	df4470176b229b311927ff0ac4f268d354c3a2357cac4300ac0992d2ebb90899
 internal/hostenv/write_linux.go	checkedDir	authority	A,J4	rev3=census-ruling-region-ha-2b;basis=carlos system host-env write (root, Linux): opens one directory under one already held, O_NOFOLLOW|O_DIRECTORY, creating it 0755 first only when asked and missing, and refuses it unless fstat shows a directory owned by this process's user (root, as write requires) that no group or other can write	file:Fstat@576,file:Openat@168,file:Openat@372	012257ac0866f3385044785d8abae9c31e61134928c394e22a2aeaef39ec9f3c
 internal/hostenv/write_linux.go	writeHostEnv	authority	A,J4	rev3=census-ruling-region-ha-2b;basis=carlos system host-env write (root, Linux): writes /etc/carlos/host.env only through /etc and /etc/carlos held by descriptor and checked by checkedDir; the new file is created 0600, O_EXCL|O_NOFOLLOW, in that directory, synced, renamed over host.env within it, and the directory synced; its read sites open the root and the temporary file	file:Open@68,file:Openat@509	1245fc0f819ba73fb45545ffdcf92d5d4941802175e73bac209ab4d4238adff1
+@coverage	internal/host	64ee4f561f2a28d584a6f7c82e068e57db4822f6cbb130116284bca583d2d390
+internal/host/installchannel.go	channelInstall.run	authority	F,J4	rev3=census-ruling-region-ha-2b;basis=carlos system install-channel (root, at a fresh box's start-up): its one read site opens root's apply lock; the install goes through verifyPointer, hostfloor.CheckImage and stageCandidate into both protected slots, and refuses a box whose guard is armed or that already runs another release	file:OpenFile@196	f0cdb6dd28e83da282c3b0d7bc2012dcd80db181a8cecbf1a2db6bcc41d1d5e3
````

Run: `nice -n 19 go test ./internal/provision/ -run 'TestPlan9B8RecordWriteCensus|TestPlan14RootReadCensus$' -count=1 && nice -n 19 go test ./internal/console/ -run TestClaimAuthoritySourceCensus -count=1`
Expected: PASS.

- [ ] **Step 8: Commit**

```bash
git add internal/host/installchannel.go internal/host/installchannel_linux.go internal/host/installchannel_other.go internal/host/installchannel_test.go region_ha_boundary_test.go approved_region_ha_2b_copy_test.go internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv
git commit -m "region HA 2b: carlos system install-channel"
```

---

### Task 4: Group membership, read by the console

**Files:**
- Create: `internal/hostactors/membershipread.go`, `internal/hostactors/groupref.go`, `internal/hostactors/membershipread_test.go`, `internal/hostactors/export_test.go`, `internal/hostactors/boundsdk_test.go`
- Modify: `internal/hostactors/enrollment_confirmation.go` (`EnrollAutomatic` and the unexported `checkConfirmed` read membership themselves; the stored confirmation is removed), `internal/hostactors/private.go` (a group box's report under its derived reference)
- Modify: `internal/hostactors/attempt_regression_test.go`, `internal/hostactors/roles_test.go` (main's tests, onto a fake reader)
- Create: `internal/hostactors/awsmembership/awsmembership.go`, `awsmembership_test.go`
- Modify: `go.mod`, `go.sum` (`github.com/aws/aws-sdk-go-v2/service/autoscaling v1.74.0`)
- Modify: `region_ha_boundary_test.go`, `internal/provision/plan9_b8_census_test.go` (`deadlineStore.Put`), the overlay

**Interfaces:**
- Consumes: `validateScope`, `segment`, `token`, `EnrollmentAttempt`, `Directory.enroll`, `Replace` and `checkMembership` (all main's, unchanged, run on the bound store), `release.Store`.
- Produces (`hostactors`): `type MembershipReader interface { Member(ctx, awsRegion, instance string) (Membership, error) }`; `Membership`; `MembershipEvidence{RequestIDs []string; ObservedAt time.Time}`; `ErrMembershipUnknown`, `ErrNotMember`, `ErrMembershipStale`, `ErrWriteDeadline`; `GroupBoxReference(account, instance string) string`; `func (d Directory) EnrollAutomatic(ctx, sg release.Signer, o Observed, v boxid.Verifier, in EnrollmentRequest, raw []byte, instance string, r MembershipReader, now func() time.Time) (Observed, MembershipEvidence, error)`. Unexported: `func (d Directory) checkConfirmed(ctx, o Observed, v boxid.Verifier, in EnrollmentRequest, raw []byte, instance string, r MembershipReader, now func() time.Time) (EnrollmentRequest, membershipConfirmation, error)`, `membershipConfirmation` (with `fresh`, `deadline`, `atWrite`, `evidence`), `confirmMembership` (each `Member` call cut off at `storeCallTimeout`, as unknown), `writeBound`, `deadlineStore` (with `call`), `membershipFreshness = time.Minute`, and `var storeCallTimeout = 15 * time.Second` (a var so tests can shorten it). Changed: `Directory.PrivateBoxReport` reads a group box (`Box.Group` set) under `GroupBoxReference` of its pinned account and instance.
- Removes (`hostactors`): `ConfirmationKey`, `MembershipConfirmation`, `TerminatedEnrollment`, `freshConfirmation`. Nothing on main but `EnrollAutomatic` and its two tests used them.
- Produces (`hostactors/awsmembership`): `New(ctx) (*Reader, error)` over the console's credentials; `(*Reader).Member` (`DescribeAutoScalingInstances`, then `DescribeInstances` by the `instance-id` filter).

H28: the console is the only membership confirmer, and its read cannot be forged (C1). `EnrollAutomatic` no longer reads a confirmation object from the store: it takes the console's `MembershipReader`, calls `Member` itself, and builds the confirmation from that answer through the unexported `confirmMembership`. The confirmation type is unexported and never leaves the package, and no exported function takes one, or a `Membership`; `TestNoExportedPathTakesAConfirmation` holds that. `checkConfirmed`, unexported, is the same check without the write, which the console's pass makes immediately before it invites (Task 5); it returns the confirmation itself, and the pass binds the invitation to it with `writeBound`, so no exported path carries one.

Every other admission check stands, unchanged: the attempt in the roster matches the request, the record (by digest), the instance, the region's group (name and ARN), account and AWS region; it is not consumed; the two attempt ids are tokens and differ. The checks that existed only for box confirmers go: the confirmer's roster entry and `TerminationFence`, and the cross-region `TerminatedEnrollments` (H17 is withdrawn: the console reads any region, so an emptied region recovers the same way). `BeginEnrollmentAttempt` and the single-use `Consumed` are main's, unchanged.

The read (C2) is a member only when the instance is in the region's group, `Pending` or `InService`, `pending` or `running`, owned by the region's account and in its AWS region, with a request id from each call. Its anchor, its time, is taken before the call, so never later than the describe's completion. `checkConfirmed` refuses a read that was already 60 seconds old or more when the describe returned (`ErrMembershipStale`, which counts against the attempt); that is the only freshness check besides the bound. A failed call, or an answer that is not one (EC2 does not list the instance, a request id is missing), is `ErrMembershipUnknown`, never `ErrNotMember` (#7). The request ids come back as `MembershipEvidence` whenever the cloud answered, refusals included, for the console's log: main's roster has no field for them, and none is added.

**The write bound: the single enforcement point (S1, T3).** The write deadline is the read's anchor plus 60 seconds, and a write at or after it is refused: the write is issued no later than 60 s after the membership read. Each call issued has a hard timeout of 15 s (U2). What the bound guarantees, and what it does not (V1): every store call of the invite and the enrolling write is issued before the deadline (60 s from the start of the membership describe); the console waits at most 15 s for any call; a write whose result the console did not see is unknown and is settled on a later pass by reading the roster. A cancelled write can still commit later; such an enrolment rests on a membership read that was true when the write was issued, and is the same exposure as an instance detached after enrolment, which spec 3's worker reconciles. `Invite`, `checkMembership` and `Replace` are sealed and stay unchanged, and so does `Directory.enroll` (round 3 removed the callback round 1 gave it); instead `writeBound` gives them a copy of the `Directory` whose store is a `deadlineStore` and a context that ends at the deadline. What the bound guarantees:
- no store call (`Get`, `Put`, `List`, `ListDirs`, `Delete`), the roster `Put` included, is issued at or after the deadline, by the read's clock or the context's: `deadlineStore` refuses it before it reaches the store underneath;
- every call runs under `deadlineStore.call`, with its own context that ends 15 seconds after it was issued (`storeCallTimeout`) or at the deadline, whichever is first. A call still in flight at the deadline, or still unanswered at its timeout, is cut off: `call` returns `ErrWriteDeadline` itself, without waiting for a store that ignores its context, and cancels the call's context, which the AWS SDK enforces at the HTTP layer;
- a call that answers only after the deadline, even with success, is `ErrWriteDeadline` too, so a write whose commit can't be confirmed before the deadline is unknown;
- an unknown write keeps the attempt and its wait, and the next pass reads the roster as every pass does (Task 5, U1);
- it bounds the console's waiting, not the bucket's commit: a call cut off may still be applied later. Every write it carries is conditional ("Global Constraints: Freshness bound", V4), so such a write lands only on the roster revision it was built against.

The store is wrapped because it can't be trusted to do this: `release.DirStore` ignores its context (`release.S3Store` passes it to the AWS SDK, which does honour it). The pass's S3 store is the console's claim data boundary (`internal/console/claim_runtime.go:42`, `NewConditionalRecordStore`, then `NewObservedMutationBoundary`), whose S3 client the console shares; U2 preferred a dedicated client for the pass, but a second client means a second boundary, with its own census-selected construction and its own mutation observer. The wrapper gives the same hard limit without one: each call's own 15-second context reaches the SDK's HTTP request, and `call` stops waiting at that point whatever the store does. `TestTheBoundReachesTheSDKRequest` (V2, W1) holds that: it builds the console's stores as `claim_runtime.go:33` to `:50` does, the production mutation check included, over `release.NewS3Store` with only the network replaced by a recorder that honours S3's conditional headers. The bucket is seeded with a root-signed roster, so the enrolling roster write is a successful conditional `PUT` through the whole stack: the deadline store, the observed mutation boundary (its pre-read, the production check, which admits a root-signed roster, and its forwarding), the conditional record store's forwarding and the S3 store. The test asserts the SDK sends exactly the roster read's `GET`, then the write's pre-read `GET` and its `PUT`, that the `PUT` carries `If-Match` with the ETag read, that a stale ETag is then refused, and that every request carries 15 seconds from its call, or the write deadline when that is earlier. `export_test.go` gives the external test `writeBound`; the test is external because `hostops` imports `hostactors`. `EnrollAutomatic` runs main's `enroll` on the bound directory. The tests drive a describe of exactly 60 seconds, a clock that reaches the deadline between the check and the roster write, and (Task 5) a store read that answers at the deadline inside the sealed functions, a `Put` still in flight there, a store that ignores its context and never answers, and a `Put` that answers success after the deadline.

**Group references (S3).** `GroupBoxReference` is platform-1e's function: `g-` and the first 20 hex digits of `sha256("carlos-box-ref-v1|" + account + "|" + instance)`. `checkConfirmed` refuses a request whose reference is not the attempt instance's group reference, before the cloud is asked, and `PrivateBoxReport` reads a group box's report under the reference derived from the instance its entry pins, so `checkMembership`'s `VerifyReport`, the census and activation never read a group box's report under a key the box chose. A report another box writes there names its own instance and enrollment, and `VerifyReport` refuses it. Non-group boxes are read exactly as before. `deadlineStore.Put` is a new site in the record-write census (nonrecord).

- [ ] **Step 1: Write the failing tests**

`internal/hostactors/membershipread_test.go`:

````go
package hostactors

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"reflect"
	"strings"
	"testing"
	"time"

	"github.com/carlosframework/platform/internal/boxid"
	"github.com/carlosframework/platform/internal/boxid/boxidtest"
	"github.com/carlosframework/platform/internal/release"
	"github.com/carlosframework/platform/internal/status"
)

// fakeMembership is the cloud, as the console's reader sees it: each
// instance's membership. err fails every call; during runs inside each
// call (a slow describe, say).
type fakeMembership struct {
	members map[string]Membership
	err     error
	during  func()
	calls   []string
}

func (f *fakeMembership) Member(_ context.Context, awsRegion, instance string) (Membership, error) {
	f.calls = append(f.calls, awsRegion+" "+instance)
	if f.during != nil {
		f.during()
	}
	if f.err != nil {
		return Membership{}, f.err
	}
	return f.members[instance], nil
}

func member(group string) Membership {
	return Membership{GroupName: group, AvailabilityZoneRegion: "eu-west-1", LifecycleState: "InService", InstanceState: "running", Owner: "123", RequestIDs: []string{"asg-req", "ec2-req"}}
}

// memberWorld is fixture()'s roster with ie's group "routers-ie", and a
// box (instance i-new, reference newRef) the group launched, invited, with an open
// attempt.
type memberWorld struct {
	d     Directory
	sg    release.Signer
	o     Observed
	v     boxid.Verifier
	raw   []byte
	in    EnrollmentRequest
	cloud *fakeMembership
	clock time.Time
}

func newMemberWorld(t *testing.T, m Membership) *memberWorld {
	t.Helper()
	ctx := context.Background()
	d, sg, o := fixture(t)
	aws := boxidtest.NewSigner(t)
	_, raw := aws.Record(t, newRef, "123", "eu-west-1", "i-new")
	next := Clone(o.Roster)
	next.Regions = map[string]Region{"ie": {Account: "123", AWSRegion: "eu-west-1", GroupName: "routers-ie", GroupARN: "arn:aws:autoscaling:eu-west-1:123:autoScalingGroup:x:autoScalingGroupName/routers-ie", Namespaces: []string{"platform"}, Pools: []string{"default"}}}
	o, err := d.Replace(ctx, sg, o, next)
	if err != nil {
		t.Fatal(err)
	}
	w := &memberWorld{d: d, sg: sg, v: boxid.Verifier{Account: "123", Certs: aws.Certs()}, raw: raw, clock: time.Now().UTC(),
		cloud: &fakeMembership{members: map[string]Membership{"i-new": m}},
		in:    EnrollmentRequest{Reference: newRef, Namespace: "platform", Label: newRef, Region: "ie", Pool: "default", AWSRegion: "eu-west-1"}}
	if _, err := d.Invite(ctx, sg, newRef, raw); err != nil {
		t.Fatal(err)
	}
	o, a, err := d.BeginEnrollmentAttempt(ctx, sg, o, w.in, raw, "i-new")
	if err != nil {
		t.Fatal(err)
	}
	w.o, w.in.AttemptID, w.in.ConfirmationID = o, a.AttemptID, a.ConfirmationID
	return w
}

func (w *memberWorld) now() time.Time { return w.clock }

func (w *memberWorld) enroll() (Observed, MembershipEvidence, error) {
	return w.d.EnrollAutomatic(context.Background(), w.sg, w.o, w.v, w.in, w.raw, "i-new", w.cloud, w.now)
}

// The console reads membership itself, once, in the attempt's AWS region,
// and enrolls on it; the read's request ids come back for its log.
func TestEnrollAutomaticReadsMembershipItself(t *testing.T) {
	w := newMemberWorld(t, member("routers-ie"))
	next, ev, err := w.enroll()
	if err != nil {
		t.Fatal(err)
	}
	if b := next.Roster.Boxes[newRef]; b.State != "active" || b.Group != "routers-ie" || b.Cloud.InstanceID != "i-new" {
		t.Fatalf("roster entry %+v", b)
	}
	if !reflect.DeepEqual(w.cloud.calls, []string{"eu-west-1 i-new"}) || !reflect.DeepEqual(ev.RequestIDs, []string{"asg-req", "ec2-req"}) || !ev.ObservedAt.Equal(w.clock) {
		t.Fatalf("calls %v, evidence %+v", w.cloud.calls, ev)
	}
	// A replay of the consumed attempt is refused before the cloud is
	// asked again.
	w.o = next
	if _, _, err := w.enroll(); !errors.Is(err, ErrRoster) || len(w.cloud.calls) != 1 {
		t.Fatalf("replay: %v, calls %v", err, w.cloud.calls)
	}
	if _, _, err := w.d.checkConfirmed(context.Background(), next, w.v, w.in, w.raw, "i-new", w.cloud, w.now); !errors.Is(err, ErrRoster) {
		t.Fatalf("replayed check: %v", err)
	}
}

// Every positive answer that the instance is not a current member of the
// region's group refuses, and writes nothing.
func TestEnrollAutomaticRefusesANonMember(t *testing.T) {
	for name, m := range map[string]Membership{
		"another group":     member("routers-uk"),
		"no group":          {Owner: "123", InstanceState: "running", AvailabilityZoneRegion: "eu-west-1", RequestIDs: []string{"a", "b"}},
		"leaving the group": func() Membership { m := member("routers-ie"); m.LifecycleState = "Terminating"; return m }(),
		"stopped":           func() Membership { m := member("routers-ie"); m.InstanceState = "stopped"; return m }(),
		"terminated":        func() Membership { m := member("routers-ie"); m.InstanceState = "terminated"; return m }(),
		"another account":   func() Membership { m := member("routers-ie"); m.Owner = "999"; return m }(),
		"another zone":      func() Membership { m := member("routers-ie"); m.AvailabilityZoneRegion = "eu-west-2"; return m }(),
	} {
		t.Run(name, func(t *testing.T) {
			w := newMemberWorld(t, m)
			if _, _, err := w.d.checkConfirmed(context.Background(), w.o, w.v, w.in, w.raw, "i-new", w.cloud, w.now); !errors.Is(err, ErrNotMember) {
				t.Fatalf("check: %v", err)
			}
			if _, _, err := w.enroll(); !errors.Is(err, ErrNotMember) {
				t.Fatalf("enroll: %v", err)
			}
			o, err := w.d.Read(context.Background())
			if err != nil || o.Roster.Revision != w.o.Roster.Revision {
				t.Fatalf("a refused enrollment wrote the roster: %v", err)
			}
		})
	}
}

// A read that fails, or is not an answer, is unknown: never a member and
// never a non-member.
func TestAMembershipReadThatIsNotAnAnswerIsUnknown(t *testing.T) {
	for name, edit := range map[string]func(*fakeMembership){
		"the call fails": func(f *fakeMembership) { f.err = errors.New("Throttling") },
		"EC2 does not list it": func(f *fakeMembership) {
			m := f.members["i-new"]
			m.InstanceState, m.Owner = "", ""
			f.members["i-new"] = m
		},
		"one request id": func(f *fakeMembership) { m := f.members["i-new"]; m.RequestIDs = []string{"a"}; f.members["i-new"] = m },
		"a blank request id": func(f *fakeMembership) {
			m := f.members["i-new"]
			m.RequestIDs = []string{"a", ""}
			f.members["i-new"] = m
		},
	} {
		t.Run(name, func(t *testing.T) {
			w := newMemberWorld(t, member("routers-ie"))
			edit(w.cloud)
			_, _, err := w.enroll()
			if !errors.Is(err, ErrMembershipUnknown) || errors.Is(err, ErrNotMember) {
				t.Fatalf("%v", err)
			}
		})
	}
}

// C2: the write deadline is 60 seconds from the read's anchor, taken
// before the describe and so never later than its completion, and a read
// sixty seconds old or more is refused. A describe that took exactly a
// minute leaves the roster as it was.
func TestEnrollAutomaticRefusesAReadOlderThanAMinuteAtTheWrite(t *testing.T) {
	w := newMemberWorld(t, member("routers-ie"))
	w.cloud.during = func() { w.clock = w.clock.Add(60 * time.Second) }
	if _, _, err := w.enroll(); !errors.Is(err, ErrMembershipStale) {
		t.Fatalf("slow read: %v", err)
	}
	o, err := w.d.Read(context.Background())
	if err != nil || o.Roster.Revision != w.o.Roster.Revision || o.Roster.EnrollmentAttempts[newRef].Consumed {
		t.Fatalf("a stale read wrote the roster: %v", err)
	}
	// A read that was fresh when it was checked but is over a minute old
	// by the write: here every look at the clock takes 31 seconds, so the
	// read is 31 seconds old when checked and 62 when the roster write is
	// about to be made. The write bound refuses it there, as unknown.
	u := newMemberWorld(t, member("routers-ie"))
	ticking := func() time.Time { u.clock = u.clock.Add(31 * time.Second); return u.clock }
	if _, _, err := u.d.EnrollAutomatic(context.Background(), u.sg, u.o, u.v, u.in, u.raw, "i-new", u.cloud, ticking); !errors.Is(err, ErrWriteDeadline) || errors.Is(err, ErrMembershipStale) {
		t.Fatalf("a read gone stale before the write: %v", err)
	}
	if o, err := u.d.Read(context.Background()); err != nil || o.Roster.Revision != u.o.Roster.Revision {
		t.Fatalf("a read gone stale before the write wrote the roster: %v", err)
	}
	// Fifty-nine seconds is inside the minute.
	v := newMemberWorld(t, member("routers-ie"))
	v.cloud.during = func() { v.clock = v.clock.Add(59 * time.Second) }
	if _, _, err := v.enroll(); err != nil {
		t.Fatalf("a read inside the minute: %v", err)
	}
}

// No exported identifier of this package carries a membership
// confirmation: the only one is built inside it, from a reader's answer.
func TestNoExportedPathTakesAConfirmation(t *testing.T) {
	// Every exported method of Directory, EnrollAutomatic among them: the
	// admission check itself (checkConfirmed) is not exported.
	if _, ok := reflect.TypeOf(Directory{}).MethodByName("EnrollAutomatic"); !ok {
		t.Fatal("no EnrollAutomatic")
	}
	if _, ok := reflect.TypeOf(Directory{}).MethodByName("CheckConfirmed"); ok {
		t.Fatal("the admission check is exported again")
	}
	for j := 0; j < reflect.TypeOf(Directory{}).NumMethod(); j++ {
		m := reflect.TypeOf(Directory{}).Method(j)
		name := m.Name
		for i := 0; i < m.Type.NumIn(); i++ {
			in := m.Type.In(i)
			if strings.Contains(strings.ToLower(in.Name()), "confirmation") || in == reflect.TypeOf(MembershipEvidence{}) || in == reflect.TypeOf(Membership{}) {
				t.Fatalf("%s takes a %s", name, in)
			}
		}
	}
}

// newRef is the joining box's reference: the group reference of
// instance i-new in account 123.
var newRef = GroupBoxReference("123", "i-new")

// The ruling's first test: two group boxes never share a reference. The
// reference is a pseudonym of account and instance, stable, and outside
// the label space.
func TestGroupBoxReferencesDoNotCollide(t *testing.T) {
	if GroupBoxReference("123", "i-0a61f580df1acfa65") != GroupBoxReference("123", "i-0a61f580df1acfa65") {
		t.Fatal("not stable")
	}
	if GroupBoxReference("123", "i-a") == GroupBoxReference("124", "i-a") {
		t.Fatal("the account is not in the reference")
	}
	seen := map[string]string{}
	for i := 0; i < 200000; i++ {
		inst := fmt.Sprintf("i-%017x", i)
		ref := GroupBoxReference("123456789012", inst)
		if len(ref) != 22 || !strings.HasPrefix(ref, "g-") || !segment(ref) {
			t.Fatalf("%s: %q", inst, ref)
		}
		if other, ok := seen[ref]; ok {
			t.Fatalf("%s and %s share %s", inst, other, ref)
		}
		seen[ref] = inst
	}
	if strings.Contains(GroupBoxReference("123", "i-0a61f580df1acfa65"), "0a61f580") {
		t.Fatal("the reference shows the instance id")
	}
}

// The console reads a group box's report under the reference it derives
// from the roster's pinned instance, never the label in the entry; every
// other box under its reference, exactly as before.
func TestPrivateBoxReportDerivesAGroupBoxsKey(t *testing.T) {
	d, _, _ := fixture(t)
	ctx := context.Background()
	put := func(key, instance string) {
		raw, _ := json.Marshal(status.Box{GeneratedAt: time.Now(), Metrics: &status.BoxMetrics{InstanceID: instance}})
		if _, err := d.Store.Put(ctx, key, raw, ""); err != nil {
			t.Fatal(err)
		}
	}
	derived := GroupBoxReference("123", "i-g")
	put("status/boxes/"+derived+".json", "i-g")
	put("status/boxes/chosen.json", "i-chosen")
	put("status/boxes/annie-welding.json", "i-old")
	r, err := d.PrivateBoxReport(ctx, "chosen", Box{Label: "chosen", Group: "routers-ie", Cloud: Cloud{Account: "123", InstanceID: "i-g"}})
	if err != nil || r.InstanceID != "i-g" {
		t.Fatalf("group box: %+v %v", r, err)
	}
	r, err = d.PrivateBoxReport(ctx, "annie-welding", Box{Label: "annie-welding", Cloud: Cloud{Account: "123", InstanceID: "i-old"}})
	if err != nil || r.InstanceID != "i-old" {
		t.Fatalf("a box an operator enrolled: %+v %v", r, err)
	}
	if _, err := d.PrivateBoxReport(ctx, derived, Box{Group: "routers-ie"}); !errors.Is(err, ErrRoster) {
		t.Fatalf("a group box with no pinned instance: %v", err)
	}
}

// A group box is admitted only under the reference its instance derives:
// an attempt under a reference the box chose is refused before the cloud
// is asked, even for a member.
func TestEnrollAutomaticRefusesAReferenceTheBoxChose(t *testing.T) {
	w := newMemberWorld(t, member("routers-ie"))
	in := w.in
	in.Reference, in.Label = "chosen", "chosen"
	o, a, err := w.d.BeginEnrollmentAttempt(context.Background(), w.sg, w.o, in, w.raw, "i-new")
	if err != nil {
		t.Fatal(err)
	}
	in.AttemptID, in.ConfirmationID = a.AttemptID, a.ConfirmationID
	if _, _, err := w.d.EnrollAutomatic(context.Background(), w.sg, o, w.v, in, w.raw, "i-new", w.cloud, w.now); !errors.Is(err, ErrRoster) || len(w.cloud.calls) != 0 {
		t.Fatalf("a chosen reference: %v, calls %v", err, w.cloud.calls)
	}
}
````

The bound through the console's production stores, down to the SDK request (V2):

`internal/hostactors/export_test.go`:

````go
package hostactors

import (
	"context"
	"time"
)

// BindForTest is writeBound for a membership read anchored at observedAt,
// for the external test that drives the console's production store.
func BindForTest(ctx context.Context, d Directory, observedAt time.Time, now func() time.Time) (Directory, context.Context, context.CancelFunc) {
	return writeBound(ctx, d, membershipConfirmation{observedAt: observedAt}, now)
}
````

`internal/hostactors/boundsdk_test.go`:

````go
package hostactors_test

import (
	"bytes"
	"context"
	"crypto/ed25519"
	"errors"
	"fmt"
	"io"
	"net/http"
	"strings"
	"sync"
	"testing"
	"time"

	awss3 "github.com/aws/aws-sdk-go-v2/service/s3"

	"github.com/carlosframework/platform/internal/hostactors"
	"github.com/carlosframework/platform/internal/hostops"
	"github.com/carlosframework/platform/internal/release"
)

// sdkRequest is one HTTP request the AWS SDK sent: its method, its path,
// its If-Match condition, and how long its context had left when it was
// sent (zero: no deadline).
type sdkRequest struct {
	method, path, ifMatch string
	left                  time.Duration
}

// recordingHTTP stands in for the network under the SDK: a bucket that
// honours S3's conditional headers. It records each request's context
// deadline. A GET answers the object and its ETag, or NoSuchKey; a PUT
// with If-Match succeeds only on the current ETag, one with
// If-None-Match: * only when the key is absent, and gets a new ETag.
type recordingHTTP struct {
	mu      sync.Mutex
	seen    []sdkRequest
	objects map[string]bucketObject
	next    int
}

type bucketObject struct {
	body []byte
	etag string
}

func (h *recordingHTTP) Do(r *http.Request) (*http.Response, error) {
	var left time.Duration
	if dl, ok := r.Context().Deadline(); ok {
		left = time.Until(dl)
	}
	var body []byte
	if r.Body != nil {
		body, _ = io.ReadAll(r.Body)
	}
	h.mu.Lock()
	defer h.mu.Unlock()
	h.seen = append(h.seen, sdkRequest{r.Method, r.URL.Path, r.Header.Get("If-Match"), left})
	resp := &http.Response{Header: http.Header{}, Request: r, Proto: "HTTP/1.1", ProtoMajor: 1, ProtoMinor: 1, Body: io.NopCloser(strings.NewReader(""))}
	reply := func(code int, xml string) (*http.Response, error) {
		resp.StatusCode = code
		resp.Header.Set("Content-Type", "application/xml")
		resp.Body = io.NopCloser(strings.NewReader(`<?xml version="1.0" encoding="UTF-8"?><Error><Code>` + xml + `</Code><Message>fake</Message></Error>`))
		return resp, nil
	}
	key := r.URL.Path
	cur, exists := h.objects[key]
	switch r.Method {
	case http.MethodPut:
		if m := r.Header.Get("If-Match"); m != "" && (!exists || strings.Trim(m, `"`) != strings.Trim(cur.etag, `"`)) {
			return reply(http.StatusPreconditionFailed, "PreconditionFailed")
		}
		if r.Header.Get("If-None-Match") == "*" && exists {
			return reply(http.StatusPreconditionFailed, "PreconditionFailed")
		}
		h.next++
		obj := bucketObject{body, fmt.Sprintf(`"e%d"`, h.next)}
		h.objects[key] = obj
		resp.StatusCode = http.StatusOK
		resp.Header.Set("ETag", obj.etag)
		return resp, nil
	case http.MethodGet:
		if !exists {
			return reply(http.StatusNotFound, "NoSuchKey")
		}
		resp.StatusCode = http.StatusOK
		resp.Header.Set("ETag", cur.etag)
		resp.Header.Set("Content-Length", fmt.Sprint(len(cur.body)))
		resp.Body = io.NopCloser(bytes.NewReader(cur.body))
		resp.ContentLength = int64(len(cur.body))
		return resp, nil
	}
	return reply(http.StatusNotFound, "NoSuchKey")
}

func (h *recordingHTTP) take() []sdkRequest {
	h.mu.Lock()
	defer h.mu.Unlock()
	out := h.seen
	h.seen = nil
	return out
}

// productionStores builds the console's stores as claim_runtime.go does:
// the release S3 store (its own construction, s3compat and all, with only
// the network replaced), the conditional record store over it, and the
// observed mutation boundary with the production mutation check over
// that: the store the enrolment pass's Directory carries.
func productionStores(t *testing.T) (raw *release.S3Store, data release.Store, h *recordingHTTP, root release.Signer) {
	t.Helper()
	t.Setenv("AWS_ACCESS_KEY_ID", "k")
	t.Setenv("AWS_SECRET_ACCESS_KEY", "s")
	t.Setenv("AWS_SESSION_TOKEN", "")
	t.Setenv("AWS_REGION", "eu-west-1")
	t.Setenv("AWS_EC2_METADATA_DISABLED", "true")
	t.Setenv("AWS_CONFIG_FILE", "/nonexistent")
	t.Setenv("AWS_SHARED_CREDENTIALS_FILE", "/nonexistent")
	t.Setenv("CARLOS_S3_ENDPOINT", "http://127.0.0.1:9")
	raw, err := release.NewS3Store(context.Background(), "deployment")
	if err != nil {
		t.Fatal(err)
	}
	h = &recordingHTTP{objects: map[string]bucketObject{}}
	raw.Client = awss3.New(raw.Client.Options(), func(o *awss3.Options) { o.HTTPClient = h; o.RetryMaxAttempts = 1 })
	pub, priv, err := ed25519.GenerateKey(nil)
	if err != nil {
		t.Fatal(err)
	}
	control, err := hostops.NewControlWriter(raw, pub, time.Now)
	if err != nil {
		t.Fatal(err)
	}
	check, err := hostops.NewProductionMutationCheck(raw, control)
	if err != nil {
		t.Fatal(err)
	}
	records, err := hostops.NewConditionalRecordStore(raw, pub)
	if err != nil {
		t.Fatal(err)
	}
	data, err = hostops.NewObservedMutationBoundary(true, records, check, nil)
	if err != nil {
		t.Fatal(err)
	}
	return raw, data, h, release.RootSigner(priv)
}

// V2 and W1 (platform-1e): the per-call context of a bound write reaches
// the AWS SDK's HTTP request through the console's production stores, so
// the 15-second limit is enforced where the request is sent. The roster
// write is a successful conditional PUT through the whole stack: the
// bound's deadline store, the observed mutation boundary (its pre-read,
// the production mutation check, which admits a root-signed roster, and
// its forwarding), the conditional record store's forwarding and the S3
// store. Every request it sends, the PUT included, carries a deadline of
// 15 seconds from its call, or the write deadline when that comes first.
// A wrapper anywhere on the path that drops the context, or a bound that
// does not give each call its own, fails here.
func TestTheBoundReachesTheSDKRequest(t *testing.T) {
	_, data, h, root := productionStores(t)
	for name, c := range map[string]struct {
		readAge   time.Duration
		low, high time.Duration
	}{
		"the per-call limit":         {0, 14 * time.Second, 15 * time.Second},
		"the earlier write deadline": {50 * time.Second, 9 * time.Second, 10 * time.Second},
	} {
		t.Run(name, func(t *testing.T) {
			check := func(what string, got []sdkRequest, methods ...string) []sdkRequest {
				t.Helper()
				var sent []string
				for _, r := range got {
					sent = append(sent, r.method)
					if r.left <= c.low || r.left > c.high {
						t.Errorf("%s: %s %s had %s left, want (%s, %s]", what, r.method, r.path, r.left, c.low, c.high)
					}
				}
				if strings.Join(sent, " ") != strings.Join(methods, " ") {
					t.Fatalf("%s: the SDK sent %v, want %v", what, sent, methods)
				}
				return got
			}
			// The bucket holds a roster the pass has read.
			h.mu.Lock()
			h.objects = map[string]bucketObject{}
			h.mu.Unlock()
			before, err := release.SignRootEnvelope(root, map[string]int{"revision": 1})
			if err != nil {
				t.Fatal(err)
			}
			d := hostactors.Directory{Store: data, Bucket: "deployment"}
			etag, err := data.Put(context.Background(), hostactors.Key, before, "")
			if err != nil {
				t.Fatal(err)
			}
			h.take()
			bd, bctx, cancel := hostactors.BindForTest(context.Background(), d, time.Now().Add(-c.readAge), time.Now)
			defer cancel()
			if _, got, err := bd.Store.Get(bctx, hostactors.Key); err != nil || got != etag {
				t.Fatalf("roster read: %q %v", got, err)
			}
			check("the roster read", h.take(), http.MethodGet)
			// The enrolling write: conditional on that ETag, and admitted.
			next, err := release.SignRootEnvelope(root, map[string]int{"revision": 2})
			if err != nil {
				t.Fatal(err)
			}
			after, err := bd.Store.Put(bctx, hostactors.Key, next, etag)
			if err != nil || after == etag {
				t.Fatalf("the conditional roster write: %q %v", after, err)
			}
			sent := check("the roster write", h.take(), http.MethodGet, http.MethodPut)
			if strings.Trim(sent[1].ifMatch, `"`) != strings.Trim(etag, `"`) {
				t.Fatalf("the roster PUT was sent with If-Match %q, want the ETag read, %q", sent[1].ifMatch, etag)
			}
			// And it stays conditional: the old ETag now loses.
			if _, err := bd.Store.Put(bctx, hostactors.Key, next, etag); !errors.Is(err, release.ErrCASConflict) {
				t.Fatalf("a stale ETag was not refused: %v", err)
			}
			h.take()
		})
	}
}
````

Main's two tests that wrote a confirmation object read through a fake reader instead:

`internal/hostactors/attempt_regression_test.go`:

````diff
--- a/internal/hostactors/attempt_regression_test.go
+++ b/internal/hostactors/attempt_regression_test.go
@@ -10,12 +10,11 @@
 	"encoding/base64"
 	"encoding/json"
 	"github.com/carlosframework/platform/internal/boxid"
-	"github.com/carlosframework/platform/internal/release"
 	"testing"
 	"time"
 )
 
-func TestEnrollmentRefreshRejectsExpiredSupersededAndConsumedEvidence(t *testing.T) {
+func TestEnrollmentRejectsSupersededAndConsumedAttempts(t *testing.T) {
 	ctx := context.Background()
 	d, sg, o := fixture(t)
 	key, err := rsa.GenerateKey(rand.Reader, 2048)
@@ -28,10 +27,10 @@
 	if err != nil {
 		t.Fatal(err)
 	}
-	rec := boxid.Record{V: 1, Box: "new", IdentityDocument: base64.StdEncoding.EncodeToString(doc), IdentitySignature: base64.StdEncoding.EncodeToString(sig)}
+	rec := boxid.Record{V: 1, Box: newRef, IdentityDocument: base64.StdEncoding.EncodeToString(doc), IdentitySignature: base64.StdEncoding.EncodeToString(sig)}
 	raw, _ := json.Marshal(rec)
 	v := boxid.Verifier{Account: "123", Certs: map[string]*x509.Certificate{"eu-west-1": {PublicKey: &key.PublicKey, PublicKeyAlgorithm: x509.RSA}}}
-	in := EnrollmentRequest{Reference: "new", Namespace: "platform", Label: "new", Region: "ie", Pool: "default", AWSRegion: "eu-west-1"}
+	in := EnrollmentRequest{Reference: newRef, Namespace: "platform", Label: newRef, Region: "ie", Pool: "default", AWSRegion: "eu-west-1"}
 	next := Clone(o.Roster)
 	next.Regions = map[string]Region{"ie": {Account: "123", AWSRegion: "eu-west-1", GroupName: "routers", GroupARN: "group-arn", Namespaces: []string{"platform"}, Pools: []string{"default"}}}
 	o, err = d.Replace(ctx, sg, o, next)
@@ -45,27 +44,9 @@
 	if err != nil {
 		t.Fatal(err)
 	}
-	now := time.Now().UTC()
-	confirmation := func(a EnrollmentAttempt, at time.Time) MembershipConfirmation {
-		c := MembershipConfirmation{PassID: "pass-1", AttemptID: a.AttemptID, ConfirmationID: a.ConfirmationID, EvidenceSHA256: a.EvidenceSHA256, TargetPlacement: "ie", AWSRegion: "eu-west-1", GroupName: "routers", GroupARN: "group-arn", InstanceID: "i-new", Account: "123", AvailabilityZoneRegion: "eu-west-1", LifecycleState: "InService", InstanceState: "running", ObservedAt: at, RequestIDs: []string{"asg", "ec2"}}
-		c.Confirmer.Box = "box"
-		c.Confirmer.Enrollment = o.Roster.Boxes["box"].Enrollment
-		c.Confirmer.Region = "ie"
-		return c
-	}
-	save := func(a EnrollmentAttempt, c MembershipConfirmation) {
-		t.Helper()
-		wire, _ := json.Marshal(c)
-		if err := release.CreateExact(ctx, d.Store, ConfirmationKey(in.Reference, a.AttemptID, a.ConfirmationID), wire); err != nil {
-			t.Fatal(err)
-		}
-	}
-	save(first, confirmation(first, now.Add(-2*time.Minute)))
-	in.AttemptID = first.AttemptID
-	in.ConfirmationID = first.ConfirmationID
-	if _, err = d.EnrollAutomatic(ctx, sg, o, v, in, raw, "i-new", now); err == nil {
-		t.Fatal("expired evidence admitted")
-	}
+	// The console's own read, which says the instance is a member.
+	cloud := &fakeMembership{members: map[string]Membership{"i-new": member("routers")}}
+	now := func() time.Time { return time.Now().UTC() }
 	staleRoster := o
 	o, second, err := d.BeginEnrollmentAttempt(ctx, sg, o, in, raw, "i-new")
 	if err != nil {
@@ -74,30 +55,24 @@
 	if first.AttemptID == second.AttemptID || first.ConfirmationID == second.ConfirmationID {
 		t.Fatal("immutable identity reused")
 	}
-	save(second, confirmation(second, now))
-	if _, err = d.EnrollAutomatic(ctx, sg, o, v, in, raw, "i-new", now); err == nil {
+	in.AttemptID, in.ConfirmationID = first.AttemptID, first.ConfirmationID
+	if _, _, err = d.EnrollAutomatic(ctx, sg, o, v, in, raw, "i-new", cloud, now); err == nil {
 		t.Fatal("superseded request admitted")
 	}
-	if _, err = d.EnrollAutomatic(ctx, sg, o, v, in, raw, "i-new", now.Add(-2*time.Minute)); err == nil {
-		t.Fatal("superseded IDs admitted while old evidence was still fresh")
-	}
-	// Even a still-fresh old confirmation cannot win the stale roster CAS.
-	if _, err = d.EnrollAutomatic(ctx, sg, staleRoster, v, in, raw, "i-new", now.Add(-2*time.Minute)); err == nil {
+	// The superseded roster snapshot loses its CAS even with its own
+	// attempt's ids.
+	if _, _, err = d.EnrollAutomatic(ctx, sg, staleRoster, v, in, raw, "i-new", cloud, now); err == nil {
 		t.Fatal("superseded snapshot admitted")
 	}
-	in.AttemptID = second.AttemptID
-	in.ConfirmationID = second.ConfirmationID
-	admitted, err := d.EnrollAutomatic(ctx, sg, o, v, in, raw, "i-new", now)
+	in.AttemptID, in.ConfirmationID = second.AttemptID, second.ConfirmationID
+	admitted, _, err := d.EnrollAutomatic(ctx, sg, o, v, in, raw, "i-new", cloud, now)
 	if err != nil {
 		t.Fatal(err)
 	}
 	if !admitted.Roster.EnrollmentAttempts[in.Reference].Consumed {
 		t.Fatal("attempt not consumed with roster admission")
 	}
-	if _, err = d.EnrollAutomatic(ctx, sg, admitted, v, in, raw, "i-new", now); err == nil {
-		t.Fatal("consumed confirmation replayed")
-	}
-	if _, _, err = d.Store.Get(ctx, ConfirmationKey(in.Reference, first.AttemptID, first.ConfirmationID)); err != nil {
-		t.Fatal("old immutable evidence removed", err)
+	if _, _, err = d.EnrollAutomatic(ctx, sg, admitted, v, in, raw, "i-new", cloud, now); err == nil {
+		t.Fatal("consumed attempt replayed")
 	}
 }
````

`internal/hostactors/roles_test.go`:

````diff
--- a/internal/hostactors/roles_test.go
+++ b/internal/hostactors/roles_test.go
@@ -189,9 +189,9 @@
 			if err != nil {
 				t.Fatal(err)
 			}
-			raw, _ := json.Marshal(boxid.Record{V: 1, Box: "new", IdentityDocument: base64.StdEncoding.EncodeToString(doc), IdentitySignature: base64.StdEncoding.EncodeToString(sig)})
+			raw, _ := json.Marshal(boxid.Record{V: 1, Box: newRef, IdentityDocument: base64.StdEncoding.EncodeToString(doc), IdentitySignature: base64.StdEncoding.EncodeToString(sig)})
 			v := boxid.Verifier{Account: "123", Certs: map[string]*x509.Certificate{"eu-west-1": {PublicKey: &key.PublicKey, PublicKeyAlgorithm: x509.RSA}}}
-			in := EnrollmentRequest{Reference: "new", Namespace: "platform", Label: "new", Region: "ie", Pool: "default", AWSRegion: "eu-west-1"}
+			in := EnrollmentRequest{Reference: newRef, Namespace: "platform", Label: newRef, Region: "ie", Pool: "default", AWSRegion: "eu-west-1"}
 			next := Clone(o.Roster)
 			next.Regions = map[string]Region{"ie": {Account: "123", AWSRegion: "eu-west-1", GroupName: "routers", GroupARN: "group-arn", Namespaces: []string{"platform"}, Pools: []string{"default"}, Role: role}}
 			if o, err = d.Replace(ctx, sg, o, next); err != nil {
@@ -204,19 +204,14 @@
 			if err != nil {
 				t.Fatal(err)
 			}
-			now := time.Now().UTC()
-			c := MembershipConfirmation{PassID: "pass-1", AttemptID: a.AttemptID, ConfirmationID: a.ConfirmationID, EvidenceSHA256: a.EvidenceSHA256, TargetPlacement: "ie", AWSRegion: "eu-west-1", GroupName: "routers", GroupARN: "group-arn", InstanceID: "i-new", Account: "123", AvailabilityZoneRegion: "eu-west-1", LifecycleState: "InService", InstanceState: "running", ObservedAt: now, RequestIDs: []string{"asg", "ec2"}}
-			c.Confirmer.Box, c.Confirmer.Enrollment, c.Confirmer.Region = "box", o.Roster.Boxes["box"].Enrollment, "ie"
-			wire, _ := json.Marshal(c)
-			if err := release.CreateExact(ctx, d.Store, ConfirmationKey(in.Reference, a.AttemptID, a.ConfirmationID), wire); err != nil {
-				t.Fatal(err)
-			}
+			// The console's own read: a running member of the group.
+			cloud := &fakeMembership{members: map[string]Membership{"i-new": member("routers")}}
 			in.AttemptID, in.ConfirmationID = a.AttemptID, a.ConfirmationID
-			admitted, err := d.EnrollAutomatic(ctx, sg, o, v, in, raw, "i-new", now)
-			if err != nil {
-				t.Fatal(err)
-			}
-			b := admitted.Roster.Boxes["new"]
+			admitted, _, err := d.EnrollAutomatic(ctx, sg, o, v, in, raw, "i-new", cloud, time.Now)
+			if err != nil {
+				t.Fatal(err)
+			}
+			b := admitted.Roster.Boxes[newRef]
 			if b.Group != "routers" || b.Role != role {
 				t.Fatalf("admitted box group %q role %q, want routers %q", b.Group, b.Role, role)
 			}
````

`internal/hostactors/awsmembership/awsmembership_test.go`:

````go
package awsmembership

import (
	"reflect"
	"testing"

	"github.com/aws/aws-sdk-go-v2/aws"
	awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware"
	"github.com/aws/aws-sdk-go-v2/service/autoscaling"
	asgtypes "github.com/aws/aws-sdk-go-v2/service/autoscaling/types"
	"github.com/aws/aws-sdk-go-v2/service/ec2"
	ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types"
	"github.com/aws/smithy-go/middleware"

	"github.com/carlosframework/platform/internal/hostactors"
)

func meta(id string) middleware.Metadata {
	var md middleware.Metadata
	awsmiddleware.SetRequestIDMetadata(&md, id)
	return md
}

func asgReply(instance, group, zone, state string) *autoscaling.DescribeAutoScalingInstancesOutput {
	return &autoscaling.DescribeAutoScalingInstancesOutput{ResultMetadata: meta("asg-req"), AutoScalingInstances: []asgtypes.AutoScalingInstanceDetails{{
		InstanceId: aws.String(instance), AutoScalingGroupName: aws.String(group), AvailabilityZone: aws.String(zone), LifecycleState: aws.String(state)}}}
}

func ec2Reply(owner string, instances map[string]ec2types.InstanceStateName) *ec2.DescribeInstancesOutput {
	res := ec2types.Reservation{OwnerId: aws.String(owner)}
	for id, st := range instances {
		res.Instances = append(res.Instances, ec2types.Instance{InstanceId: aws.String(id), State: &ec2types.InstanceState{Name: st}})
	}
	return &ec2.DescribeInstancesOutput{ResultMetadata: meta("ec2-req"), Reservations: []ec2types.Reservation{res}}
}

func TestMembershipFromTheTwoReplies(t *testing.T) {
	got := membershipFrom("i-new", asgReply("i-new", "routers-ie", "eu-west-1b", "InService"), ec2Reply("123", map[string]ec2types.InstanceStateName{"i-new": ec2types.InstanceStateNameRunning}))
	want := hostactors.Membership{GroupName: "routers-ie", AvailabilityZoneRegion: "eu-west-1", LifecycleState: "InService", InstanceState: "running", Owner: "123", RequestIDs: []string{"asg-req", "ec2-req"}}
	if !reflect.DeepEqual(got, want) {
		t.Fatalf("got %+v\nwant %+v", got, want)
	}
	// Not a member: Auto Scaling knows nothing of it, or answers for
	// another instance. EC2 knowing it changes nothing.
	for _, ao := range []*autoscaling.DescribeAutoScalingInstancesOutput{{ResultMetadata: meta("asg-req")}, asgReply("i-other", "routers-ie", "eu-west-1b", "InService")} {
		m := membershipFrom("i-new", ao, ec2Reply("123", map[string]ec2types.InstanceStateName{"i-new": ec2types.InstanceStateNameRunning}))
		if m.GroupName != "" || m.LifecycleState != "" {
			t.Fatalf("non-member read as %+v", m)
		}
	}
	// EC2 no longer lists it: no state and no owner.
	m := membershipFrom("i-new", asgReply("i-new", "routers-ie", "eu-west-1b", "Terminating"), &ec2.DescribeInstancesOutput{ResultMetadata: meta("ec2-req")})
	if m.InstanceState != "" || m.Owner != "" {
		t.Fatalf("forgotten instance read as %+v", m)
	}
}

func TestZoneRegion(t *testing.T) {
	for az, want := range map[string]string{"eu-west-1a": "eu-west-1", "ap-southeast-2c": "ap-southeast-2", "us-west-2-lax-1a": "us-west-2-lax-1", "euw1-az1": "", "eu-west-1": "", "": ""} {
		if got := zoneRegion(az); got != want {
			t.Fatalf("zoneRegion(%q) = %q, want %q", az, got, want)
		}
	}
}
````

- [ ] **Step 2: Run them to see them fail**

Run: `nice -n 19 go test ./internal/hostactors/... -count=1`
Expected: FAIL, neither package builds (`undefined: Membership`, `undefined: membershipFrom`).

- [ ] **Step 3: Add the Auto Scaling SDK**

v1.74.0 is the release built against the module's AWS core v1.44.0. Its sums match sum.golang.org.

`go.mod`:

````diff
--- a/go.mod
+++ b/go.mod
@@ -7,6 +7,7 @@
 	github.com/aws/aws-sdk-go-v2/config v1.32.6
 	github.com/aws/aws-sdk-go-v2/credentials v1.19.6
 	github.com/aws/aws-sdk-go-v2/service/acm v1.45.0
+	github.com/aws/aws-sdk-go-v2/service/autoscaling v1.74.0
 	github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.68.0
 	github.com/aws/aws-sdk-go-v2/service/costexplorer v1.67.5
 	github.com/aws/aws-sdk-go-v2/service/ec2 v1.324.0
````

`go.sum`:

````diff
--- a/go.sum
+++ b/go.sum
@@ -40,6 +40,8 @@
 github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.38/go.mod h1:1PDUYG9Z+JrbbsobsAZHjWOm9QBT/djiK3QbykTL5Z4=
 github.com/aws/aws-sdk-go-v2/service/acm v1.45.0 h1:C/LUyd2F3iFtityAYQbAYb56uP0x32HaHSVXfRSImAM=
 github.com/aws/aws-sdk-go-v2/service/acm v1.45.0/go.mod h1:8TFQHTFTgR7OoBHlqJr8gsHv+ePC8z4S1lcrZVPgqbM=
+github.com/aws/aws-sdk-go-v2/service/autoscaling v1.74.0 h1:hm0br6zbFj1tLd5l8F6owm2/4pFK+z6NA+wIkvdMFxg=
+github.com/aws/aws-sdk-go-v2/service/autoscaling v1.74.0/go.mod h1:71sdt+AKd1aCexOigHqdAO5Er2oY2dEhI63220v7A6o=
 github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.68.0 h1:RdWMeCVaJfu4hCPyRNVQMKMP/Y5J04zB5LF45DmHhRg=
 github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.68.0/go.mod h1:HfsU8PQ6mHewSJNS77IPAFMZZgFaYSHjAhSOAFXdu9M=
 github.com/aws/aws-sdk-go-v2/service/costexplorer v1.67.5 h1:hiJJTfio1+i/xnfLwu6RsDjDzkGSiLux54LTzDK4MyA=
````

- [ ] **Step 4: Write the read, the admission check and the adapter**

`internal/hostactors/membershipread.go`:

````go
package hostactors

import (
	"context"
	"errors"
	"fmt"
	"time"

	"github.com/carlosframework/platform/internal/release"
)

// Group membership, confirmed by the console (region HA spec 2 §5 step 5,
// H16, H28). Before it invites a box its region's group launched, and
// again before it enrolls it, the console asks the cloud, read-only and
// with its own credentials, whether the instance is a current member of
// the region's group. No box writes or contributes membership evidence:
// a confirmation exists only inside this package, built by
// confirmMembership from one MembershipReader answer, and nothing
// exported takes one in.
//
// What the cloud says is the reader's business; the AWS adapter is
// internal/hostactors/awsmembership. This file names no provider (§25).

// MembershipReader answers, read-only, from the cloud the roster's region
// entries name. It is the console's own client.
type MembershipReader interface {
	// Member describes instance in awsRegion: its group, if any, and its
	// state. An instance in no group is a Membership with no GroupName. An
	// error is a read that failed, never an answer.
	Member(ctx context.Context, awsRegion, instance string) (Membership, error)
}

// Membership is one instance's group membership, as the cloud reported it.
type Membership struct {
	GroupName              string
	AvailabilityZoneRegion string
	LifecycleState         string
	InstanceState          string
	Owner                  string
	// RequestIDs are the cloud's request ids for the reads, one per call.
	RequestIDs []string
}

// MembershipEvidence is what a confirmation read, for the console's log:
// the cloud's request ids, and when the read began.
type MembershipEvidence struct {
	RequestIDs []string
	ObservedAt time.Time
}

// membershipFreshness is the write deadline: a read's write is issued
// less than membershipFreshness after the read's anchor, never at or after
// it.
const membershipFreshness = time.Minute

var (
	// ErrMembershipUnknown is a membership read that failed. It says
	// nothing about the instance: the console tries again with the same
	// attempt.
	ErrMembershipUnknown = errors.New("hostactors: group membership could not be read")
	// ErrNotMember is the cloud's positive answer that the instance is not
	// a current, running member of the region's group in the deployment's
	// account and AWS region.
	ErrNotMember = errors.New("hostactors: the instance is not a current member of the region's group")
	// ErrMembershipStale is a read that was already membershipFreshness old
	// or more when the describe returned: the cloud was slow, and the
	// answer counts against the attempt.
	ErrMembershipStale = errors.New("hostactors: the membership read is more than a minute old")
	// ErrWriteDeadline is a write the deadline stopped (writeBound): no
	// store call was issued at or after it, but one already in flight was
	// cancelled, so whether it committed is unknown. The next pass reads
	// back and describes again.
	ErrWriteDeadline = errors.New("hostactors: the write would be issued more than a minute after its membership read")
)

// membershipConfirmation is one read of an attempt's instance. It is built
// only by confirmMembership, from the reader's answer, and never leaves
// this package. A read the cloud answered carries its request ids and
// time whether or not it found a member, so every read can be logged.
type membershipConfirmation struct {
	requestIDs []string
	observedAt time.Time
}

// confirmMembership asks r about instance in region's AWS region, and
// returns the confirmation only when the answer is a current member of
// region's group: Pending or InService, pending or running, owned by the
// region's account, in its AWS region, with a request id for each call.
// The read's anchor, its time, is taken before the call: never later than
// the describe's completion, so its age is never understated.
//
// The call gets at most storeCallTimeout, as each bound store call does:
// neither describe sets a deadline of its own, and a connection that
// stalls would otherwise hold the serial pass, and every box behind this
// one, for as long as it stalls. A call cut off, by the timeout or by the
// pass's own context, is unknown whatever it returned: the attempt stands
// and its wait does not grow.
func confirmMembership(ctx context.Context, r MembershipReader, region Region, instance string, now func() time.Time) (membershipConfirmation, error) {
	if r == nil || now == nil {
		return membershipConfirmation{}, fmt.Errorf("%w: no reader", ErrMembershipUnknown)
	}
	at := now().UTC()
	rctx, cancel := context.WithTimeout(ctx, storeCallTimeout)
	m, err := r.Member(rctx, region.AWSRegion, instance)
	cut := rctx.Err()
	cancel()
	// The ids the cloud gave are kept whatever came of the read, failed
	// calls included, for the console's log.
	c := membershipConfirmation{requestIDs: append([]string(nil), m.RequestIDs...), observedAt: at}
	if cut != nil {
		return c, fmt.Errorf("%w: the read did not finish in time: %v", ErrMembershipUnknown, cut)
	}
	if err != nil {
		return c, fmt.Errorf("%w: %v", ErrMembershipUnknown, err)
	}
	// A reply without an id from each call is not an answer, whatever it
	// claims, so it is never counted against the attempt.
	if len(m.RequestIDs) < 2 {
		return c, fmt.Errorf("%w: the cloud gave no request ids", ErrMembershipUnknown)
	}
	for _, id := range m.RequestIDs {
		if id == "" || id == NoRequestID {
			return c, fmt.Errorf("%w: the cloud gave a blank request id", ErrMembershipUnknown)
		}
	}
	switch {
	case m.GroupName != region.GroupName:
		return c, fmt.Errorf("%w: it is in group %q", ErrNotMember, m.GroupName)
	case m.LifecycleState != "Pending" && m.LifecycleState != "InService":
		return c, fmt.Errorf("%w: its lifecycle state is %q", ErrNotMember, m.LifecycleState)
	case m.InstanceState == "" || m.Owner == "":
		// Auto Scaling knows it but EC2 does not list it (yet, or any
		// more): no answer.
		return c, fmt.Errorf("%w: EC2 does not list it", ErrMembershipUnknown)
	case m.InstanceState != "pending" && m.InstanceState != "running":
		return c, fmt.Errorf("%w: it is %q", ErrNotMember, m.InstanceState)
	case m.Owner != region.Account || m.AvailabilityZoneRegion != region.AWSRegion:
		return c, fmt.Errorf("%w: it is in account %q, region %q", ErrNotMember, m.Owner, m.AvailabilityZoneRegion)
	}
	return c, nil
}

// fresh refuses a confirmation membershipFreshness old or more at now, or
// one from the future: the describe itself was too slow.
func (c membershipConfirmation) fresh(now time.Time) error {
	if c.observedAt.IsZero() || now.Sub(c.observedAt) >= membershipFreshness || c.observedAt.After(now.Add(5*time.Second)) {
		return fmt.Errorf("%w (read at %s, checked at %s)", ErrMembershipStale, c.observedAt.Format(time.RFC3339), now.UTC().Format(time.RFC3339))
	}
	return nil
}

// deadline is the last moment a write this read allows may be issued.
func (c membershipConfirmation) deadline() time.Time { return c.observedAt.Add(membershipFreshness) }

// atWrite is the bound store's check before each call: at or after the
// deadline it is ErrWriteDeadline, unknown, never a refusal.
func (c membershipConfirmation) atWrite(now time.Time) error {
	if c.observedAt.IsZero() || !now.Before(c.deadline()) {
		return fmt.Errorf("%w (read at %s, write at %s)", ErrWriteDeadline, c.observedAt.Format(time.RFC3339), now.UTC().Format(time.RFC3339))
	}
	return nil
}

func (c membershipConfirmation) evidence() MembershipEvidence {
	return MembershipEvidence{RequestIDs: append([]string(nil), c.requestIDs...), ObservedAt: c.observedAt}
}

// writeBound is d with every store call bound to c's deadline (C2, S1),
// the single enforcement point of the write's freshness. Each store call
// (a read, the write, the read-back after a lost write) is refused before
// it is issued once the deadline is reached, by either clock, the
// context's or now; the returned context ends at the deadline; and each
// call it does issue gets at most storeCallTimeout (U2). The sealed
// writers (Invite, Replace and the checkMembership it makes) run on it
// unchanged, so no read, signature's Put or read-back of theirs is issued
// 60 seconds or more after the membership read's anchor, and any outcome
// not confirmed in time is ErrWriteDeadline, unknown.
func writeBound(ctx context.Context, d Directory, c membershipConfirmation, now func() time.Time) (Directory, context.Context, context.CancelFunc) {
	bctx, cancel := context.WithTimeout(ctx, c.deadline().Sub(now()))
	bd := d
	bd.Store = deadlineStore{Store: d.Store, check: func(callCtx context.Context) error {
		if bctx.Err() != nil || callCtx.Err() != nil {
			return fmt.Errorf("%w: the deadline passed", ErrWriteDeadline)
		}
		return c.atWrite(now())
	}}
	return bd, bctx, cancel
}

// storeCallTimeout is the hard limit on one store call of a bound write
// (U2): a call the store has not answered within it is unknown, and its
// context is cancelled, so an S3 request is abandoned at the HTTP layer.
// It bounds how long the console waits, not when a write commits: a
// call cut off may still commit later, and is settled from the roster on
// a later pass. confirmMembership gives each membership read the same
// limit. A var so tests can shorten it.
var storeCallTimeout = 15 * time.Second

// deadlineStore refuses every call once check says the deadline has been
// reached, before the call reaches the store underneath, and gives each
// call it issues at most storeCallTimeout. The bound cannot rest on the
// store alone: release.DirStore ignores its context, so the wrapper
// returns on its own when the call outlives the timeout or the deadline,
// whatever the store does. Every outcome it cannot confirm in time is
// ErrWriteDeadline, unknown: a call cut off in flight, one that never
// answered, and one that answered, even successfully, only after the
// deadline.
type deadlineStore struct {
	release.Store
	check func(context.Context) error
}

// call runs one store call under the bound.
func (s deadlineStore) call(ctx context.Context, f func(context.Context) error) error {
	if err := s.check(ctx); err != nil {
		return err
	}
	cctx, cancel := context.WithTimeout(ctx, storeCallTimeout)
	defer cancel()
	done := make(chan error, 1)
	go func() { done <- f(cctx) }()
	var err error
	select {
	case err = <-done:
	case <-cctx.Done():
	}
	// Cut off, whether the store gave up on the context or not: by the
	// deadline, or by the hard timeout. Whatever it answered, if anything,
	// is not known to be the outcome.
	if cctx.Err() != nil {
		if ctx.Err() != nil {
			return fmt.Errorf("%w: in flight at the deadline", ErrWriteDeadline)
		}
		return fmt.Errorf("%w: the store did not answer in time", ErrWriteDeadline)
	}
	if err == nil {
		if late := s.check(ctx); late != nil {
			return fmt.Errorf("%w: it answered only after the deadline", ErrWriteDeadline)
		}
	}
	return err
}

func (s deadlineStore) Get(ctx context.Context, key string) ([]byte, string, error) {
	var body []byte
	var etag string
	err := s.call(ctx, func(cctx context.Context) error {
		var err error
		body, etag, err = s.Store.Get(cctx, key)
		return err
	})
	if err != nil {
		return nil, "", err
	}
	return body, etag, nil
}

func (s deadlineStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
	var etag string
	err := s.call(ctx, func(cctx context.Context) error {
		var err error
		etag, err = s.Store.Put(cctx, key, body, ifMatch)
		return err
	})
	if err != nil {
		return "", err
	}
	return etag, nil
}

func (s deadlineStore) List(ctx context.Context, prefix string) ([]string, error) {
	var keys []string
	err := s.call(ctx, func(cctx context.Context) error {
		var err error
		keys, err = s.Store.List(cctx, prefix)
		return err
	})
	if err != nil {
		return nil, err
	}
	return keys, nil
}

func (s deadlineStore) ListDirs(ctx context.Context, prefix string) ([]string, error) {
	var dirs []string
	err := s.call(ctx, func(cctx context.Context) error {
		var err error
		dirs, err = s.Store.ListDirs(cctx, prefix)
		return err
	})
	if err != nil {
		return nil, err
	}
	return dirs, nil
}

func (s deadlineStore) Delete(ctx context.Context, key string) error {
	return s.call(ctx, func(cctx context.Context) error { return s.Store.Delete(cctx, key) })
}
````

`internal/hostactors/groupref.go`:

````go
package hostactors

import (
	"crypto/sha256"
	"encoding/hex"
)

// GroupBoxReference is the reference of a box its region's group launched
// (census ruling region-ha-2b): "g-" and the first 20 hex digits of
// sha256("carlos-box-ref-v1|" + account + "|" + instance). Status labels
// come from 1,600 values and collide as groups replace their boxes;
// instance ids never repeat in an account, and 80 bits of their digest
// make a collision negligible. It is a pseudonym, so the public status
// feed never shows an instance id. The box derives it to publish under it,
// and the console derives it again from the instance it proved (the
// attempt's, then the roster's pinned one) to read and admit it, never
// trusting the key a box chose. Boxes an operator enrolled keep their
// labels.
func GroupBoxReference(account, instance string) string {
	sum := sha256.Sum256([]byte("carlos-box-ref-v1|" + account + "|" + instance))
	return "g-" + hex.EncodeToString(sum[:])[:20]
}
````

`internal/hostactors/enrollment_confirmation.go`:

````diff
--- a/internal/hostactors/enrollment_confirmation.go
+++ b/internal/hostactors/enrollment_confirmation.go
@@ -4,7 +4,6 @@
 	"context"
 	"crypto/rand"
 	"encoding/hex"
-	"encoding/json"
 	"github.com/carlosframework/platform/internal/boxid"
 	"github.com/carlosframework/platform/internal/hostfloor"
 	"github.com/carlosframework/platform/internal/release"
@@ -22,97 +21,75 @@
 	// group takes (roles.go): "" (default) or legacy.
 	Role string `json:"role,omitempty"`
 }
-type TerminatedEnrollment struct {
-	PassID     string    `json:"pass_id"`
-	Box        string    `json:"box"`
-	Enrollment string    `json:"enrollment"`
-	InstanceID string    `json:"instance_id"`
-	State      string    `json:"state"`
-	ObservedAt time.Time `json:"observed_at"`
-	RequestID  string    `json:"request_id"`
-}
-type MembershipConfirmation struct {
-	PassID         string `json:"pass_id"`
-	AttemptID      string `json:"attempt_id"`
-	ConfirmationID string `json:"confirmation_id"`
-	EvidenceSHA256 string `json:"evidence_sha256"`
 
-	TargetPlacement        string    `json:"target_placement"`
-	AWSRegion              string    `json:"aws_region"`
-	GroupName              string    `json:"group_name"`
-	GroupARN               string    `json:"group_arn"`
-	InstanceID             string    `json:"instance_id"`
-	Account                string    `json:"account"`
-	AvailabilityZoneRegion string    `json:"availability_zone_region"`
-	LifecycleState         string    `json:"lifecycle_state"`
-	InstanceState          string    `json:"instance_state"`
-	ObservedAt             time.Time `json:"observed_at"`
-	RequestIDs             []string  `json:"request_ids"`
-	Confirmer              struct {
-		Box        string `json:"box"`
-		Enrollment string `json:"enrollment"`
-		Region     string `json:"region"`
-	} `json:"confirmer"`
-	TerminatedEnrollments []TerminatedEnrollment `json:"terminated_enrollments"`
+// EnrollAutomatic admits a box its region's group launched (H16, H28). The
+// console confirms the box's group membership itself, here: Member is
+// asked, through r, and every store call of the enrolling write,
+// checkMembership's reads and Replace's Put included, runs on the store
+// writeBound binds to that read's deadline, the single enforcement point:
+// none is issued at or after it. No box writes or contributes membership
+// evidence, and no caller can hand a confirmation in: membershipread.go
+// builds it from r's answer alone. The evidence comes back whenever the
+// cloud answered, so every read can be logged.
+func (d Directory) EnrollAutomatic(ctx context.Context, sg release.Signer, o Observed, v boxid.Verifier, in EnrollmentRequest, raw []byte, instance string, r MembershipReader, now func() time.Time) (Observed, MembershipEvidence, error) {
+	pinned, c, err := d.checkConfirmed(ctx, o, v, in, raw, instance, r, now)
+	if err != nil {
+		return Observed{}, c.evidence(), err
+	}
+	bd, bctx, cancel := writeBound(ctx, d, c, now)
+	defer cancel()
+	next, err := bd.enroll(bctx, sg, o, v, pinned, raw, true)
+	if err != nil {
+		return Observed{}, c.evidence(), err
+	}
+	// Callers go on with the store itself, not the bound one.
+	return next, c.evidence(), nil
 }
 
-// Confirmers write this create-only evidence with read-only ASG/EC2 results.
-// This consumer gives H16/H17 their admission boundary; spec 4 owns producing it.
-func (d Directory) EnrollAutomatic(ctx context.Context, sg release.Signer, o Observed, v boxid.Verifier, in EnrollmentRequest, raw []byte, instance string, now time.Time) (Observed, error) {
+// checkConfirmed is EnrollAutomatic's admission check, made without
+// writing: the attempt in the roster matches the request and the record,
+// the request's reference is the instance's group reference
+// (GroupBoxReference), and a describe made now confirms the attempt's
+// instance is a current member of the region's group. It returns in
+// pinned to that instance and the confirmation the read built, which
+// never leaves this package. The console's enrollment pass asks it
+// immediately before it invites a box, and makes the invitation on the
+// store writeBound binds to that confirmation (autoenroll.go).
+func (d Directory) checkConfirmed(ctx context.Context, o Observed, v boxid.Verifier, in EnrollmentRequest, raw []byte, instance string, r MembershipReader, now func() time.Time) (EnrollmentRequest, membershipConfirmation, error) {
 	if err := validateScope(o.Roster, Scope{Namespace: in.Namespace, Regions: []string{in.Region}, Pools: []string{in.Pool}}); err != nil {
-		return Observed{}, err
+		return EnrollmentRequest{}, membershipConfirmation{}, err
 	}
 	region, ok := o.Roster.Regions[in.Region]
 	if !ok || region.Account != v.Account || region.AWSRegion != in.AWSRegion || region.GroupARN == "" || region.GroupName == "" || !segment(instance) || !segment(in.Reference) || in.ExistingReference {
-		return Observed{}, ErrRoster
+		return EnrollmentRequest{}, membershipConfirmation{}, ErrRoster
+	}
+	// A group box is enrolled only under the reference derived from its
+	// instance, never one it chose (census ruling region-ha-2b).
+	if in.Reference != GroupBoxReference(region.Account, instance) {
+		return EnrollmentRequest{}, membershipConfirmation{}, ErrRoster
 	}
 	attempt, ok := o.Roster.EnrollmentAttempts[in.Reference]
 	if !ok || attempt.Consumed || !token.MatchString(in.AttemptID) || !token.MatchString(in.ConfirmationID) || in.AttemptID == in.ConfirmationID || attempt.AttemptID != in.AttemptID || attempt.ConfirmationID != in.ConfirmationID || attempt.InstanceID != instance || attempt.EvidenceSHA256 != hostfloor.Digest(raw) || attempt.Region != in.Region || attempt.Account != region.Account || attempt.AWSRegion != region.AWSRegion || attempt.GroupARN != region.GroupARN || attempt.GroupName != region.GroupName {
-		return Observed{}, ErrRoster
+		return EnrollmentRequest{}, membershipConfirmation{}, ErrRoster
 	}
-	body, _, err := d.Store.Get(ctx, ConfirmationKey(in.Reference, in.AttemptID, in.ConfirmationID))
+	c, err := confirmMembership(ctx, r, region, instance, now)
 	if err != nil {
-		return Observed{}, err
+		return EnrollmentRequest{}, c, err
 	}
-	var c MembershipConfirmation
-	if json.Unmarshal(body, &c) != nil || !segment(c.PassID) || c.AttemptID != attempt.AttemptID || c.ConfirmationID != attempt.ConfirmationID || c.EvidenceSHA256 != attempt.EvidenceSHA256 || c.TargetPlacement != in.Region || c.AWSRegion != region.AWSRegion || c.Account != region.Account || c.GroupARN != region.GroupARN || c.GroupName != region.GroupName || c.InstanceID != instance || c.AvailabilityZoneRegion != region.AWSRegion || (c.LifecycleState != "Pending" && c.LifecycleState != "InService") || (c.InstanceState != "pending" && c.InstanceState != "running") || !freshConfirmation(c.ObservedAt, now) || len(c.RequestIDs) < 2 {
-		return Observed{}, ErrRoster
-	}
-	for _, id := range c.RequestIDs {
-		if id == "" {
-			return Observed{}, ErrRoster
-		}
-	}
-	confirmer, ok := o.Roster.Boxes[c.Confirmer.Box]
-	if !ok || confirmer.State != "active" || confirmer.Enrollment != c.Confirmer.Enrollment || confirmer.Region != c.Confirmer.Region {
-		return Observed{}, ErrRoster
-	}
-	fenced, err := d.TerminationFence(ctx, o, c.Confirmer.Box, now)
-	if err != nil || fenced {
-		return Observed{}, ErrRoster
-	}
-	if confirmer.Region != in.Region {
-		seen := map[string]bool{}
-		for _, read := range c.TerminatedEnrollments {
-			b, ok := o.Roster.Boxes[read.Box]
-			if !ok || read.PassID != c.PassID || seen[read.Box] || b.Region != in.Region || b.Enrollment != read.Enrollment || b.Cloud.InstanceID != read.InstanceID || read.State != "terminated" || read.RequestID == "" || !freshConfirmation(read.ObservedAt, now) || read.ObservedAt.Before(c.ObservedAt.Add(-time.Minute)) || read.ObservedAt.After(c.ObservedAt) {
-				return Observed{}, ErrRoster
-			}
-			seen[read.Box] = true
-		}
-		for ref, b := range o.Roster.Boxes {
-			if b.Region == in.Region && (b.State == "active" || b.State == "quarantined") && !seen[ref] {
-				return Observed{}, ErrRoster
-			}
-		}
+	if err := c.fresh(now()); err != nil {
+		return EnrollmentRequest{}, c, err
 	}
 	// Pin the verified IMDS identity to the instance that membership proved.
 	in.TofuInstanceID = instance
 	in.ExistingReference = true
-	return d.enroll(ctx, sg, o, v, in, raw, true)
-}
-func freshConfirmation(at, now time.Time) bool {
-	return !at.IsZero() && now.Sub(at) <= time.Minute && !at.After(now.Add(5*time.Second))
+	return in, c, nil
 }
 
 type EnrollmentAttempt struct {
@@ -126,10 +103,6 @@
 	GroupName      string `json:"group_name"`
 	GroupARN       string `json:"group_arn"`
 	Consumed       bool   `json:"consumed"`
-}
-
-func ConfirmationKey(ref, attempt, confirmation string) string {
-	return "control/box-enrollment/" + ref + "/" + attempt + "/" + confirmation + ".json"
 }
 
 // BeginEnrollmentAttempt supersedes evidence by CAS in the signed authority.
````

`internal/hostactors/private.go`:

````diff
--- a/internal/hostactors/private.go
+++ b/internal/hostactors/private.go
@@ -7,9 +7,18 @@
 	"strings"
 )
 
+// PrivateBoxReport reads the private status report of box ref. A box its
+// region's group enrolled (b.Group set) is read under the reference the
+// console derives from the instance the roster pins, never under the key
+// the box chose (GroupBoxReference); every other box under ref, as before.
 func (d Directory) PrivateBoxReport(ctx context.Context, ref string, b Box) (Report, error) {
 	key := "status/boxes/" + ref + ".json"
-	if strings.HasPrefix(b.Namespace, "fleet:") {
+	if b.Group != "" {
+		if b.Cloud.Account == "" || !segment(b.Cloud.InstanceID) {
+			return Report{}, ErrRoster
+		}
+		key = "status/boxes/" + GroupBoxReference(b.Cloud.Account, b.Cloud.InstanceID) + ".json"
+	} else if strings.HasPrefix(b.Namespace, "fleet:") {
 		fleet := strings.TrimPrefix(b.Namespace, "fleet:")
 		if !segment(fleet) || !segment(b.Label) {
 			return Report{}, ErrRoster
````

`internal/hostactors/awsmembership/awsmembership.go`:

````go
// Package awsmembership is the AWS adapter of the console's membership
// read (region HA spec 2 §5 step 5, §25; H16, H28): read-only Auto Scaling
// and EC2 describe calls, made in the AWS region the roster names for the
// attempt's placement. The console role needs
// autoscaling:DescribeAutoScalingInstances and ec2:DescribeInstances,
// conditioned on aws:RequestedRegion being one of the deployment's AWS
// regions. No box role has either.
package awsmembership

import (
	"context"

	"github.com/aws/aws-sdk-go-v2/aws"
	awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware"
	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/service/autoscaling"
	"github.com/aws/aws-sdk-go-v2/service/ec2"
	ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types"
	"github.com/aws/smithy-go/middleware"

	"github.com/carlosframework/platform/internal/hostactors"
)

// Reader is hostactors.MembershipReader over the console's own credentials.
type Reader struct{ cfg aws.Config }

// New loads the console's AWS configuration (its instance role).
func New(ctx context.Context) (*Reader, error) {
	cfg, err := config.LoadDefaultConfig(ctx)
	if err != nil {
		return nil, err
	}
	return &Reader{cfg: cfg}, nil
}

// Member describes instance's Auto Scaling membership, then its EC2 state
// and owner: two calls, two request ids.
func (r *Reader) Member(ctx context.Context, awsRegion, instance string) (hostactors.Membership, error) {
	asg := autoscaling.NewFromConfig(r.cfg, func(o *autoscaling.Options) { o.Region = awsRegion })
	ao, err := asg.DescribeAutoScalingInstances(ctx, &autoscaling.DescribeAutoScalingInstancesInput{InstanceIds: []string{instance}})
	if err != nil {
		return hostactors.Membership{}, err
	}
	ec := ec2.NewFromConfig(r.cfg, func(o *ec2.Options) { o.Region = awsRegion })
	eo, err := ec.DescribeInstances(ctx, &ec2.DescribeInstancesInput{Filters: []ec2types.Filter{{Name: aws.String("instance-id"), Values: []string{instance}}}})
	if err != nil {
		return hostactors.Membership{}, err
	}
	return membershipFrom(instance, ao, eo), nil
}

// membershipFrom reads the two replies. Anything but exactly the one
// instance asked about, in exactly one group, is no membership.
func membershipFrom(instance string, ao *autoscaling.DescribeAutoScalingInstancesOutput, eo *ec2.DescribeInstancesOutput) hostactors.Membership {
	m := hostactors.Membership{RequestIDs: []string{requestID(ao.ResultMetadata), requestID(eo.ResultMetadata)}}
	if len(ao.AutoScalingInstances) == 1 && aws.ToString(ao.AutoScalingInstances[0].InstanceId) == instance {
		d := ao.AutoScalingInstances[0]
		m.GroupName = aws.ToString(d.AutoScalingGroupName)
		m.LifecycleState = aws.ToString(d.LifecycleState)
		m.AvailabilityZoneRegion = zoneRegion(aws.ToString(d.AvailabilityZone))
	}
	if len(eo.Reservations) == 1 && len(eo.Reservations[0].Instances) == 1 && aws.ToString(eo.Reservations[0].Instances[0].InstanceId) == instance {
		res := eo.Reservations[0]
		m.Owner = aws.ToString(res.OwnerId)
		if st := res.Instances[0].State; st != nil {
			m.InstanceState = string(st.Name)
		}
	}
	return m
}

// zoneRegion is the region of an availability zone name ("eu-west-1a" is
// in "eu-west-1"): the name less one trailing letter after a digit. Any
// other shape (a Local Zone, a zone id) answers "", which matches no
// region.
func zoneRegion(az string) string {
	n := len(az)
	if n < 3 || az[n-1] < 'a' || az[n-1] > 'z' || az[n-2] < '0' || az[n-2] > '9' {
		return ""
	}
	return az[:n-1]
}

func requestID(md middleware.Metadata) string {
	id, _ := awsmiddleware.GetRequestIDMetadata(md)
	return id
}

var _ hostactors.MembershipReader = (*Reader)(nil)
````

- [ ] **Step 5: Run the tests**

Run: `nice -n 19 go test ./internal/hostactors/... -count=1`
Expected: PASS, main's tests included.

- [ ] **Step 6: The provider boundary**

`region_ha_boundary_test.go`:

````diff
--- a/region_ha_boundary_test.go
+++ b/region_ha_boundary_test.go
@@ -35,6 +35,8 @@
 	"internal/hostenv/write_linux.go",
 	"internal/hostenv/write_other.go",
 	"internal/host/installchannel.go",
+	"internal/hostactors/membershipread.go",
+	"internal/hostactors/groupref.go",
 }
 
 // regionHAAdapterFiles are spec 2's AWS adapters. Each is named for AWS so
@@ -43,6 +45,7 @@
 	"internal/host/awsaddr.go",
 	"internal/console/awsregionmetric.go",
 	"internal/hostenv/awsssm.go",
+	"internal/hostactors/awsmembership/awsmembership.go",
 }
 
 // regionHAPackages are the packages spec 2 touches. Every S3 client built
@@ -55,6 +58,7 @@
 	"internal/edgesfile",
 	"internal/host",
 	"internal/hostactors",
+	"internal/hostactors/awsmembership",
 	"internal/hostenv",
 	"internal/hostops",
 	"internal/lbhealth",
````

Run: `nice -n 19 go test . -run 'TestRegionHA(Core|Adapters|S3)' -count=1`
Expected: PASS.

- [ ] **Step 7: Census**

No root-read unit is added or changed: the read is the cloud's, through the reader, and `EnrollAutomatic` no longer reads the store itself. Only the two `@coverage` rows move, and `.` for go.mod. The seal stands. The record-write census gains `deadlineStore.Put`, classed nonrecord as the other forwarding stores are:

`internal/provision/plan9_b8_census_test.go`:

````diff
--- a/internal/provision/plan9_b8_census_test.go
+++ b/internal/provision/plan9_b8_census_test.go
@@ -814,6 +814,11 @@
 	{"internal/hostactors/censuskey.go", "Directory.BindCensusKey", "nonrecord", "sel:Put", "7d9d673435e8c8a6aac3105e3a754bde2adcbc5d603945dd599cef703f334a5b"},
 	{"internal/hostactors/invitation.go", "Directory.Invite", "nonrecord", "sel:Put", "356618a75a23d0157d8e0402e20187a42956f67a4332d698bb22bf05133ac489"},
 	{"internal/hostactors/roster.go", "Directory.Replace", "nonrecord", "sel:Put", "e6e8ac0070650dfacc4a438b080cb69738530b3d9013de55c1bcf06431b0a847"},
+	// Region HA plan 2b (Task 4): the store a membership read binds to its
+	// deadline; it forwards Put, refuses it once the deadline passes, gives
+	// it at most storeCallTimeout, and reports one that answers late as
+	// unknown.
+	{"internal/hostactors/membershipread.go", "deadlineStore.Put", "nonrecord", "def:Put,sel:Put", "848f2b529d87e34f128dd481f14213e1cfaf8c54555806e6314864ef9a2dbb14"},
 	{"internal/hostbox/regionalworker.go", "RegionalWorker.Census", "key-site", "lit:instances", "477bcb896b5ae8ec80af592d68eceb7992ad8a23cd6cff6043ad03b3ef2a8085"},
 	{"internal/hostbox/requeststore.go", "RequestStore.CopyVersion", "versioned-transport", "def:CopyVersion,sel:CopyVersion", "3a50049ce7ca7f2cbc770bef46218944cdc7000205a86c20e4932f0b510da28f"},
 	{"internal/hostbox/requeststore.go", "RequestStore.PutIfAbsent", "versioned-transport", "def:PutIfAbsent,sel:PutIfAbsent", "3666cbe5d2b91c1c652ffb4459c10a374fbd92e325e4e5c574fa3525d0ce2ffe"},
````

`internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv`:

````diff
--- a/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv
+++ b/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv
@@ -1,10 +1,13 @@
 # Plan 14 census overlay region-ha-2b (region HA spec 2, plan 2b: docs/superpowers/plans/2026-10-17-region-ha-2b-joining.md), loaded last. RULED by the Plan 14 controller 2026-10-16 as census-ruling-region-ha-2b.
 # Task 2: host.env from the deployment's parameters (internal/hostenv: hostenv.go, the layout, assembly and rendering, with an allowlist of names, shared values written create-only and the group-box marker added on write; cli.go, carlos system host-env write and seed; write_linux.go, the write through directories held by descriptor, and write_other.go, its refusal off Linux; awsssm.go, the SSM adapter) and the SSM SDK in go.mod. Three new root-read units. runHostEnv: seed's --from file. Ruled authority A,J4 on conditions (no box write to the parameters; allowlisted names, safe values, atomic write, checked parent; authority-selecting values cross-checked against the roster at the agent's use). writeHostEnv and checkedDir (Linux): write's fixed path, /etc/carlos/host.env, reached only through /etc and /etc/carlos opened O_NOFOLLOW|O_DIRECTORY and checked by fstat (root's, writable by no group or other), the file written 0600 in that directory, synced, renamed and the directory synced; ruled A,J4 under census-ruling-region-ha-2b, as the checked parent runHostEnv's condition asks for. The legacy seal stands.
 # Task 3: carlos system install-channel (internal/host installchannel.go, installchannel_linux.go): a fresh box installs its signed channel release into both protected slots through verifyPointer, hostfloor.CheckImage and stageCandidate, all unchanged, and on every run hands each directory on the agent's cache path (system, system/router and the version's) to the service user through bootstrap's own descriptor-anchored open, refusing a symlinked parent. One new root-read unit, channelInstall.run, whose one read site opens root's apply lock (systemApplier's lockPath). Ruled authority F,J4. stageCandidate, accepted-legacy, is called and not changed. The legacy seal stands.
-@coverage	.	be7c96bb2ff00b5b018b436f976f0d33bbcc8765c17e357baa6cd0290f59f42d
+# Task 4: group membership, read by the console (internal/hostactors membershipread.go: MembershipReader, the unexported confirmation and the write bound, the single enforcement point of the 60-second deadline, each store call cut off at 15 seconds; enrollment_confirmation.go: EnrollAutomatic and CheckConfirmed make the read themselves and write on the bound store, and the box-written confirmation object is removed; groupref.go: GroupBoxReference; private.go: a group box's report read under the reference the console derives; awsmembership, the Auto Scaling and EC2 describe adapter) and the Auto Scaling SDK in go.mod. No root-read unit is added or changed: the read is the cloud's, through the reader. Envelope-only, and the seal stands. The record-write census gains deadlineStore.Put (nonrecord, ruled).
+@coverage	.	3678cfe22582d35fe0d8e93d3fed898cdffa6fb2ce2d81e0252c8a8da77993b3
 @coverage	internal/hostenv	a3f3274a86cdf8194afb871301c1ba3289dfbdc9b703dd547178273df151cdaa
 internal/hostenv/cli.go	runHostEnv	authority	A,J4	rev3=census-ruling-region-ha-2b;basis=carlos system host-env: write runs as root at a box's start-up and writes /etc/carlos/host.env, 0600, whole (temporary file and rename), from allowlisted parameters with safe values only, refusing a missing required value; its one read site is seed's --from file, an operator's host.env whose values go only to the parameter store; write's path is fixed and reached only through writeHostEnv	file:ReadFile@1500	df4470176b229b311927ff0ac4f268d354c3a2357cac4300ac0992d2ebb90899
 internal/hostenv/write_linux.go	checkedDir	authority	A,J4	rev3=census-ruling-region-ha-2b;basis=carlos system host-env write (root, Linux): opens one directory under one already held, O_NOFOLLOW|O_DIRECTORY, creating it 0755 first only when asked and missing, and refuses it unless fstat shows a directory owned by this process's user (root, as write requires) that no group or other can write	file:Fstat@576,file:Openat@168,file:Openat@372	012257ac0866f3385044785d8abae9c31e61134928c394e22a2aeaef39ec9f3c
 internal/hostenv/write_linux.go	writeHostEnv	authority	A,J4	rev3=census-ruling-region-ha-2b;basis=carlos system host-env write (root, Linux): writes /etc/carlos/host.env only through /etc and /etc/carlos held by descriptor and checked by checkedDir; the new file is created 0600, O_EXCL|O_NOFOLLOW, in that directory, synced, renamed over host.env within it, and the directory synced; its read sites open the root and the temporary file	file:Open@68,file:Openat@509	1245fc0f819ba73fb45545ffdcf92d5d4941802175e73bac209ab4d4238adff1
 @coverage	internal/host	64ee4f561f2a28d584a6f7c82e068e57db4822f6cbb130116284bca583d2d390
 internal/host/installchannel.go	channelInstall.run	authority	F,J4	rev3=census-ruling-region-ha-2b;basis=carlos system install-channel (root, at a fresh box's start-up): its one read site opens root's apply lock; the install goes through verifyPointer, hostfloor.CheckImage and stageCandidate into both protected slots, and refuses a box whose guard is armed or that already runs another release	file:OpenFile@196	f0cdb6dd28e83da282c3b0d7bc2012dcd80db181a8cecbf1a2db6bcc41d1d5e3
+@coverage	internal/hostactors	f6dbcea46bed878257c5b34f8caeef83e71cc995cae7b3a4ae59339ed0740c68
+@coverage	internal/hostactors/awsmembership	a3defad7179245492a3c1dcfce5e20f0f85db2e240b474c654acf211da16df98
````

Run: `nice -n 19 go test ./internal/provision/ -run 'TestPlan9B8RecordWriteCensus|TestPlan14RootReadCensus$' -count=1 && nice -n 19 go test ./internal/console/ -run TestClaimAuthoritySourceCensus -count=1`
Expected: PASS.

- [ ] **Step 8: Commit**

```bash
git add internal/hostactors go.mod go.sum region_ha_boundary_test.go internal/provision/plan9_b8_census_test.go internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv
git commit -m "region HA 2b: group membership, read by the console"
```

---

### Task 5: The console's automatic enrolment pass

**Files:**
- Create: `internal/hostactors/autoenroll.go`, `internal/hostactors/autoenroll_test.go`
- Create: `internal/console/enrollpass.go`, `internal/console/enrollpass_test.go`, `internal/console/awsmembership.go`
- Modify: `internal/console/hostclaimops.go` (start the pass with region HA on)
- Modify: `internal/console/claim_authority_census_test.go`, `internal/console/testdata/claim-authority-sites.txt` (R6)
- Modify: `region_ha_boundary_test.go`, the overlay, `internal/provision/plan14_root_reads_test.go` (the seal)

**Interfaces:**
- Consumes: `Directory.Read`, `Invite`, `enrollmentNonce`, `BeginEnrollmentAttempt`, `checkConfirmed`, `EnrollAutomatic`, `GroupBoxReference`, `writeBound` and `ErrWriteDeadline` (Task 4), `MembershipReader`, `VerifyReport`, `Evidence.BoxReport`, `RoleName`; `awsmembership.New` (Task 4); `boxid.Load`, `Verifier`, `EmbeddedCerts`; `hostfloor.Read`, `Digest`; the console's `ClaimAuthority` (`Directory`, `Signer`, `Evidence`, `Namespace`).
- Produces (`hostactors`): `type AutoEnroller struct { Directory; Signer; Evidence; Members MembershipReader; Certs; Namespace, Pool string; Now func() time.Time; Patience time.Duration; Logf func(string, ...any) }`; `func (e *AutoEnroller) EnrollPass(ctx) ([]EnrollStep, error)`; `EnrollStep{Box, Step string}`; `ErrNoFloor`.
- Produces (`console`): `newAutoEnroller(*hostops.ProductionAuthority, hostactors.MembershipReader) (*hostactors.AutoEnroller, error)`; `runEnrollPass(ctx, e, every, logf)`; `(*Server).startEnrollPass(ctx)`; `consoleMembershipReader(ctx)` (`awsmembership.go`, the adapter); `enrollPassInterval = 10s`, `enrollPatience = 2m`.

For each box whose identity record is published and which the roster does not list, the pass takes the next step its state allows (§5 step 5, A2-1, H28): with no usable attempt, begin one; with one, read the instance's membership itself (`checkConfirmed`) and invite on the directory bound to the confirmation that read returns (`writeBound`), so `Invite`'s own read, its signature's `Put` and any read-back of its own are refused at or after the deadline, a call in flight then is cancelled, and one still unanswered 15 seconds after it began is cut off; once invited, wait for the box's guard-ready report to pass `VerifyReport` (the check `membership.go:40` makes at admission), and then enroll through `EnrollAutomatic`, which reads membership again itself and writes on its own bound directory. The same attempt serves the invitation and the enrolment: the read, not the attempt, carries the freshness, and the attempt's `Consumed` refuses a replay. An invited box is found with `enrollmentNonce`, which reads and verifies the invitation and writes nothing; `Invite` is get-or-create, so it runs only on the bound store after a membership read, and an invitation that vanished between two reads is never minted again without one. Round 3 removed the check the pass made just before `Invite`: the bound store is the single enforcement point, and nothing that waits sits between the describe and the `Invite` call.

A read that fails is unknown: the step names it, and the next pass asks again with the same attempt, whose wait does not grow (#7). So is a write the bound could not confirm (`ErrWriteDeadline`, S1, U2): nothing was issued at or after the deadline, but a call cut off in flight, or one that answered only after the deadline, may have committed, or may still commit later (V1). `TestAWriteTheConsoleGaveUpOnCanCommitLater` documents that exposure: the enrolling `Put` blocks, the pass returns unknown, the instance is detached, the next pass finds the box absent and its fresh describe refuses without writing, then the delayed `Put` commits on the unchanged roster revision, and the pass after that sees the box enrolled and does nothing more. Such an enrolment rests on a membership read that was true when its write was issued: the exposure of an instance detached after enrolment, which spec 3's worker reconciles. An invitation that landed is simply found and the box goes on to its guard-ready report: an invitation grants nothing.

**Unknown enrolments are settled by the roster (U1).** An enrolling write whose outcome is unknown is not remembered and not read back. The next pass reads the signed roster, as every pass does, and that settles it: a box the roster lists is enrolled, and the pass has nothing more to do for it (no step, no describe); one it does not list goes on with the same attempt while that attempt is still valid, and its wait does not grow, so the next read and write are made under the same rules as any other. A box whose instance left its group, but still runs, after its enrolment landed keeps that enrolment until an operator removes it; spec 3's regional worker reconciles roster membership against Auto Scaling group membership, including detached-but-running instances ("Activation gates", note). A terminated instance is kept from hosting and from capacity by main's termination fence, and a stopped one by the stale-edges rule and Task 6's restart hold. Only `ErrNotMember` or `ErrMembershipStale` refuses the attempt: the pass keeps it, asking the cloud nothing more, for `Patience`, then twice that, up to 32 times, before it begins another, so the record of a box the group has already replaced costs one roster write and one describe an hour. An invitation resets the wait. The memory of attempts is the console's own and starts afresh at a restart. Every membership read is logged (`Logf`, S5), with the box's reference, its attempt id, its instance id, both request ids ("none" when the call failed before the cloud answered), the read's time and what came of it: the audit main's roster has no field for. Between the final check and the `Invite` call there is nothing that waits: no I/O, channel, lock or store call, only the construction of the bound directory.

The box's region is the one roster region whose group runs in the record's account and AWS region; the record is then verified with `boxid.Verifier` before anything is written. The record's key must be the group reference of the instance it proves (S3): a record under any other key, one the box chose, is refused before an attempt is begun. The box's report is read under that derived reference too (`Box.Group` and the record's proven `Cloud`, through `PrivateBoxReport`), never under the box's own say. The pass enrolls into the console's namespace and the `default` pool, and the box takes its group's role in the same write (2a). A deployment without a published host floor enrolls nothing (`ErrNoFloor`): no box could arm its guard. The console signs with the root key it already holds; it is the roster's only writer and the only membership confirmer.

The construction of the pass hands this console's roster directory and root signer to `AutoEnroller`, and its invocation runs the pass that invites and enrolls, so both go into the console claim-authority census (R6): `newAutoEnroller`, `runEnrollPass` and `EnrollPass` join its selected names, and the three sites they produce are appended to `claim-authority-sites.txt` (appended, never re-sorted; the test sorts both sides). Removing or duplicating any of the three turns the census red.

- [ ] **Step 1: Write the failing tests**

The hostactors test is the whole sequence on plan 9's published deployment (`plan9Fixture`), with Task 4's fake reader as the console's cloud.

`internal/hostactors/autoenroll_test.go`:

````go
package hostactors

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"strings"
	"testing"
	"time"

	"github.com/carlosframework/platform/internal/boxid"
	"github.com/carlosframework/platform/internal/boxid/boxidtest"
	"github.com/carlosframework/platform/internal/release"
	"github.com/carlosframework/platform/internal/status"
)

// enrollWorld is plan9Fixture's published deployment (boxes a, b and w,
// active in ie under a published host floor) with ie's group, and a box
// (instance i-new, reference newRef) that the group launched: its
// identity record is published, and nothing else. The console reads the
// cloud through cloud.
type enrollWorld struct {
	plan9World
	e      *AutoEnroller
	cloud  *fakeMembership
	signer *boxidtest.Signer
	clock  time.Time
	logs   []string
}

func newEnrollWorld(t *testing.T, role string) *enrollWorld {
	t.Helper()
	w, _ := plan9Fixture(t, func(r *Roster) {
		r.Regions = map[string]Region{"ie": {Account: "123", AWSRegion: "eu-west-1", GroupName: "routers-ie",
			GroupARN: "arn:aws:autoscaling:eu-west-1:123:autoScalingGroup:x:autoScalingGroupName/routers-ie", Namespaces: []string{"platform"}, Pools: []string{"default"}, Role: role}}
	})
	signer := boxidtest.NewSigner(t)
	_, raw := signer.Record(t, newRef, "123", "eu-west-1", "i-new")
	if _, err := w.d.Store.Put(context.Background(), boxid.Key(newRef), raw, ""); err != nil {
		t.Fatal(err)
	}
	ew := &enrollWorld{plan9World: w, clock: time.Now().UTC(), signer: signer,
		cloud: &fakeMembership{members: map[string]Membership{"i-new": member("routers-ie")}}}
	ew.e = &AutoEnroller{Directory: w.d, Signer: w.sg, Evidence: ExactEvidence{PublicKey: w.d.PublicKey, Store: w.d.Store, Boxes: w.d.PrivateBoxReport},
		Members: ew.cloud, Certs: signer.Certs(), Namespace: "platform", Pool: "default", Now: func() time.Time { return ew.clock }, Patience: 2 * time.Minute,
		Logf: func(format string, args ...any) { ew.logs = append(ew.logs, fmt.Sprintf(format, args...)) }}
	return ew
}

// pass runs the enrollment pass and returns new's step.
func (w *enrollWorld) pass(t *testing.T) string {
	t.Helper()
	steps, err := w.e.EnrollPass(context.Background())
	if err != nil {
		t.Fatal(err)
	}
	for _, s := range steps {
		if s.Box == newRef {
			return s.Step
		}
	}
	return ""
}

// guardReady is the joining box, its guard armed, publishing the report
// every hosting box publishes.
func (w *enrollWorld) guardReady(t *testing.T, enrollment string, ready bool) {
	t.Helper()
	box := status.Box{GeneratedAt: time.Now().UTC(), Metrics: &status.BoxMetrics{Enrollment: enrollment, InstanceID: "i-new", Release: "v1.0.0", ReleaseTag: "v1.0.0",
		Platform: "linux-amd64", BinarySHA256: w.hash, MovesProtocol: 1, BootID: "boot", FloorSHA256: w.floor.Digest, FloorGuardReady: ready, SystemChannel: "stable"}}
	raw, _ := json.Marshal(box)
	_, etag, _ := w.d.Store.Get(context.Background(), "status/boxes/"+newRef+".json")
	if _, err := w.d.Store.Put(context.Background(), "status/boxes/"+newRef+".json", raw, etag); err != nil {
		t.Fatal(err)
	}
}

func (w *enrollWorld) invitation(t *testing.T) (Invitation, bool) {
	t.Helper()
	if _, _, err := w.d.Store.Get(context.Background(), "control/host-actor-invitations/"+newRef+".json"); err != nil {
		return Invitation{}, false
	}
	raw, err := boxid.Load(context.Background(), w.d.Store, newRef)
	if err != nil {
		t.Fatal(err)
	}
	// Invite returns the stored invitation, verified, and writes nothing.
	got, err := w.d.Invite(context.Background(), w.sg, newRef, raw)
	if err != nil {
		t.Fatal(err)
	}
	return got, true
}

func (w *enrollWorld) attempt(t *testing.T) EnrollmentAttempt {
	t.Helper()
	o, err := w.d.Read(context.Background())
	if err != nil {
		t.Fatal(err)
	}
	return o.Roster.EnrollmentAttempts[newRef]
}

// The whole sequence (spec 2 §5 step 5, §11): attempt, the console's own
// membership read and the invitation, guard-ready, the console's second
// read and the enrollment. The deployment's floor is published, so the
// enrolling write passes membership.go's rule only because the guard-ready
// report is there; the box takes its group's role in it.
func TestAutomaticEnrollmentFromRecordToRoster(t *testing.T) {
	w := newEnrollWorld(t, RoleLegacy)
	if got := w.pass(t); got != "began an attempt" {
		t.Fatalf("first pass: %q", got)
	}
	if _, ok := w.invitation(t); ok || len(w.cloud.calls) != 0 {
		t.Fatalf("invited before membership was read: calls %v", w.cloud.calls)
	}
	if got := w.pass(t); got != "invited" {
		t.Fatalf("second pass: %q", got)
	}
	inv, ok := w.invitation(t)
	if !ok || len(w.cloud.calls) != 1 {
		t.Fatalf("invitation %v, calls %v", ok, w.cloud.calls)
	}
	// Invited, but the guard is not armed: nothing more, and no read.
	// First no report at all, then a report from a box whose guard is not
	// ready.
	if got := w.pass(t); got != "waiting for its guard-ready report" {
		t.Fatalf("invited, no report: %q", got)
	}
	w.guardReady(t, inv.Enrollment, false)
	if got := w.pass(t); got != "waiting for its guard-ready report" || len(w.cloud.calls) != 1 {
		t.Fatalf("invited, guard not ready: %q, calls %v", got, w.cloud.calls)
	}
	w.guardReady(t, inv.Enrollment, true)
	w.clock = w.clock.Add(5 * time.Minute)
	if got := w.pass(t); !strings.HasPrefix(got, "enrolled in ie") {
		t.Fatalf("ready: %q", got)
	}
	// The enrollment read membership again, itself.
	if len(w.cloud.calls) != 2 || len(w.logs) != 2 || !strings.Contains(w.logs[1], "attempt "+w.attempt(t).AttemptID+", instance i-new, request ids asg-req, ec2-req") || !strings.HasSuffix(w.logs[1], "): enrolled") {
		t.Fatalf("calls %v, logs %q", w.cloud.calls, w.logs)
	}
	o, err := w.d.Read(context.Background())
	if err != nil {
		t.Fatal(err)
	}
	b, ok := o.Roster.Boxes[newRef]
	if !ok || b.State != "active" || b.Enrollment != inv.Enrollment || b.Cloud.InstanceID != "i-new" || b.Group != "routers-ie" || b.Role != RoleLegacy || b.Namespace != "platform" || b.Pool != "default" {
		t.Fatalf("roster entry %+v", b)
	}
	if !o.Roster.EnrollmentAttempts[newRef].Consumed {
		t.Fatal("the attempt was not consumed")
	}
	// Enrolled: the pass has nothing left to do for it.
	if got := w.pass(t); got != "" {
		t.Fatalf("enrolled box stepped again: %q", got)
	}
}

// R1: an instance in the deployment's account that the cloud does not
// place in the region's group is never invited and never enrolled, however
// long it waits; and one that leaves the group after its invitation is not
// enrolled on a guard-ready report.
func TestAnInstanceOutsideTheGroupIsNeverInvitedOrEnrolled(t *testing.T) {
	for name, m := range map[string]Membership{
		"another group": member("routers-uk"),
		"no group":      {Owner: "123", InstanceState: "running", AvailabilityZoneRegion: "eu-west-1", RequestIDs: []string{"a", "b"}},
	} {
		t.Run(name, func(t *testing.T) {
			w := newEnrollWorld(t, "")
			w.cloud.members["i-new"] = m
			for i := 0; i < 12; i++ {
				w.pass(t)
				w.clock = w.clock.Add(time.Minute)
			}
			if _, ok := w.invitation(t); ok {
				t.Fatal("an instance outside the group was invited")
			}
			if o, _ := w.d.Read(context.Background()); o.Roster.Boxes[newRef].Enrollment != "" {
				t.Fatal("an instance outside the group was enrolled")
			}
		})
	}
	w := newEnrollWorld(t, "")
	w.pass(t)
	if got := w.pass(t); got != "invited" {
		t.Fatal(got)
	}
	inv, _ := w.invitation(t)
	w.guardReady(t, inv.Enrollment, true)
	w.cloud.members["i-new"] = func() Membership { m := member("routers-ie"); m.LifecycleState = "Detaching"; return m }()
	if got := w.pass(t); !strings.Contains(got, "not confirmed") {
		t.Fatalf("left the group: %q", got)
	}
	if o, _ := w.d.Read(context.Background()); o.Roster.Boxes[newRef].Enrollment != "" {
		t.Fatal("an instance that left the group was enrolled")
	}
}

// R1 (#7): a read that fails is unknown. The attempt stands, its wait does
// not grow, and the next read that answers goes on from it.
func TestAMembershipReadThatFailsKeepsTheAttempt(t *testing.T) {
	w := newEnrollWorld(t, "")
	w.pass(t)
	first := w.attempt(t).AttemptID
	w.cloud.err = errors.New("Throttling: rate exceeded")
	for i := 0; i < 10; i++ {
		w.clock = w.clock.Add(time.Hour)
		if got := w.pass(t); !strings.Contains(got, "could not be read") {
			t.Fatalf("failed read: %q", got)
		}
	}
	if got := w.attempt(t).AttemptID; got != first || w.e.attempts[newRef].tries != 1 {
		t.Fatalf("a failed read superseded the attempt (%s, was %s) or grew its wait (%d)", got, first, w.e.attempts[newRef].tries)
	}
	w.cloud.err = nil
	if got := w.pass(t); got != "invited" || w.attempt(t).AttemptID != first {
		t.Fatalf("after the outage: %q", got)
	}
}

// C2: the invitation is written only while the console's read is under a
// minute old. Here every look at the clock takes 31 seconds: the read is
// fresh when checkConfirmed has it, and over a minute old by the time the
// invitation would be written. That is unknown: the attempt and its wait
// stand.
func TestAnInvitationNeedsAReadUnderAMinuteOldAtTheWrite(t *testing.T) {
	w := newEnrollWorld(t, "")
	w.pass(t)
	first := w.attempt(t).AttemptID
	w.e.Now = func() time.Time { w.clock = w.clock.Add(31 * time.Second); return w.clock }
	if got := w.pass(t); !strings.Contains(got, "more than a minute after its membership read") {
		t.Fatalf("slow pass: %q", got)
	}
	if _, ok := w.invitation(t); ok {
		t.Fatal("invited on a read over a minute old")
	}
	if w.attempt(t).AttemptID != first || w.e.attempts[newRef].tries != 1 || w.e.attempts[newRef].refused != "" {
		t.Fatalf("a missed deadline counted against the attempt: %+v", w.e.attempts[newRef])
	}
}

// slowStore is the deployment bucket with one slow read: the first Get of
// slow after armed is set moves the console's clock on by exactly 60
// seconds, to the deadline itself, as a bucket that answers late would. It honours its context, as the S3
// store does, and records every Put.
type slowStore struct {
	release.Store
	slow  string
	armed bool
	late  func()
	puts  []string
}

func (s *slowStore) Get(ctx context.Context, key string) ([]byte, string, error) {
	if err := ctx.Err(); err != nil {
		return nil, "", err
	}
	if s.armed && key == s.slow {
		s.armed = false
		s.late()
	}
	return s.Store.Get(ctx, key)
}

func (s *slowStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
	if err := ctx.Err(); err != nil {
		return "", err
	}
	s.puts = append(s.puts, key)
	return s.Store.Put(ctx, key, body, ifMatch)
}

// slowAfterRead makes the store slow on key after the console's next
// membership read.
func (w *enrollWorld) slowAfterRead(key string) *slowStore {
	ss := &slowStore{Store: w.e.Directory.Store, slow: key, late: func() { w.clock = w.clock.Add(60 * time.Second) }}
	w.e.Directory.Store = ss
	w.cloud.during = func() { ss.armed = true }
	return ss
}

// S1: a read inside the sealed Invite that answers late (here, its own
// read of the invitation key) leaves the invitation unwritten: no Put is
// issued after the deadline. The attempt and its wait stand, and the next
// pass, with a fresh read, invites.
func TestASlowReadInsideInviteWritesNothing(t *testing.T) {
	w := newEnrollWorld(t, "")
	w.pass(t)
	first := w.attempt(t).AttemptID
	ss := w.slowAfterRead("control/host-actor-invitations/" + newRef + ".json")
	if got := w.pass(t); !strings.Contains(got, "more than a minute after its membership read") {
		t.Fatalf("slow invite: %q", got)
	}
	if len(ss.puts) != 0 {
		t.Fatalf("written after the deadline: %v", ss.puts)
	}
	if _, ok := w.invitation(t); ok || w.attempt(t).AttemptID != first || w.e.attempts[newRef].tries != 1 {
		t.Fatalf("invited %v, attempt %+v", ok, w.e.attempts[newRef])
	}
	w.cloud.during = nil
	if got := w.pass(t); got != "invited" || w.attempt(t).AttemptID != first {
		t.Fatalf("next pass: %q", got)
	}
}

// S1: a read inside the sealed checkMembership that answers late (the
// guard-ready report Replace checks before its CAS) leaves the roster
// unwritten. The attempt stays unconsumed, and the next pass enrolls.
func TestASlowReadInsideTheRosterCheckWritesNothing(t *testing.T) {
	w := newEnrollWorld(t, "")
	w.pass(t)
	if got := w.pass(t); got != "invited" {
		t.Fatal(got)
	}
	inv, _ := w.invitation(t)
	w.guardReady(t, inv.Enrollment, true)
	first, tries := w.attempt(t).AttemptID, w.e.attempts[newRef].tries
	ss := w.slowAfterRead("status/boxes/" + newRef + ".json")
	if got := w.pass(t); !strings.Contains(got, "more than a minute after its membership read") {
		t.Fatalf("slow enrolment: %q", got)
	}
	if len(ss.puts) != 0 {
		t.Fatalf("written after the deadline: %v", ss.puts)
	}
	if a := w.attempt(t); a.AttemptID != first || a.Consumed || w.e.attempts[newRef].tries != tries || w.e.attempts[newRef].refused != "" {
		t.Fatalf("attempt %+v, seen %+v", a, w.e.attempts[newRef])
	}
	if o, _ := w.d.Read(context.Background()); o.Roster.Boxes[newRef].Enrollment != "" {
		t.Fatal("enrolled after the deadline")
	}
	w.cloud.during = nil
	w.guardReady(t, inv.Enrollment, true)
	if got := w.pass(t); !strings.HasPrefix(got, "enrolled in ie") {
		t.Fatalf("next pass: %q", got)
	}
}

// The ruling's second test: a box cannot satisfy another box's report
// check. A report under newRef's key that another instance wrote is not
// newRef's guard-ready report, and a record under a key that is not its
// instance's group reference is never invited.
func TestABoxCannotStandInForAnother(t *testing.T) {
	w := newEnrollWorld(t, "")
	w.pass(t)
	if got := w.pass(t); got != "invited" {
		t.Fatal(got)
	}
	inv, _ := w.invitation(t)
	box := status.Box{GeneratedAt: time.Now().UTC(), Metrics: &status.BoxMetrics{Enrollment: inv.Enrollment, InstanceID: "i-other", Release: "v1.0.0", ReleaseTag: "v1.0.0",
		Platform: "linux-amd64", BinarySHA256: w.hash, MovesProtocol: 1, BootID: "boot", FloorSHA256: w.floor.Digest, FloorGuardReady: true, SystemChannel: "stable"}}
	raw, _ := json.Marshal(box)
	if _, err := w.d.Store.Put(context.Background(), "status/boxes/"+newRef+".json", raw, ""); err != nil {
		t.Fatal(err)
	}
	if got := w.pass(t); got != "waiting for its guard-ready report" {
		t.Fatalf("another instance's report: %q", got)
	}
	// A record its box published under a key of its own choosing.
	v := newEnrollWorld(t, "")
	_, chosen := v.signer.Record(t, "chosen", "123", "eu-west-1", "i-chosen")
	if _, err := v.d.Store.Put(context.Background(), boxid.Key("chosen"), chosen, ""); err != nil {
		t.Fatal(err)
	}
	steps, err := v.e.EnrollPass(context.Background())
	if err != nil {
		t.Fatal(err)
	}
	for _, s := range steps {
		if s.Box == "chosen" && !strings.Contains(s.Step, "not under its group reference") {
			t.Fatalf("chosen key: %q", s.Step)
		}
	}
	if _, _, err := v.d.Store.Get(context.Background(), "control/host-actor-invitations/chosen.json"); err == nil {
		t.Fatal("a box with a chosen key was invited")
	}
}

// inflightStore is the bucket with one slow Put: the first Put of key
// after armed is set commits (when commit is set) and then, by mode,
// waits for its context as an S3 request cut off by the deadline does
// ("cancelled"), ignores its context and never answers ("ignores"), or
// answers success only after the console's clock has passed the deadline
// ("late"). In mode "delayed" it commits nothing until hang is closed,
// whatever its context says, then commits and reports on done: a write
// the console gave up on that the bucket applies later.
type inflightStore struct {
	release.Store
	key           string
	armed, commit bool
	mode          string
	late          func()
	hang          chan struct{}
	done          chan error
}

func (s *inflightStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
	if s.armed && key == s.key {
		s.armed = false
		if s.mode == "delayed" {
			<-s.hang
			etag, err := s.Store.Put(ctx, key, body, ifMatch)
			s.done <- err
			return etag, err
		}
		etag := ""
		if s.commit {
			var err error
			if etag, err = s.Store.Put(ctx, key, body, ifMatch); err != nil {
				return "", err
			}
		}
		switch s.mode {
		case "ignores":
			<-s.hang
			return etag, nil
		case "late":
			s.late()
			return etag, nil
		}
		<-ctx.Done()
		return "", ctx.Err()
	}
	return s.Store.Put(ctx, key, body, ifMatch)
}

// inflightAfterRead makes key's next Put slow in mode: the next
// membership read takes 59.95 seconds, so its write has 50 milliseconds,
// and a late answer comes a second after that.
func (w *enrollWorld) inflightAfterRead(key string, commit bool, mode string) *inflightStore {
	is := &inflightStore{Store: w.e.Directory.Store, key: key, commit: commit, mode: mode, hang: make(chan struct{}), done: make(chan error, 1),
		late: func() { w.clock = w.clock.Add(time.Second) }}
	w.e.Directory.Store = is
	w.cloud.during = func() { w.clock = w.clock.Add(59950 * time.Millisecond); is.armed = true }
	return is
}

// S1: a Put still in flight at the deadline is cancelled there, and its
// result is unknown: the attempt and its wait stand, and the next pass
// reads the bucket as it does every pass. Here the invitation did not
// land, then (a second time) it did, and the next pass finds it.
func TestAPutInFlightAtTheDeadlineIsUnknown(t *testing.T) {
	w := newEnrollWorld(t, "")
	w.pass(t)
	first, tries := w.attempt(t).AttemptID, w.e.attempts[newRef].tries
	w.inflightAfterRead("control/host-actor-invitations/"+newRef+".json", false, "cancelled")
	if got := w.pass(t); !strings.Contains(got, "in flight at the deadline") {
		t.Fatalf("in flight: %q", got)
	}
	if _, ok := w.invitation(t); ok || w.attempt(t).AttemptID != first || w.e.attempts[newRef].tries != tries || w.e.attempts[newRef].refused != "" {
		t.Fatalf("invited %v, seen %+v", ok, w.e.attempts[newRef])
	}
	w.inflightAfterRead("control/host-actor-invitations/"+newRef+".json", true, "cancelled")
	if got := w.pass(t); !strings.Contains(got, "in flight at the deadline") {
		t.Fatalf("in flight, landed: %q", got)
	}
	w.cloud.during = nil
	if got := w.pass(t); got != "waiting for its guard-ready report" {
		t.Fatalf("read back: %q", got)
	}
}

// U2: a store that ignores its context and never answers is cut off by
// the hard per-call timeout, well before the deadline: the outcome is
// unknown, the attempt and its wait stand, and nothing was invited.
func TestAStoreThatIgnoresItsContextIsCutOffAtTheHardTimeout(t *testing.T) {
	was := storeCallTimeout
	storeCallTimeout = 100 * time.Millisecond
	t.Cleanup(func() { storeCallTimeout = was })
	w := newEnrollWorld(t, "")
	w.pass(t)
	first, tries := w.attempt(t).AttemptID, w.e.attempts[newRef].tries
	is := &inflightStore{Store: w.e.Directory.Store, key: "control/host-actor-invitations/" + newRef + ".json", mode: "ignores", hang: make(chan struct{}), armed: true}
	t.Cleanup(func() { close(is.hang) })
	w.e.Directory.Store = is
	start := time.Now()
	got := w.pass(t)
	if !strings.Contains(got, "did not answer in time") || time.Since(start) > 5*time.Second {
		t.Fatalf("after %s: %q", time.Since(start), got)
	}
	if _, ok := w.invitation(t); ok || w.attempt(t).AttemptID != first || w.e.attempts[newRef].tries != tries || w.e.attempts[newRef].refused != "" {
		t.Fatalf("invited %v, seen %+v", ok, w.e.attempts[newRef])
	}
}

// U1, U2: an enrolling write whose outcome is unknown is settled by the
// next pass's roster read, with nothing more. Not landed: the same
// attempt, its wait unchanged, enrols on the next pass. Landed, whether
// cut off in flight or answering success only after the deadline: the
// roster lists the box, and the pass has nothing left to do for it.
func TestAnUnknownEnrolmentIsSettledByTheRoster(t *testing.T) {
	ready := func(t *testing.T) *enrollWorld {
		w := newEnrollWorld(t, "")
		w.pass(t)
		if got := w.pass(t); got != "invited" {
			t.Fatal(got)
		}
		inv, _ := w.invitation(t)
		w.guardReady(t, inv.Enrollment, true)
		return w
	}
	t.Run("did not land", func(t *testing.T) {
		w := ready(t)
		first, tries := w.attempt(t).AttemptID, w.e.attempts[newRef].tries
		w.inflightAfterRead(Key, false, "cancelled")
		if got := w.pass(t); !strings.Contains(got, "in flight at the deadline") {
			t.Fatalf("in flight: %q", got)
		}
		if a := w.attempt(t); a.Consumed || a.AttemptID != first || w.e.attempts[newRef].tries != tries {
			t.Fatalf("attempt %+v, seen %+v", a, w.e.attempts[newRef])
		}
		w.cloud.during = nil
		if got := w.pass(t); !strings.HasPrefix(got, "enrolled in ie") {
			t.Fatalf("next pass: %q", got)
		}
	})
	for name, mode := range map[string]string{"landed, cut off in flight": "cancelled", "landed, answered after the deadline": "late"} {
		t.Run(name, func(t *testing.T) {
			w := ready(t)
			w.inflightAfterRead(Key, true, mode)
			want := map[string]string{"cancelled": "in flight at the deadline", "late": "answered only after the deadline"}[mode]
			if got := w.pass(t); !strings.Contains(got, want) {
				t.Fatalf("unknown: %q", got)
			}
			w.cloud.during = nil
			calls := len(w.cloud.calls)
			steps, err := w.e.EnrollPass(context.Background())
			if err != nil || len(steps) != 0 || len(w.cloud.calls) != calls {
				t.Fatalf("settled by the roster: %+v %v, calls %v", steps, err, w.cloud.calls)
			}
			o, _ := w.d.Read(context.Background())
			if b := o.Roster.Boxes[newRef]; b.State != "active" || !o.Roster.EnrollmentAttempts[newRef].Consumed {
				t.Fatalf("roster %+v", b)
			}
		})
	}
}

// V1, the accepted exposure: the console waits for no write past its
// bound, but a write it gave up on can still commit. Here the enrolling
// Put is issued before the deadline and blocks; the pass returns unknown;
// the instance is detached from its group (still running); the next pass
// finds the box absent from the roster, and its fresh describe refuses
// without writing anything; then the bucket applies the delayed Put, on
// the roster revision it was built against, which nothing has moved. The
// pass after that finds the box enrolled and does nothing more. Such an
// enrolment rests on a membership read that was true when its write was
// issued: the same exposure as an instance detached after enrolment,
// which spec 3's regional worker reconciles.
func TestAWriteTheConsoleGaveUpOnCanCommitLater(t *testing.T) {
	w := newEnrollWorld(t, "")
	w.pass(t)
	if got := w.pass(t); got != "invited" {
		t.Fatal(got)
	}
	inv, _ := w.invitation(t)
	w.guardReady(t, inv.Enrollment, true)
	before, _ := w.d.Read(context.Background())
	is := w.inflightAfterRead(Key, false, "delayed")
	if got := w.pass(t); !strings.Contains(got, "in flight at the deadline") {
		t.Fatalf("unknown: %q", got)
	}
	// Detached: the group no longer lists the instance, which still runs.
	w.cloud.during = nil
	w.cloud.members["i-new"] = member("")
	calls := len(w.cloud.calls)
	if got := w.pass(t); !strings.Contains(got, "not confirmed") || len(w.cloud.calls) != calls+1 {
		t.Fatalf("absent, then refused: %q, calls %v", got, w.cloud.calls)
	}
	if o, _ := w.d.Read(context.Background()); o.ETag != before.ETag {
		t.Fatalf("the refusing pass wrote the roster: %s, was %s", o.ETag, before.ETag)
	}
	close(is.hang)
	if err := <-is.done; err != nil {
		t.Fatalf("the delayed write did not commit: %v", err)
	}
	calls = len(w.cloud.calls)
	steps, err := w.e.EnrollPass(context.Background())
	if err != nil || len(steps) != 0 || len(w.cloud.calls) != calls {
		t.Fatalf("enrolled: %+v %v, calls %v", steps, err, w.cloud.calls)
	}
	o, _ := w.d.Read(context.Background())
	if b := o.Roster.Boxes[newRef]; b.State != "active" || o.Roster.Revision != before.Roster.Revision+1 {
		t.Fatalf("roster %+v, revision %d", b, o.Roster.Revision)
	}
}

// A refused attempt is kept, with no more reads, for its wait, then begun
// again; each refusal doubles the wait, up to 32 times Patience.
func TestARefusedAttemptIsBegunAgainWithADoublingWait(t *testing.T) {
	w := newEnrollWorld(t, "")
	w.cloud.members["i-new"] = member("")
	if got := w.pass(t); got != "began an attempt" {
		t.Fatal(got)
	}
	first := w.attempt(t).AttemptID
	if got := w.pass(t); !strings.Contains(got, "not confirmed") {
		t.Fatal(got)
	}
	w.clock = w.clock.Add(time.Minute + 59*time.Second)
	if got := w.pass(t); !strings.Contains(got, "waiting before another attempt") || len(w.cloud.calls) != 1 {
		t.Fatalf("inside the wait: %q, calls %v", got, w.cloud.calls)
	}
	w.clock = w.clock.Add(time.Second)
	if got := w.pass(t); got != "began an attempt" || w.attempt(t).AttemptID == first {
		t.Fatalf("after two minutes: %q", got)
	}
	if got := w.pass(t); !strings.Contains(got, "not confirmed") {
		t.Fatal(got)
	}
	// The second refusal waits four minutes.
	w.clock = w.clock.Add(3*time.Minute + 59*time.Second)
	if got := w.pass(t); !strings.Contains(got, "waiting before another attempt") {
		t.Fatalf("second attempt before its four minutes: %q", got)
	}
	w.clock = w.clock.Add(time.Second)
	if got := w.pass(t); got != "began an attempt" {
		t.Fatalf("second attempt after four minutes: %q", got)
	}
	if got := w.e.wait(10); got != 64*time.Minute {
		t.Fatalf("the wait is not capped: %s", got)
	}
}

func TestAutomaticEnrollmentRefusesWhatItCannotProve(t *testing.T) {
	// A box from an account or region with no group is left alone.
	w := newEnrollWorld(t, "")
	_, raw := boxidtest.NewSigner(t).Record(t, "elsewhere", "999", "eu-west-1", "i-x")
	if _, err := w.d.Store.Put(context.Background(), boxid.Key("elsewhere"), raw, ""); err != nil {
		t.Fatal(err)
	}
	steps, err := w.e.EnrollPass(context.Background())
	if err != nil {
		t.Fatal(err)
	}
	for _, s := range steps {
		if s.Box == "elsewhere" && !strings.Contains(s.Step, "no region's group") {
			t.Fatalf("elsewhere: %q", s.Step)
		}
	}
	// A record whose signature does not verify under the certificates.
	w.e.Certs = boxidtest.NewSigner(t).Certs()
	if got := w.pass(t); !strings.Contains(got, "does not verify") {
		t.Fatalf("unverified record: %q", got)
	}
	// A deployment with no published floor enrolls nothing.
	d, sg, _ := fixture(t)
	unpublished := &AutoEnroller{Directory: d, Signer: sg, Namespace: "platform", Pool: "default", Now: time.Now}
	if _, err := unpublished.EnrollPass(context.Background()); !errors.Is(err, ErrNoFloor) {
		t.Fatalf("unpublished: %v", err)
	}
}
````

`internal/console/enrollpass_test.go`:

````go
package console

import (
	"context"
	"crypto/ed25519"
	"errors"
	"fmt"
	"strings"
	"sync"
	"testing"
	"time"

	"github.com/carlosframework/platform/internal/hostactors"
	"github.com/carlosframework/platform/internal/hostops"
	"github.com/carlosframework/platform/internal/release"
)

// noMembers is a membership reader that is never asked.
type noMembers struct{}

func (noMembers) Member(context.Context, string, string) (hostactors.Membership, error) {
	return hostactors.Membership{}, errors.New("not asked")
}

func TestEnrollPassIsBuiltFromTheClaimAuthority(t *testing.T) {
	pub, key, _ := ed25519.GenerateKey(nil)
	a := &hostops.ProductionAuthority{Directory: hostactors.Directory{Store: release.NewDirStore(t.TempDir()), PublicKey: pub, Bucket: "bucket"},
		Signer: release.RootSigner(key), Namespace: "platform"}
	if _, err := newAutoEnroller(nil, noMembers{}); !errors.Is(err, hostops.ErrContract) {
		t.Fatalf("no authority: %v", err)
	}
	if _, err := newAutoEnroller(a, nil); !errors.Is(err, hostops.ErrContract) {
		t.Fatalf("no membership reader: %v", err)
	}
	e, err := newAutoEnroller(a, noMembers{})
	if err != nil {
		t.Fatal(err)
	}
	if e.Namespace != "platform" || e.Pool != "default" || e.Patience != enrollPatience || len(e.Certs) == 0 || e.Directory.Bucket != "bucket" || e.Members != (noMembers{}) || e.Logf == nil {
		t.Fatalf("enroller %+v", e)
	}
}

// A pass that cannot read the roster is logged on its first failure and
// every tenth, not every ten seconds, and the loop ends with its context.
func TestEnrollPassLogsFailuresSparingly(t *testing.T) {
	pub, key, _ := ed25519.GenerateKey(nil)
	e := &hostactors.AutoEnroller{Directory: hostactors.Directory{Store: release.NewDirStore(t.TempDir()), PublicKey: pub, Bucket: "bucket"},
		Signer: release.RootSigner(key), Namespace: "platform", Pool: "default", Now: time.Now, Patience: time.Minute}
	var mu sync.Mutex
	var lines []string
	logf := func(format string, args ...any) {
		mu.Lock()
		lines = append(lines, fmt.Sprintf(format, args...))
		mu.Unlock()
	}
	ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
	defer cancel()
	runEnrollPass(ctx, e, 2*time.Millisecond, logf)
	mu.Lock()
	defer mu.Unlock()
	if len(lines) < 2 || !strings.Contains(lines[0], "armed") {
		t.Fatalf("lines %q", lines)
	}
	for i, l := range lines[1:] {
		var n int
		if _, err := fmt.Sscanf(l[strings.Index(l, "#"):], "#%d", &n); err != nil || (n != 1 && n%10 != 0) || (i == 0 && n != 1) {
			t.Fatalf("logged %q", l)
		}
	}
}
````

- [ ] **Step 2: Run them to see them fail**

Run: `nice -n 19 go test ./internal/hostactors/ ./internal/console/ -run 'TestAutomaticEnrollment|TestAnInstanceOutside|TestAMembershipReadThatFails|TestAnInvitationNeeds|TestASlowRead|TestAPutInFlight|TestAStoreThatIgnores|TestAnUnknownEnrolment|TestAWriteTheConsoleGaveUp|TestABoxCannotStandIn|TestARefusedAttempt|TestEnrollPass' -count=1`
Expected: FAIL, neither package builds (`undefined: AutoEnroller`, `undefined: newAutoEnroller`).

- [ ] **Step 3: Write the pass and start it**

`internal/hostactors/autoenroll.go`:

````go
package hostactors

import (
	"context"
	"crypto/x509"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"slices"
	"sort"
	"strings"
	"time"

	"github.com/carlosframework/platform/internal/boxid"
	"github.com/carlosframework/platform/internal/hostfloor"
	"github.com/carlosframework/platform/internal/release"
)

// The console's automatic enrollment pass (region HA spec 2 §5 step 5,
// A2-1, H16, H28). A box its region's group launched publishes its signed
// identity record (control/boxes/<ref>.json) and joins. For each such box
// not yet in the roster, the pass takes the next step its state allows:
//
//  1. no invitation: begin an enrollment attempt (BeginEnrollmentAttempt),
//     then, on the next pass, confirm the instance's group membership with
//     the console's own read (checkConfirmed, Members) and invite the box
//     (Invite) while that read is under a minute old;
//  2. invited: wait for the box to arm its guard and publish its
//     guard-ready report (VerifyReport, the same check membership.go makes
//     when the box is admitted);
//  3. guard-ready: enroll it (EnrollAutomatic), which reads membership
//     again, itself, immediately before the enrolling write.
//
// A read that fails is unknown: the pass asks again next time, with the
// same attempt, and its wait does not grow. Only the cloud's positive
// answer that the instance is not a current member (ErrNotMember), or a
// read that was over a minute old at its write (ErrMembershipStale),
// counts against the attempt: the pass then waits Patience before it
// begins another, doubling each time, so the record of a box the group
// has already replaced stops costing roster writes and describe calls.
// The invitation never changes.
//
// The box takes its group's role in the enrolling write (enroll.go). Only
// a deployment whose host floor is published is enrolled automatically:
// a box can arm its guard only under one.
//
// Nothing here names a provider beyond the identity the roster already
// rests on (H26, §25): the record is AWS's signed instance document, as
// for every enrolled box on main, and Members is an interface.

// AutoEnroller is the pass's configuration and its memory of attempts.
type AutoEnroller struct {
	Directory Directory
	Signer    release.Signer
	// Evidence reads boxes' reports and release identities (the console's
	// ExactEvidence).
	Evidence Evidence
	// Members is the console's own read of the cloud's group membership
	// (awsmembership in production).
	Members MembershipReader
	// Certs verify identity records (boxid.EmbeddedCerts in production).
	Certs map[string]*x509.Certificate
	// Namespace and Pool are what a box is enrolled into: the console's
	// own namespace, and the default pool.
	Namespace, Pool string
	Now             func() time.Time
	// Patience is how long the pass waits after an attempt is refused
	// before it begins another. Each refused attempt doubles the wait, up
	// to 32 times Patience, so a record whose instance is gone (a box the
	// group replaced while it was joining) costs the roster a write an
	// hour, not one a minute. An invitation resets it.
	Patience time.Duration
	// Logf records each membership read's request ids (C2's audit: the
	// roster has no field for them). nil is silent.
	Logf func(format string, args ...any)

	attempts map[string]attemptSeen
}

// attemptSeen is what the pass remembers of a box's current attempt: its
// id, when the pass first saw it, how many attempts it has begun for the
// box since the last invitation, and why the cloud refused the current
// one ("" while it has not). Memory only: a console restart starts every
// box afresh.
type attemptSeen struct {
	id      string
	since   time.Time
	tries   int
	refused string
}

// wait is how long a refused attempt is kept after tries attempts.
func (e *AutoEnroller) wait(tries int) time.Duration {
	return e.Patience << min(max(tries-1, 0), 5)
}

// EnrollStep is what one pass did, or is waiting for, for one box.
type EnrollStep struct {
	Box  string
	Step string
}

// ErrNoFloor is a deployment with no published host floor: no box can arm
// its guard, so none is enrolled automatically.
var ErrNoFloor = errors.New("hostactors: no host floor is published, so no box is enrolled automatically")

// EnrollPass takes one step for every box whose identity record is
// published and that the roster does not list. It returns the steps in
// reference order, and the first error that stopped the pass as a whole.
func (e *AutoEnroller) EnrollPass(ctx context.Context) ([]EnrollStep, error) {
	o, floor, err := e.inputs(ctx)
	if err != nil {
		return nil, err
	}
	var steps []EnrollStep
	keys, err := e.Directory.Store.List(ctx, boxid.Prefix)
	if err != nil {
		return nil, err
	}
	var refs []string
	for _, k := range keys {
		ref, ok := strings.CutSuffix(strings.TrimPrefix(k, boxid.Prefix), ".json")
		if !ok || strings.Contains(ref, "/") {
			continue
		}
		if _, listed := o.Roster.Boxes[ref]; !listed {
			refs = append(refs, ref)
		}
	}
	sort.Strings(refs)
	if e.attempts == nil {
		e.attempts = map[string]attemptSeen{}
	}
	for _, ref := range refs {
		next, step := e.step(ctx, o, floor, ref)
		if next.ETag != "" {
			o = next
		}
		steps = append(steps, EnrollStep{Box: ref, Step: step})
	}
	for ref := range e.attempts {
		if !slices.Contains(refs, ref) {
			delete(e.attempts, ref)
		}
	}
	return steps, nil
}

// inputs reads the roster and the host floor it was published under.
func (e *AutoEnroller) inputs(ctx context.Context) (Observed, hostfloor.Verified, error) {
	o, err := e.Directory.Read(ctx)
	if err != nil {
		return Observed{}, hostfloor.Verified{}, err
	}
	if o.Roster.Publication != "published" {
		return Observed{}, hostfloor.Verified{}, ErrNoFloor
	}
	floor, err := hostfloor.Read(ctx, e.Directory.Store, e.Directory.PublicKey, e.Directory.Bucket)
	if err != nil {
		return Observed{}, hostfloor.Verified{}, err
	}
	if floor.Digest != o.Roster.FloorSHA256 {
		return Observed{}, hostfloor.Verified{}, ErrRoster
	}
	return o, floor, nil
}

// step takes one box's next step. It returns the roster it wrote, if it
// wrote one, and what it did or waits for.
func (e *AutoEnroller) step(ctx context.Context, o Observed, floor hostfloor.Verified, ref string) (Observed, string) {
	d, now := e.Directory, e.Now()
	raw, err := boxid.Load(ctx, d.Store, ref)
	if err != nil {
		return Observed{}, fmt.Sprintf("its identity record could not be read: %v", err)
	}
	name, region, err := groupRegion(o.Roster, raw)
	if err != nil {
		return Observed{}, err.Error()
	}
	if !slices.Contains(region.Namespaces, e.Namespace) || !slices.Contains(region.Pools, e.Pool) {
		return Observed{}, fmt.Sprintf("%s does not serve namespace %s and pool %s", name, e.Namespace, e.Pool)
	}
	v := boxid.Verifier{Account: region.Account, Certs: e.Certs}
	expect := boxid.Expect{Box: ref, AWSRegion: region.AWSRegion}
	for other, b := range o.Roster.Boxes {
		expect.Roster = append(expect.Roster, boxid.Enrolled{Box: other, Cloud: b.Cloud})
	}
	ev, err := v.Enroll(raw, expect)
	if err != nil {
		return Observed{}, fmt.Sprintf("its identity record does not verify: %v", err)
	}
	instance := ev.Cloud.InstanceID
	// A group box publishes under the reference its instance derives;
	// the pass derives it again and never takes the key the box chose
	// (census ruling region-ha-2b).
	if ref != GroupBoxReference(region.Account, instance) {
		return Observed{}, "its record is not under its group reference, so it is not enrolled automatically"
	}
	in := EnrollmentRequest{Reference: ref, Namespace: e.Namespace, Label: ref, Region: name, Pool: e.Pool, AWSRegion: region.AWSRegion}

	// The invitation is only read here, once, and verified for this
	// record (enrollmentNonce writes nothing). Invite is get-or-create, so
	// it runs only on the bound store after a membership read
	// (confirmAndWrite): an invitation that vanished between two reads is
	// never minted again without one.
	nonce, err := d.enrollmentNonce(ctx, in, raw)
	invited := err == nil
	switch {
	case err == nil, errors.Is(err, os.ErrNotExist):
	case errors.Is(err, ErrRoster):
		return Observed{}, fmt.Sprintf("its invitation is not for this record: %v", err)
	default:
		return Observed{}, fmt.Sprintf("its invitation could not be read: %v", err)
	}
	if invited {
		// The report is read under the reference derived from the
		// attempt's proven instance (PrivateBoxReport, Group set).
		rep, err := e.Evidence.BoxReport(ctx, ref, Box{Namespace: e.Namespace, Label: ref, Group: region.GroupName, Cloud: ev.Cloud})
		if err != nil {
			return Observed{}, "waiting for its guard-ready report"
		}
		if err := VerifyReport(ctx, e.Evidence, d.PublicKey, floor, rep, nonce, instance, now); err != nil {
			return Observed{}, "waiting for its guard-ready report"
		}
	}
	a, ok := o.Roster.EnrollmentAttempts[ref]
	usable := ok && !a.Consumed && a.InstanceID == instance && a.EvidenceSHA256 == hostfloor.Digest(raw) && a.Region == name &&
		a.Account == region.Account && a.AWSRegion == region.AWSRegion && a.GroupName == region.GroupName && a.GroupARN == region.GroupARN
	seen := e.attempts[ref]
	if usable {
		if seen.id != a.AttemptID {
			seen = attemptSeen{id: a.AttemptID, since: now, tries: seen.tries}
			e.attempts[ref] = seen
		}
		if seen.refused != "" {
			// The cloud refused this attempt: keep it until its wait is
			// over, asking nothing more, then begin another.
			if now.Sub(seen.since) < e.wait(seen.tries) {
				return Observed{}, "not confirmed (" + seen.refused + "); waiting before another attempt"
			}
		} else {
			in.AttemptID, in.ConfirmationID = a.AttemptID, a.ConfirmationID
			next, step, err := e.confirmAndWrite(ctx, o, v, in, raw, instance, invited, name, region)
			if err == nil {
				if !invited {
					seen.tries = 0
					e.attempts[ref] = seen
				} else {
					delete(e.attempts, ref)
				}
				return next, step
			}
			if !errors.Is(err, ErrNotMember) && !errors.Is(err, ErrMembershipStale) {
				// Unknown: a failed read, or a write that failed. The same
				// attempt is asked again next time; the wait does not grow.
				return Observed{}, step
			}
			seen.refused, seen.since = err.Error(), now
			e.attempts[ref] = seen
			return Observed{}, "not confirmed (" + seen.refused + "); waiting before another attempt"
		}
	}
	next, a, err := d.BeginEnrollmentAttempt(ctx, e.Signer, o, in, raw, instance)
	if err != nil {
		return Observed{}, fmt.Sprintf("an attempt could not be begun: %v", err)
	}
	e.attempts[ref] = attemptSeen{id: a.AttemptID, since: now, tries: seen.tries + 1}
	return next, "began an attempt"
}

// confirmAndWrite reads the box's membership and makes the write it
// allows: the invitation, or, for a box already invited and guard-ready,
// the enrollment. The membership read is the console's own, made here,
// immediately before the write.
func (e *AutoEnroller) confirmAndWrite(ctx context.Context, o Observed, v boxid.Verifier, in EnrollmentRequest, raw []byte, instance string, invited bool, name string, region Region) (Observed, string, error) {
	d := e.Directory
	if invited {
		next, ev, err := d.EnrollAutomatic(ctx, e.Signer, o, v, in, raw, instance, e.Members, e.Now)
		// A write the deadline left unknown (ErrWriteDeadline) is settled
		// by the next pass's roster read: a box the roster lists is
		// enrolled, and the pass has nothing more to do for it; one it
		// does not list goes on with the same attempt, whose wait has not
		// grown.
		e.logRead(in, instance, ev, "enrolled", err)
		if err != nil {
			return Observed{}, fmt.Sprintf("it could not be enrolled: %v", err), err
		}
		return next, fmt.Sprintf("enrolled in %s with the group's role, %s", name, RoleName(region.Role)), nil
	}
	// checkConfirmed returns the confirmation its read built, and the
	// write is bound to that, never to one rebuilt from exported evidence.
	_, c, err := d.checkConfirmed(ctx, o, v, in, raw, instance, e.Members, e.Now)
	ev := c.evidence()
	if err != nil {
		e.logRead(in, instance, ev, "", err)
		return Observed{}, fmt.Sprintf("its membership could not be confirmed: %v", err), err
	}
	// Invite is main's and sealed. It runs on the store bound to the
	// read's deadline (writeBound), the single enforcement point: its
	// record read, its signature's Put and any read-back are refused once
	// the read is a minute old; a call in flight then, or still unanswered
	// 15 seconds after it began, is cut off as unknown, and so is one that
	// answers only after the deadline. The next pass settles it from the
	// bucket.
	// Nothing that waits sits between the read and this call.
	bd, bctx, cancel := writeBound(ctx, d, c, e.Now)
	defer cancel()
	_, err = bd.Invite(bctx, e.Signer, in.Reference, raw)
	e.logRead(in, instance, ev, "invited", err)
	if err != nil {
		return Observed{}, fmt.Sprintf("it could not be invited: %v", err), err
	}
	return Observed{}, "invited", nil
}

// logRead records one membership read: the box, its attempt, its instance,
// the cloud's two request ids and the read's time, and what came of it
// (C2's audit: the roster has no field for them). A call that failed
// before the cloud answered has no request ids and says so.
func (e *AutoEnroller) logRead(in EnrollmentRequest, instance string, ev MembershipEvidence, done string, err error) {
	outcome := done
	if err != nil {
		outcome = "not " + done + ": " + err.Error()
		if done == "" {
			outcome = err.Error()
		}
	}
	e.logOutcome(in, instance, ev, outcome)
}

// logOutcome is one membership log line (S5): the box, its attempt, its
// instance, both request ids and the read's time, and the outcome.
func (e *AutoEnroller) logOutcome(in EnrollmentRequest, instance string, ev MembershipEvidence, outcome string) {
	if e.Logf == nil {
		return
	}
	ids := "none"
	if len(ev.RequestIDs) > 0 {
		ids = strings.Join(ev.RequestIDs, ", ")
	}
	at := "never"
	if !ev.ObservedAt.IsZero() {
		at = ev.ObservedAt.Format(time.RFC3339)
	}
	e.Logf("console: automatic box enrollment: membership read for %s (attempt %s, instance %s, request ids %s, read at %s): %s", in.Reference, in.AttemptID, instance, ids, at, outcome)
}

// groupRegion is the one roster region whose group could have launched
// the box the record names: its account and AWS region, with a group.
// The record is read here only to choose; Verifier.Enroll then proves it.
func groupRegion(r Roster, raw []byte) (string, Region, error) {
	var rec boxid.Record
	if json.Unmarshal(raw, &rec) != nil {
		return "", Region{}, errors.New("its identity record is not JSON")
	}
	doc, err := base64.StdEncoding.DecodeString(rec.IdentityDocument)
	if err != nil {
		return "", Region{}, errors.New("its identity document is not base64")
	}
	var id struct {
		AccountID string `json:"accountId"`
		Region    string `json:"region"`
	}
	if json.Unmarshal(doc, &id) != nil {
		return "", Region{}, errors.New("its identity document is not JSON")
	}
	var names []string
	for name, g := range r.Regions {
		if g.GroupName != "" && g.Account == id.AccountID && g.AWSRegion == id.Region {
			names = append(names, name)
		}
	}
	switch len(names) {
	case 0:
		return "", Region{}, fmt.Errorf("no region's group runs in %s for account %s", id.Region, id.AccountID)
	case 1:
		return names[0], r.Regions[names[0]], nil
	}
	sort.Strings(names)
	return "", Region{}, fmt.Errorf("the regions %s all have a group in %s", strings.Join(names, ", "), id.Region)
}
````

`internal/console/enrollpass.go`:

````go
package console

import (
	"context"
	"log"
	"time"

	"github.com/carlosframework/platform/internal/boxid"
	"github.com/carlosframework/platform/internal/hostactors"
	"github.com/carlosframework/platform/internal/hostops"
)

// The console's automatic enrollment pass (region HA spec 2 §5 step 5,
// H16, H28): every enrollPassInterval it takes the next step for each box
// its region's group launched that the roster does not list yet
// (hostactors.AutoEnroller: attempt, invitation, guard-ready, enrollment).
// It runs beside claim recovery, on a host-claim console with region HA
// on, and signs with the root key this console already holds. It confirms
// each box's group membership itself, with its own read-only Auto Scaling
// and EC2 describes (consoleMembershipReader), immediately before it
// invites and before it enrolls. The console is the only writer of the
// roster and the only membership confirmer, so it is the only place this
// can run.

// enrollPassInterval is how often the pass runs: a box is invited, and
// enrolled once its guard-ready report is published, within ten seconds.
const enrollPassInterval = 10 * time.Second

// enrollPatience is how long the pass keeps an attempt the cloud refused
// before it begins another (doubling with each refusal).
const enrollPatience = 2 * time.Minute

// newAutoEnroller is the pass over this console's claim authority (its
// roster, root signer, report evidence and namespace, and the default
// pool) and its own membership reader. An error when either is missing.
func newAutoEnroller(a *hostops.ProductionAuthority, members hostactors.MembershipReader) (*hostactors.AutoEnroller, error) {
	if a == nil || members == nil {
		return nil, hostops.ErrContract
	}
	certs, err := boxid.EmbeddedCerts()
	if err != nil {
		return nil, err
	}
	return &hostactors.AutoEnroller{Directory: a.Directory, Signer: a.Signer, Evidence: a.Evidence, Members: members, Certs: certs,
		Namespace: a.Namespace, Pool: "default", Now: time.Now, Patience: enrollPatience, Logf: log.Printf}, nil
}

// runEnrollPass runs the pass until ctx ends. Each box's step is logged
// when it changes, and a failed pass on its first failure and every
// tenth.
func runEnrollPass(ctx context.Context, e *hostactors.AutoEnroller, every time.Duration, logf func(string, ...any)) {
	logf("console: automatic box enrollment armed (every %s)", every)
	last := map[string]string{}
	fails := 0
	for {
		steps, err := e.EnrollPass(ctx)
		switch {
		case err != nil:
			fails++
			if fails == 1 || fails%10 == 0 {
				logf("console: automatic box enrollment (failure #%d): %v", fails, err)
			}
		default:
			fails = 0
			seen := map[string]bool{}
			for _, st := range steps {
				seen[st.Box] = true
				if last[st.Box] != st.Step {
					logf("console: automatic box enrollment: %s: %s", st.Box, st.Step)
					last[st.Box] = st.Step
				}
			}
			for box := range last {
				if !seen[box] {
					delete(last, box)
				}
			}
		}
		select {
		case <-ctx.Done():
			return
		case <-time.After(every):
		}
	}
}

// startEnrollPass starts the pass for this server, or logs why it cannot.
func (s *Server) startEnrollPass(ctx context.Context) {
	members, err := consoleMembershipReader(ctx)
	if err != nil {
		log.Printf("console: automatic box enrollment: not started: %v", err)
		return
	}
	e, err := newAutoEnroller(s.cfg.ClaimAuthority, members)
	if err != nil {
		log.Printf("console: automatic box enrollment: not started: %v", err)
		return
	}
	go runEnrollPass(ctx, e, enrollPassInterval, log.Printf)
}
````

`internal/console/awsmembership.go`:

````go
package console

import (
	"context"

	"github.com/carlosframework/platform/internal/hostactors"
	"github.com/carlosframework/platform/internal/hostactors/awsmembership"
)

// consoleMembershipReader is the enrollment pass's membership read (region
// HA spec 2 §5 step 5, H28, §25): the AWS adapter, over the console's own
// instance role, which 2c grants autoscaling:DescribeAutoScalingInstances
// and ec2:DescribeInstances, read-only, in the deployment's AWS regions.
func consoleMembershipReader(ctx context.Context) (hostactors.MembershipReader, error) {
	return awsmembership.New(ctx)
}
````

`internal/console/hostclaimops.go`:

````diff
--- a/internal/console/hostclaimops.go
+++ b/internal/console/hostclaimops.go
@@ -51,6 +51,9 @@
 		if pub := capacityPublisherFromEnv(ctx, os.Getenv); pub != nil {
 			go s.runCapacityPass(ctx, pub)
 		}
+		// Automatic enrollment of the boxes regions' groups launch
+		// (region HA spec 2 §5 step 5, enrollpass.go).
+		s.startEnrollPass(ctx)
 	}
 	t := time.NewTicker(claimRecoveryInterval)
 	defer t.Stop()
````

- [ ] **Step 4: Run the tests**

Run: `nice -n 19 go test ./internal/hostactors/ ./internal/console/ -run 'TestAutomaticEnrollment|TestAnInstanceOutside|TestAMembershipReadThatFails|TestAnInvitationNeeds|TestASlowRead|TestAPutInFlight|TestAStoreThatIgnores|TestAnUnknownEnrolment|TestAWriteTheConsoleGaveUp|TestABoxCannotStandIn|TestARefusedAttempt|TestEnrollPass|TestEnrollAutomatic' -count=1`
Expected: PASS.

- [ ] **Step 5: The provider boundary**

`region_ha_boundary_test.go`:

````diff
--- a/region_ha_boundary_test.go
+++ b/region_ha_boundary_test.go
@@ -37,6 +37,8 @@
 	"internal/host/installchannel.go",
 	"internal/hostactors/membershipread.go",
 	"internal/hostactors/groupref.go",
+	"internal/hostactors/autoenroll.go",
+	"internal/console/enrollpass.go",
 }
 
 // regionHAAdapterFiles are spec 2's AWS adapters. Each is named for AWS so
@@ -46,6 +48,7 @@
 	"internal/console/awsregionmetric.go",
 	"internal/hostenv/awsssm.go",
 	"internal/hostactors/awsmembership/awsmembership.go",
+	"internal/console/awsmembership.go",
 }
 
 // regionHAPackages are the packages spec 2 touches. Every S3 client built
````

Run: `nice -n 19 go test . -run 'TestRegionHA(Core|Adapters|S3)' -count=1`
Expected: PASS.

- [ ] **Step 6: The console claim-authority census**

Select the pass's construction, start and invocation, and append their three sites:

`internal/console/claim_authority_census_test.go`:

````diff
--- a/internal/console/claim_authority_census_test.go
+++ b/internal/console/claim_authority_census_test.go
@@ -43,6 +43,11 @@
 		"EnrollBackfillApp": true, "OpenGated": true, "WithAppMutation": true,
 		"withClaimSQL": true, "withPreparedClaimSQL": true, "RegisterIngest": true, "RecoverIngest": true, "RecoverSQLTransaction": true, "BindTransaction": true, "PrepareSQLPermit": true, "FenceSQL": true, "FenceSQLOutcome": true, "WithProjection": true,
 		"BindMutationRunner": true, "claimEnrollmentContext": true,
+		// Region HA spec 2's automatic enrollment pass (plan 2b Task 5): its
+		// construction hands this console's roster directory and root
+		// signer to hostactors.AutoEnroller, its start runs it, and its one
+		// invocation is the pass that invites and enrolls boxes.
+		"newAutoEnroller": true, "runEnrollPass": true, "EnrollPass": true,
 	}
 	fs := token.NewFileSet()
 	var sites []string
````

`internal/console/testdata/claim-authority-sites.txt`:

````diff
--- a/internal/console/testdata/claim-authority-sites.txt
+++ b/internal/console/testdata/claim-authority-sites.txt
@@ -145,3 +145,6 @@
 claim_loss_pages.go|pageClaimLossesAt|s.store.db.QueryRowContext(ctx, `SELECT last_key FROM claim_loss_pages WHERE rule = 'scan'`)
 store_gate.go|OpenGated|s.db.Exec(`ALTER TABLE claim_loss_pages ADD COLUMN last_key TEXT NOT NULL DEFAULT ''`)
 awsregionmetric.go|capacityPublisherFromEnv|cloudwatch.NewFromConfig(cfg, func(o *cloudwatch.Options) { o.Region = awsRegion })
+enrollpass.go|startEnrollPass|newAutoEnroller(s.cfg.ClaimAuthority, members)
+enrollpass.go|startEnrollPass|runEnrollPass(ctx, e, enrollPassInterval, log.Printf)
+enrollpass.go|runEnrollPass|e.EnrollPass(ctx)
````

Run: `nice -n 19 go test ./internal/console/ -run TestClaimAuthoritySourceCensus -count=1`
Expected: PASS. With the selected names added and the sites not yet appended, it fails naming the three as "unreviewed authority use".

- [ ] **Step 7: The root-read census**

`AutoEnroller.inputs` is a new root-read unit (the roster and the host floor, in the console), ruled accepted-legacy; its `@legacy` fingerprint moves the seal.

`internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv`:

````diff
--- a/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv
+++ b/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv
@@ -2,6 +2,7 @@
 # Task 2: host.env from the deployment's parameters (internal/hostenv: hostenv.go, the layout, assembly and rendering, with an allowlist of names, shared values written create-only and the group-box marker added on write; cli.go, carlos system host-env write and seed; write_linux.go, the write through directories held by descriptor, and write_other.go, its refusal off Linux; awsssm.go, the SSM adapter) and the SSM SDK in go.mod. Three new root-read units. runHostEnv: seed's --from file. Ruled authority A,J4 on conditions (no box write to the parameters; allowlisted names, safe values, atomic write, checked parent; authority-selecting values cross-checked against the roster at the agent's use). writeHostEnv and checkedDir (Linux): write's fixed path, /etc/carlos/host.env, reached only through /etc and /etc/carlos opened O_NOFOLLOW|O_DIRECTORY and checked by fstat (root's, writable by no group or other), the file written 0600 in that directory, synced, renamed and the directory synced; ruled A,J4 under census-ruling-region-ha-2b, as the checked parent runHostEnv's condition asks for. The legacy seal stands.
 # Task 3: carlos system install-channel (internal/host installchannel.go, installchannel_linux.go): a fresh box installs its signed channel release into both protected slots through verifyPointer, hostfloor.CheckImage and stageCandidate, all unchanged, and on every run hands each directory on the agent's cache path (system, system/router and the version's) to the service user through bootstrap's own descriptor-anchored open, refusing a symlinked parent. One new root-read unit, channelInstall.run, whose one read site opens root's apply lock (systemApplier's lockPath). Ruled authority F,J4. stageCandidate, accepted-legacy, is called and not changed. The legacy seal stands.
 # Task 4: group membership, read by the console (internal/hostactors membershipread.go: MembershipReader, the unexported confirmation and the write bound, the single enforcement point of the 60-second deadline, each store call cut off at 15 seconds; enrollment_confirmation.go: EnrollAutomatic and CheckConfirmed make the read themselves and write on the bound store, and the box-written confirmation object is removed; groupref.go: GroupBoxReference; private.go: a group box's report read under the reference the console derives; awsmembership, the Auto Scaling and EC2 describe adapter) and the Auto Scaling SDK in go.mod. No root-read unit is added or changed: the read is the cloud's, through the reader. Envelope-only, and the seal stands. The record-write census gains deadlineStore.Put (nonrecord, ruled).
+# Task 5: the console's automatic enrollment pass (internal/hostactors autoenroll.go: AutoEnroller: an enrolling write the deadline left unknown is settled by the next pass's roster read; internal/console enrollpass.go, started in StartClaimRecoveryPass with region HA on, and awsmembership.go, its membership reader). One new root-read unit, AutoEnroller.inputs, whose two read sites are the signed roster (Directory.Read) and the host floor (hostfloor.Read), in the console. Ruled accepted-legacy (J4 root-input). Invite, BeginEnrollmentAttempt and checkMembership, accepted-legacy, are called and not changed. The seal moves.
 @coverage	.	3678cfe22582d35fe0d8e93d3fed898cdffa6fb2ce2d81e0252c8a8da77993b3
 @coverage	internal/hostenv	a3f3274a86cdf8194afb871301c1ba3289dfbdc9b703dd547178273df151cdaa
 internal/hostenv/cli.go	runHostEnv	authority	A,J4	rev3=census-ruling-region-ha-2b;basis=carlos system host-env: write runs as root at a box's start-up and writes /etc/carlos/host.env, 0600, whole (temporary file and rename), from allowlisted parameters with safe values only, refusing a missing required value; its one read site is seed's --from file, an operator's host.env whose values go only to the parameter store; write's path is fixed and reached only through writeHostEnv	file:ReadFile@1500	df4470176b229b311927ff0ac4f268d354c3a2357cac4300ac0992d2ebb90899
@@ -9,5 +10,8 @@
 internal/hostenv/write_linux.go	writeHostEnv	authority	A,J4	rev3=census-ruling-region-ha-2b;basis=carlos system host-env write (root, Linux): writes /etc/carlos/host.env only through /etc and /etc/carlos held by descriptor and checked by checkedDir; the new file is created 0600, O_EXCL|O_NOFOLLOW, in that directory, synced, renamed over host.env within it, and the directory synced; its read sites open the root and the temporary file	file:Open@68,file:Openat@509	1245fc0f819ba73fb45545ffdcf92d5d4941802175e73bac209ab4d4238adff1
 @coverage	internal/host	64ee4f561f2a28d584a6f7c82e068e57db4822f6cbb130116284bca583d2d390
 internal/host/installchannel.go	channelInstall.run	authority	F,J4	rev3=census-ruling-region-ha-2b;basis=carlos system install-channel (root, at a fresh box's start-up): its one read site opens root's apply lock; the install goes through verifyPointer, hostfloor.CheckImage and stageCandidate into both protected slots, and refuses a box whose guard is armed or that already runs another release	file:OpenFile@196	f0cdb6dd28e83da282c3b0d7bc2012dcd80db181a8cecbf1a2db6bcc41d1d5e3
-@coverage	internal/hostactors	f6dbcea46bed878257c5b34f8caeef83e71cc995cae7b3a4ae59339ed0740c68
+@coverage	internal/hostactors	a1088ca1b4e6bac902a9d2419c101181860381b7749596f2c3a9392894cee91d
 @coverage	internal/hostactors/awsmembership	a3defad7179245492a3c1dcfce5e20f0f85db2e240b474c654acf211da16df98
+@coverage	internal/console	80fa5bba8bd23325ababaedc2b8ce810b25161c93a81c6d97dd7033bcc5df103
+@legacy	2f4ca2a51661d8aae1e0f38a41efdcc9df9f1dd75411ca6742d6ee3ae71f1756	internal/hostactors/autoenroll.go	AutoEnroller.inputs	legacy	accepted-legacy	accepted-legacy;rev3=J4;basis=root-input;census-ruling-region-ha-2b	file:Read@102,file:Read@305	c0ae921ee9d8df9de403d488d9708c565d659a7bf880199313f50bdc7c8f8127
+internal/hostactors/autoenroll.go	AutoEnroller.inputs	legacy	accepted-legacy	accepted-legacy;rev3=J4;basis=root-input;census-ruling-region-ha-2b	file:Read@102,file:Read@305	c0ae921ee9d8df9de403d488d9708c565d659a7bf880199313f50bdc7c8f8127
````

`internal/provision/plan14_root_reads_test.go`:

````diff
--- a/internal/provision/plan14_root_reads_test.go
+++ b/internal/provision/plan14_root_reads_test.go
@@ -816,7 +816,7 @@
 	if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv"), &inv, true); err != nil {
 		t.Fatal(err)
 	}
-	seal = "8198674d8809ef90ecebac55324bfd75e69fb3d73bc27b942f29ca2f43e3fe8d"
+	seal = "8afff5c9874f6d31079ffb2e722274b0b1f82eef7ae228e3c816245c239a93e1"
 	errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...)
 	errs = append(errs, checkRootReadDeclarations(got, inv)...)
 	if len(errs) > 0 {
````

Run: `nice -n 19 go test ./internal/provision/ -run 'TestPlan9B8RecordWriteCensus|TestPlan14RootReadCensus$' -count=1 && nice -n 19 go test ./internal/console/ -run TestClaimAuthoritySourceCensus -count=1`
Expected: PASS.

- [ ] **Step 8: Commit**

```bash
git add internal/hostactors/autoenroll.go internal/hostactors/autoenroll_test.go internal/console/enrollpass.go internal/console/enrollpass_test.go internal/console/awsmembership.go internal/console/hostclaimops.go internal/console/claim_authority_census_test.go internal/console/testdata/claim-authority-sites.txt region_ha_boundary_test.go internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv internal/provision/plan14_root_reads_test.go
git commit -m "region HA 2b: the console's automatic enrolment pass"
```

---

### Task 6: Mode selection and group references

**Files:**
- Create: `internal/host/joinmode.go`, `internal/host/joinmode_test.go`, `internal/host/boxreference.go`, `internal/host/boxreference_test.go`, `internal/status/fleet_listing_test.go`
- Modify: `internal/host/metrics_loop.go` (`metricsBoxLabel`), `internal/host/boxrecord.go` (`boxRecordLoop`), `internal/host/agent.go` (`statusLoop`, under census ruling region-ha-2b), `internal/status/fleet.go` (`loadBoxesReport`)
- Modify: `region_ha_boundary_test.go`, the overlay, `internal/provision/plan14_root_reads_test.go` (the seal)

**Interfaces:**
- Consumes: `hostfloor.ReadEnrollment`, `hostfloor.Disk.Running` (through `floorDisk`), `readGuardRoster`, `openDeployStoreEnv`, `openStatusStore`, `metricsBoxLabel`, `readMachineID`, `readIdentityRecord` (unchanged), `boxid.Load`, `status.Box`; `hostactors.GroupBoxReference` (Task 4); `hostenv.BoxReference` and `GroupReference` (Task 2).
- Produces: `agentMode` (`modeHosting`, `modeJoining`); `joinStage` (`stageNoInvitation`, `stageInvited`, `stageGuardInstalled`, `stageGuardReady`, `stageHeld`); `modeDecision{Mode, Stage, Box, Why}`; `errModeUnknown`; `modeFacts{box; enrollment; roster; guard; invited; report; relaunch; now}`; `selectMode(ctx, modeFacts) (modeDecision, error)`; `guardReadyReport`; `relaunched(row hostactors.Box, enrolled []byte, current boxid.Record) string`; `recordPendingTime`; `productionModeFacts(ctx, getenv) (modeFacts, error)`; `invitationKey(box string) string`; `const hostActorPath`, `reportFreshness`; `var agentHosting atomic.Bool` (set by Task 8's gate, read by its claim wiring); `crossCheck(modeFacts, hostactors.Box) string`; `groupReferenced(getenv) bool`, `groupBoxReference() string`, `groupStatusTick(ctx, store, reg, probes, publish, label, getenv) error`, `errNoGroupReference`, `var groupIdentity` (the IMDS read, a seam); in `internal/status`, `statusListingHorizon = 24h` and `listBoxKeys`.

The decision every agent start makes (A2-4), as a pure function over its facts, so each joining state and each unreadable fact can be tested without a box. It hosts only when `host-actor.json` names this box, the roster lists that enrollment and instance as active, and the guard is armed under the roster's floor for this binary. Anything else is joining, at the stage the facts show: no invitation; invited (also when guard-arm wrote `host-actor.json` but was cut off before it armed); guard installed; guard-ready reported (a fresh report of this enrollment and image is published); or held (the roster names the box, but not as an active entry for its enrollment and armed guard). A fact that cannot be read is `errModeUnknown`: a roster that fails to read is never "not enrolled".

**A group box started again (C3).** A box its region's group launched (its roster entry names a `Group`) hosts only on the launch it was enrolled on. Without that rule a stopped instance that is started again finds `host-actor.json`, its armed guard and its active entry all intact, and would host on a stale enrolment. EC2 sets the identity document's `pendingTime` each time the instance enters `pending`: at launch and at every start, never at a reboot (Open question 7, an activation gate). So the hosting decision reads this instance's identity document from IMDS and the record published under its reference, requires that record to be the one the roster entry binds (by digest), and holds the box when the two `pendingTime`s differ. A held group box answers 503 and its group replaces it; it cannot prove membership again under the same reference while its entry stands. An unreadable launch is unknown. A box an operator enrolled (no `Group`) is not asked: a fixed box is stopped and started for maintenance.

**Authority-selecting values, cross-checked (census condition 3).** host.env's group marker and `CARLOS_REGION` come from the parameter store. Before a box hosts, `crossCheck` holds it when the marker disagrees with the roster entry (set for a box enrolled by hand, or missing for one enrolled from a group), or when a group box's `CARLOS_REGION` is not the region the roster enrolled it in. This is the check's place: at `host-env write` the box has no roster entry yet, and before enrolment neither value grants anything (the console derives a joining box's reference and region from its proven instance). A box an operator enrolled keeps its own `CARLOS_REGION`, unchecked, as on main.

**Group references (S3, census ruling region-ha-2b).** A box whose host.env says `CARLOS_BOX_REFERENCE=group` (Task 2's `write` adds it) names itself by `GroupBoxReference` of the account and instance in its signed identity document, read from IMDS and kept once read (`groupBoxReference`); a failed read is asked again next time. `metricsBoxLabel` answers it, so every caller that names this box (the identity record, mode selection, root's join, the edges file, metrics, layout and rollout) agrees. An identity IMDS has not given is `""`, never a label in its place: `productionModeFacts` treats it as unknown, and `boxRecordLoop` asks again each minute.

**Status publication (T1, platform-1e's condition).** `statusLoop`, accepted-legacy, changes in one call, as ruled: its tick is `groupStatusTick`. A box with the group marker publishes its status report only under its group reference, the key the console derives (Task 4's `PrivateBoxReport`), resolved afresh on every tick until it resolves; until then it publishes nothing at all, and logs why. It never falls back to `status.Label`, which may be another box's key: `WriteBox` replaces whatever is under it. That holds whatever the claim and region HA switches say, because the agent's gate does not decide it. Every other box, fleet boxes included, gets `statusTick` with today's label, so its key is unchanged byte for byte, and the public `status/fleet.json` (main's `BuildFleet`, unchanged) shows a group box only by its pseudonym. The joining report (Task 8) is written under the same reference.

**The status listing (T2, Astra round 3 #2).** Every replaced group box leaves a report under its own reference, so `status/boxes/` grows with every replacement, and `loadBoxesReport` cut the whole listing at 200 keys before it looked at any: past 200, current boxes could vanish and completeness stay false, which turns off non-home eligibility (`regionCandidates`) and report-dependent retention. Now it lists the prefix with each object's write time where the store gives one (`release.ModLister`, which `release.S3Store` and `release.DirStore` both implement) and drops reports no box has rewritten in a day (`statusListingHorizon`) before any read and before the cap, so the cap bounds current reports only. This is an optimisation, never authority: every reader still judges a report's own `GeneratedAt` exactly as before (`BoxStale`, `VerifyReport`). A day, not `BoxStale`'s 15 minutes, because the console's host table shows a box that stopped reporting as stale, and an operator should still see one that died an hour ago. A store with no write times (a fleet's prefix store) is listed whole, as before. 2c adds a 30-day expiry on `status/boxes/` as hygiene; nothing depends on it.

- [ ] **Step 1: Write the failing tests**

`internal/host/joinmode_test.go`:

````go
package host

import (
	"context"
	"encoding/base64"
	"encoding/json"
	"errors"
	"io/fs"
	"os"
	"strings"
	"testing"
	"time"

	"github.com/carlosframework/platform/internal/boxid"
	"github.com/carlosframework/platform/internal/hostactors"
	"github.com/carlosframework/platform/internal/hostfloor"
	"github.com/carlosframework/platform/internal/status"
)

var joinNow = time.Date(2026, 10, 17, 12, 0, 0, 0, time.UTC)

// joinFacts is a box, "ada-hiking", in each state the tests name. The
// zero value has no host-actor.json and no invitation.
type joinFacts struct {
	enrollment    *hostfloor.Enrollment
	enrollmentErr error
	roster        hostactors.Roster
	rosterErr     error
	guard         hostfloor.Running
	guardErr      error
	invited       bool
	invitedErr    error
	report        *status.Box
	reportErr     error
	relaunchWhy   string
	relaunchErr   error
	relaunchAsked *bool
	marked        bool
	region        string
}

func (j joinFacts) facts() modeFacts {
	return modeFacts{
		box: "ada-hiking",
		enrollment: func() (hostfloor.Enrollment, error) {
			if j.enrollmentErr != nil {
				return hostfloor.Enrollment{}, j.enrollmentErr
			}
			if j.enrollment == nil {
				return hostfloor.Enrollment{}, &fs.PathError{Op: "open", Path: hostActorPath, Err: fs.ErrNotExist}
			}
			return *j.enrollment, nil
		},
		roster: func(context.Context) (hostactors.Roster, error) { return j.roster, j.rosterErr },
		guard: func() (hostfloor.Running, error) {
			if j.guardErr != nil {
				return hostfloor.Running{}, j.guardErr
			}
			return j.guard, nil
		},
		invited: func(context.Context, string) (bool, error) { return j.invited, j.invitedErr },
		report: func(context.Context, string) (status.Box, error) {
			if j.reportErr != nil {
				return status.Box{}, j.reportErr
			}
			if j.report == nil {
				return status.Box{}, os.ErrNotExist
			}
			return *j.report, nil
		},
		relaunch: func(context.Context, hostactors.Box) (string, error) {
			if j.relaunchAsked != nil {
				*j.relaunchAsked = true
			}
			return j.relaunchWhy, j.relaunchErr
		},
		marked: j.marked,
		region: j.region,
		now:    func() time.Time { return joinNow },
	}
}

const joinEnrollment = "0123456789abcdef0123456789abcdef"

func enrolledFacts() joinFacts {
	e := hostfloor.Enrollment{Box: "ada-hiking", Enrollment: joinEnrollment, InstanceID: "i-ada"}
	return joinFacts{
		enrollment: &e,
		roster: hostactors.Roster{FloorSHA256: "floor", Boxes: map[string]hostactors.Box{"ada-hiking": {
			Enrollment: joinEnrollment, State: "active", Cloud: hostactors.Cloud{InstanceID: "i-ada"}}}},
		guard: hostfloor.Running{GuardReady: true, FloorSHA256: "floor", Hash: "image"},
	}
}

// guardInstalled is a box that guard-arm has armed but the console has not
// enrolled yet.
func guardInstalled() joinFacts {
	f := enrolledFacts()
	f.roster.Boxes = map[string]hostactors.Box{}
	return f
}

func freshReport(at time.Time) *status.Box {
	return &status.Box{GeneratedAt: at, Metrics: &status.BoxMetrics{Enrollment: joinEnrollment, FloorGuardReady: true, FloorSHA256: "floor", BinarySHA256: "image"}}
}

func TestSelectModeHostsOnlyWhenEnrolledAndArmed(t *testing.T) {
	d, err := selectMode(context.Background(), enrolledFacts().facts())
	if err != nil || d.Mode != modeHosting || d.Box != "ada-hiking" {
		t.Fatalf("enrolled and armed: %+v %v", d, err)
	}
	held := map[string]func(*joinFacts){
		"the guard is not armed":         func(f *joinFacts) { f.guardErr = hostfloor.ErrFloor },
		"the guard has no policy":        func(f *joinFacts) { f.guardErr = &fs.PathError{Op: "open", Path: "policy.json", Err: fs.ErrNotExist} },
		"the guard is for another floor": func(f *joinFacts) { f.guard.FloorSHA256 = "old" },
		"the roster entry is quarantined": func(f *joinFacts) {
			b := f.roster.Boxes["ada-hiking"]
			b.State = "quarantined"
			f.roster.Boxes["ada-hiking"] = b
		},
		"the roster entry is another enrollment": func(f *joinFacts) {
			b := f.roster.Boxes["ada-hiking"]
			b.Enrollment = strings.Repeat("f", 32)
			f.roster.Boxes["ada-hiking"] = b
		},
		"the roster entry is another instance": func(f *joinFacts) {
			b := f.roster.Boxes["ada-hiking"]
			b.Cloud.InstanceID = "i-old"
			f.roster.Boxes["ada-hiking"] = b
		},
		"host-actor.json names another box": func(f *joinFacts) { f.enrollment.Box = "grace-hiking" },
	}
	for name, spoil := range held {
		t.Run(name, func(t *testing.T) {
			f := enrolledFacts()
			spoil(&f)
			d, err := selectMode(context.Background(), f.facts())
			if err != nil || d.Mode != modeJoining || d.Stage != stageHeld || d.Why == "" {
				t.Fatalf("%+v %v", d, err)
			}
		})
	}
}

// A restart at each joining state resumes joining at that state (spec 2
// §11): nothing but an active roster entry with an armed guard hosts.
func TestSelectModeResumesEachJoiningStage(t *testing.T) {
	for _, c := range []struct {
		name  string
		facts joinFacts
		want  joinStage
	}{
		{"no invitation", joinFacts{}, stageNoInvitation},
		{"invited", joinFacts{invited: true}, stageInvited},
		{"guard-arm cut off after host-actor.json", func() joinFacts { f := guardInstalled(); f.guardErr = hostfloor.ErrFloor; return f }(), stageInvited},
		{"guard installed", guardInstalled(), stageGuardInstalled},
		{"guard-ready reported", func() joinFacts { f := guardInstalled(); f.report = freshReport(joinNow.Add(-time.Minute)); return f }(), stageGuardReady},
		{"a stale report", func() joinFacts {
			f := guardInstalled()
			f.report = freshReport(joinNow.Add(-16 * time.Minute))
			return f
		}(), stageGuardInstalled},
		{"a report for another image", func() joinFacts {
			f := guardInstalled()
			f.report = freshReport(joinNow)
			f.report.Metrics.BinarySHA256 = "bootstrap"
			return f
		}(), stageGuardInstalled},
	} {
		t.Run(c.name, func(t *testing.T) {
			d, err := selectMode(context.Background(), c.facts.facts())
			if err != nil || d.Mode != modeJoining || d.Stage != c.want {
				t.Fatalf("%+v %v, want joining at %s", d, err, c.want)
			}
		})
	}
}

// A fact that cannot be read is unknown, never its negative: an enrolled
// box whose roster read fails is not sent back to joining.
func TestSelectModeIsUnknownWhenAFactCannotBeRead(t *testing.T) {
	outage := errors.New("connection reset")
	for name, f := range map[string]joinFacts{
		"the invitation":                {invitedErr: outage},
		"host-actor.json":               {enrollmentErr: hostfloor.ErrFloor},
		"the roster":                    func() joinFacts { f := enrolledFacts(); f.rosterErr = outage; return f }(),
		"the guard":                     func() joinFacts { f := enrolledFacts(); f.guardErr = errors.New("permission denied"); return f }(),
		"the status report":             func() joinFacts { f := guardInstalled(); f.reportErr = outage; return f }(),
		"a roster that does not verify": func() joinFacts { f := guardInstalled(); f.rosterErr = hostactors.ErrRoster; return f }(),
	} {
		t.Run(name, func(t *testing.T) {
			d, err := selectMode(context.Background(), f.facts())
			if !errors.Is(err, errModeUnknown) || d.Mode == modeHosting {
				t.Fatalf("%+v %v", d, err)
			}
		})
	}
}

// C3: a box its region's group launched hosts only on the launch it was
// enrolled on. Stopped and started since, it is held, never hosting on its
// old enrollment; a launch that cannot be read is unknown. A box an
// operator enrolled (no group) is not asked.
func TestSelectModeHoldsAGroupBoxStartedAgain(t *testing.T) {
	grouped := func() joinFacts {
		f := enrolledFacts()
		b := f.roster.Boxes["ada-hiking"]
		b.Group, b.Region = "routers-ie", "ie"
		f.roster.Boxes["ada-hiking"] = b
		f.relaunchAsked = new(bool)
		f.marked, f.region = true, "ie"
		return f
	}
	f := grouped()
	if d, err := selectMode(context.Background(), f.facts()); err != nil || d.Mode != modeHosting || !*f.relaunchAsked {
		t.Fatalf("same launch: %+v %v", d, err)
	}
	f = grouped()
	f.relaunchWhy = "this instance was stopped and started since it was enrolled"
	if d, err := selectMode(context.Background(), f.facts()); err != nil || d.Mode != modeJoining || d.Stage != stageHeld || d.Why != f.relaunchWhy {
		t.Fatalf("started again: %+v %v", d, err)
	}
	f = grouped()
	f.relaunchErr = errors.New("IMDS answered 500")
	if d, err := selectMode(context.Background(), f.facts()); !errors.Is(err, errModeUnknown) || d.Mode == modeHosting {
		t.Fatalf("unreadable launch: %+v %v", d, err)
	}
	f = enrolledFacts()
	f.relaunchWhy, f.relaunchAsked = "never asked", new(bool)
	if d, err := selectMode(context.Background(), f.facts()); err != nil || d.Mode != modeHosting || *f.relaunchAsked {
		t.Fatalf("an operator-enrolled box: %+v %v, asked %v", d, err, *f.relaunchAsked)
	}
}

func TestRelaunchedComparesTheLaunchTheRosterBinds(t *testing.T) {
	record := func(pending string) (boxid.Record, []byte) {
		doc := `{"accountId":"123","region":"eu-west-1","instanceId":"i-ada"` + pending + `}`
		rec := boxid.Record{V: 1, Box: "i-ada", IdentityDocument: base64.StdEncoding.EncodeToString([]byte(doc)), IdentitySignature: "c2ln"}
		raw, _ := json.Marshal(rec)
		return rec, raw
	}
	enrolledRec, enrolled := record(`,"pendingTime":"2026-10-17T09:00:00Z"`)
	row := hostactors.Box{EvidenceSHA256: hostfloor.Digest(enrolled), Group: "routers-ie"}
	if why := relaunched(row, enrolled, enrolledRec); why != "" {
		t.Fatalf("the same launch: %q", why)
	}
	again, _ := record(`,"pendingTime":"2026-10-17T11:30:00Z"`)
	if why := relaunched(row, enrolled, again); !strings.Contains(why, "stopped and started") {
		t.Fatalf("started again: %q", why)
	}
	none, _ := record("")
	if why := relaunched(row, enrolled, none); why == "" {
		t.Fatal("a document with no pendingTime matched")
	}
	_, other := record(`,"pendingTime":"2026-10-17T09:00:00Z","x":1`)
	if why := relaunched(row, other, enrolledRec); !strings.Contains(why, "not the one it was enrolled on") {
		t.Fatalf("another record: %q", why)
	}
}

// Condition 3 of census ruling region-ha-2b: the group marker and a group
// box's CARLOS_REGION come from the parameter store, and a value that
// disagrees with the signed roster holds the box instead of hosting it.
func TestSelectModeHoldsAStoreValueTheRosterDisagreesWith(t *testing.T) {
	grouped := func() joinFacts {
		f := enrolledFacts()
		b := f.roster.Boxes["ada-hiking"]
		b.Group, b.Region = "routers-ie", "ie"
		f.roster.Boxes["ada-hiking"] = b
		f.marked, f.region = true, "ie"
		return f
	}
	if d, err := selectMode(context.Background(), grouped().facts()); err != nil || d.Mode != modeHosting {
		t.Fatalf("agreeing: %+v %v", d, err)
	}
	for name, spoil := range map[string]func(*joinFacts){
		"marked, enrolled by hand": func(f *joinFacts) {
			b := f.roster.Boxes["ada-hiking"]
			b.Group = ""
			f.roster.Boxes["ada-hiking"] = b
		},
		"enrolled from a group, not marked": func(f *joinFacts) { f.marked = false },
		"another region":                    func(f *joinFacts) { f.region = "uk" },
	} {
		t.Run(name, func(t *testing.T) {
			f := grouped()
			spoil(&f)
			d, err := selectMode(context.Background(), f.facts())
			if err != nil || d.Mode != modeJoining || d.Stage != stageHeld || !strings.Contains(d.Why, "does not host") {
				t.Fatalf("%+v %v", d, err)
			}
		})
	}
	// A box an operator enrolled keeps its own region, unchecked.
	f := enrolledFacts()
	f.region = "anything"
	if d, err := selectMode(context.Background(), f.facts()); err != nil || d.Mode != modeHosting {
		t.Fatalf("an operator-enrolled box: %+v %v", d, err)
	}
}
````

`internal/host/boxreference_test.go`:

````go
package host

import (
	"bytes"
	"context"
	"errors"
	"path/filepath"
	"reflect"
	"testing"

	"github.com/carlosframework/platform/internal/hostactors"
	"github.com/carlosframework/platform/internal/hostenv"
	"github.com/carlosframework/platform/internal/registry"
	"github.com/carlosframework/platform/internal/release"
	"github.com/carlosframework/platform/internal/status"
)

// withGroupIdentity makes IMDS answer account and instance (or fail) for
// one test, with no reference kept from before.
func withGroupIdentity(t *testing.T, account, instance string, err error) {
	t.Helper()
	was := groupIdentity
	groupIdentity = func() (string, string, error) { return account, instance, err }
	groupRef.Lock()
	groupRef.ref = ""
	groupRef.Unlock()
	t.Cleanup(func() {
		groupIdentity = was
		groupRef.Lock()
		groupRef.ref = ""
		groupRef.Unlock()
	})
}

func groupEnv(vals map[string]string) func(string) string {
	return func(k string) string { return vals[k] }
}

// A box its region's group launched takes its group reference everywhere
// it names itself: the identity record, mode selection, root's join. An
// identity IMDS has not given is no reference, never a label in its place.
func TestAGroupBoxTakesItsGroupReference(t *testing.T) {
	group := groupEnv(map[string]string{hostenv.BoxReference: hostenv.GroupReference})
	withGroupIdentity(t, "", "", errors.New("IMDS answered 500"))
	if got := metricsBoxLabel(group, "machine"); got != "" {
		t.Fatalf("no identity: %q", got)
	}
	withGroupIdentity(t, "123456789012", "i-0a61f580df1acfa65", nil)
	want := hostactors.GroupBoxReference("123456789012", "i-0a61f580df1acfa65")
	if got := metricsBoxLabel(group, "machine"); got != want {
		t.Fatalf("group box: %q, want %q", got, want)
	}
}

// statusFixture is a registry with one route and a status store holding
// another box's report under the label this box's machine id hashes to.
func statusFixture(t *testing.T) (*registry.Registry, *release.DirStore, []byte) {
	t.Helper()
	reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
	if err != nil {
		t.Fatal(err)
	}
	if err := reg.Upsert(registry.Route{Host: "a.example.com", Kind: "static"}); err != nil {
		t.Fatal(err)
	}
	s := release.NewDirStore(t.TempDir())
	victim := []byte(`{"label":"victim","metrics":{"instance_id":"i-victim"}}`)
	if _, err := s.Put(context.Background(), "status/boxes/"+status.Label("machine-1")+".json", victim, ""); err != nil {
		t.Fatal(err)
	}
	return reg, s, victim
}

// T1 (census ruling region-ha-2b): a group box whose reference has not
// resolved publishes nothing, whatever the claim and region HA switches
// say; above all never under status.Label, where another box's report may
// stand. The reference is asked again each tick, and once it resolves the
// box publishes under it. Through statusLoop's own tick and WriteBox.
func TestAGroupBoxPublishesOnlyUnderItsReference(t *testing.T) {
	for name, env := range map[string]map[string]string{
		"switches on":  {hostenv.BoxReference: hostenv.GroupReference, "CARLOS_HOST_CLAIM": "1", "CARLOS_REGION_HA": "1"},
		"switches off": {hostenv.BoxReference: hostenv.GroupReference},
	} {
		t.Run(name, func(t *testing.T) {
			ctx := context.Background()
			reg, s, victim := statusFixture(t)
			withGroupIdentity(t, "", "", errors.New("IMDS answered 500"))
			asked := 0
			groupIdentity = func() (string, string, error) {
				asked++
				if asked < 3 {
					return "", "", errors.New("IMDS answered 500")
				}
				return "123456789012", "i-0a61f580df1acfa65", nil
			}
			probes := status.Probes{MachineID: "machine-1"}
			for i := 0; i < 2; i++ {
				if err := groupStatusTick(ctx, s, reg, probes, false, "", groupEnv(env)); !errors.Is(err, errNoGroupReference) {
					t.Fatalf("tick %d: %v", i, err)
				}
			}
			keys, _ := s.List(ctx, "status/boxes/")
			got, _, _ := s.Get(ctx, "status/boxes/"+status.Label("machine-1")+".json")
			if len(keys) != 1 || !bytes.Equal(got, victim) {
				t.Fatalf("published with no reference: %v, victim now %s", keys, got)
			}
			if err := groupStatusTick(ctx, s, reg, probes, false, "", groupEnv(env)); err != nil {
				t.Fatal(err)
			}
			ref := hostactors.GroupBoxReference("123456789012", "i-0a61f580df1acfa65")
			if _, _, err := s.Get(ctx, "status/boxes/"+ref+".json"); err != nil || asked != 3 {
				t.Fatalf("after recovery: %v, IMDS asked %d times", err, asked)
			}
			if got, _, _ := s.Get(ctx, "status/boxes/"+status.Label("machine-1")+".json"); !bytes.Equal(got, victim) {
				t.Fatalf("the victim's report was replaced: %s", got)
			}
		})
	}
}

// The ruling's third test: a box that is not a group box keeps its label
// and its status key byte for byte: statusLoop's tick is statusTick's, with
// statusLoop's own label (CollectBox's pseudonym, or a fleet box's label).
func TestABoxThatIsNotAGroupBoxKeepsItsLabelAndKey(t *testing.T) {
	withGroupIdentity(t, "123456789012", "i-0a61f580df1acfa65", nil)
	for name, env := range map[string]map[string]string{
		"no marker":            {},
		"another value":        {hostenv.BoxReference: "instance-id"},
		"region HA, no marker": {"CARLOS_REGION_HA": "1", "CARLOS_HOST_CLAIM": "1"},
	} {
		getenv := groupEnv(env)
		if got := metricsBoxLabel(getenv, "machine-1"); got != status.Label("machine-1") {
			t.Fatalf("%s: label %q, want %q", name, got, status.Label("machine-1"))
		}
		for _, label := range []string{"", "kitchen"} {
			ctx := context.Background()
			reg, _, _ := statusFixture(t)
			mine, theirs := release.NewDirStore(t.TempDir()), release.NewDirStore(t.TempDir())
			probes := status.Probes{MachineID: "machine-1"}
			if err := groupStatusTick(ctx, mine, reg, probes, false, label, getenv); err != nil {
				t.Fatal(err)
			}
			if err := statusTick(ctx, theirs, reg, probes, false, label); err != nil {
				t.Fatal(err)
			}
			a, _ := mine.List(ctx, "status/boxes/")
			b, _ := theirs.List(ctx, "status/boxes/")
			if !reflect.DeepEqual(a, b) || len(a) != 1 {
				t.Fatalf("%s, label %q: keys %v, statusTick's %v", name, label, a, b)
			}
		}
	}
	fleet := groupEnv(map[string]string{"CARLOS_FLEET": "pi", "CARLOS_FLEET_BOX": "kitchen"})
	if got := metricsBoxLabel(fleet, "machine-1"); got != "kitchen" {
		t.Fatalf("fleet box: %q", got)
	}
}
````

`internal/status/fleet_listing_test.go`:

````go
package status

import (
	"context"
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
	"testing"
	"time"

	"github.com/carlosframework/platform/internal/release"
)

// listOnly hides the store's ModLister, as a store that cannot say when an
// object was written does.
type listOnly struct{ release.Store }

// T2 (region HA plan 2b): 250 reports of boxes a group replaced long ago
// do not hide the two boxes running now. The old reports are dropped from
// the listing before any read and before the key cap, and the reading is
// complete.
func TestStaleReportsDoNotHideLiveBoxes(t *testing.T) {
	ctx := context.Background()
	root := t.TempDir()
	s := release.NewDirStore(root)
	now := time.Now()
	put := func(label string, at time.Time) {
		raw, _ := json.Marshal(Box{Label: label, GeneratedAt: at})
		key := "status/boxes/" + label + ".json"
		if _, err := s.Put(ctx, key, raw, ""); err != nil {
			t.Fatal(err)
		}
		if err := os.Chtimes(filepath.Join(root, filepath.FromSlash(key)), at, at); err != nil {
			t.Fatal(err)
		}
	}
	for i := 0; i < 250; i++ {
		put(fmt.Sprintf("g-%020d", i), now.Add(-48*time.Hour))
	}
	put("g-live-one", now.Add(-time.Minute))
	put("g-live-two", now.Add(-2*time.Minute))
	// A report written an hour ago that says it is old: still listed (the
	// listing only drops what is plainly history) and judged stale by its
	// readers, as before.
	put("g-quiet", now.Add(-time.Hour))

	boxes, complete, err := LoadBoxesComplete(ctx, s)
	if err != nil || !complete {
		t.Fatalf("complete %v, err %v", complete, err)
	}
	var labels []string
	for _, b := range boxes {
		labels = append(labels, b.Label)
	}
	if fmt.Sprint(labels) != "[g-live-one g-live-two g-quiet]" {
		t.Fatalf("boxes %v", labels)
	}
	if !BoxStale(boxes[2], now, SnapshotPeriod) {
		t.Fatal("an old report read as fresh")
	}
	// A store with no write times is listed whole, as before: the cap
	// applies to all 253, and the reading says it is incomplete.
	if _, complete, err := LoadBoxesComplete(ctx, listOnly{s}); err != nil || complete {
		t.Fatalf("listed whole: complete %v, err %v", complete, err)
	}
}
````

- [ ] **Step 2: Run them to see them fail**

Run: `nice -n 19 go test ./internal/host/ -run 'TestSelectMode|TestRelaunched|TestAGroupBox|TestABoxThatIsNot' -count=1 && nice -n 19 go test ./internal/status/ -run TestStaleReportsDoNotHideLiveBoxes -count=1`
Expected: FAIL, the host package does not build (`undefined: modeFacts`, `undefined: groupIdentity`), and the status test finds the live boxes missing.

- [ ] **Step 3: Write the selection and the reference**

`internal/host/joinmode.go`:

````go
package host

import (
	"context"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"os"
	"runtime"
	"strings"
	"sync/atomic"
	"time"

	"github.com/carlosframework/platform/internal/boxid"
	"github.com/carlosframework/platform/internal/hostactors"
	"github.com/carlosframework/platform/internal/hostfloor"
	"github.com/carlosframework/platform/internal/release"
	"github.com/carlosframework/platform/internal/status"
	"github.com/carlosframework/platform/internal/version"
)

// Joining and hosting (region HA spec 2 §5, A1-2, A2-4).
//
// With CARLOS_HOST_CLAIM and CARLOS_REGION_HA both on, the agent chooses
// its mode at every start, from three facts and never from one file's
// presence. It hosts only when all three hold:
//
//   - /etc/carlos/host-actor.json names this box;
//   - the signed roster has an active entry for that enrollment and
//     instance;
//   - the host lifecycle guard is armed under the roster's floor, with
//     this process's own binary one it admits.
//
// Anything else is joining. A joining box hosts nothing, publishes no
// edges file, and answers the load balancer 503 (joining.go). Joining
// resumes from whatever it finds, and each stage names the next step:
//
//   - no invitation: the console has not invited the box yet;
//   - invited: root arms the guard (carlos system join, joinroot.go);
//   - guard installed: the box publishes its guard-ready report;
//   - guard-ready reported: the console enrolls the box;
//   - held: the roster names this box, but not as an active entry for its
//     enrollment and armed guard, or, for a box its region's group
//     launched, the instance was stopped and started since the identity
//     record it was enrolled on (C3: it must prove its group membership
//     again, and never hosts on its old enrollment). Nothing automatic
//     can fix that: a held group box answers 503 and the group replaces
//     it; an operator sorts out any other.
//
// A fact that cannot be read is never taken as its negative: the mode is
// then unknown (errModeUnknown), and the caller asks again later.

type agentMode string

const (
	modeHosting agentMode = "hosting"
	modeJoining agentMode = "joining"
)

type joinStage string

const (
	stageNoInvitation   joinStage = "no invitation"
	stageInvited        joinStage = "invited"
	stageGuardInstalled joinStage = "guard installed"
	stageGuardReady     joinStage = "guard-ready reported"
	stageHeld           joinStage = "held"
)

// modeDecision is one start's answer. Stage and Why are joining's.
type modeDecision struct {
	Mode  agentMode
	Stage joinStage
	Box   string
	Why   string
}

// errModeUnknown is a fact that could not be read: no mode is chosen.
var errModeUnknown = errors.New("agent mode unknown")

func modeUnknown(what string, err error) error {
	return fmt.Errorf("%w: %s could not be read: %v", errModeUnknown, what, err)
}

// reportFreshness is how old a guard-ready report may be and still count:
// the console's own limit (hostactors.VerifyReport).
const reportFreshness = 15 * time.Minute

// hostActorPath is the box's enrollment, written by guard-arm.
const hostActorPath = "/etc/carlos/host-actor.json"

// modeFacts are where selectMode reads from; production's are
// productionModeFacts.
type modeFacts struct {
	// box is this box's reference: the label its identity record and its
	// status report are published under.
	box string
	// enrollment reads host-actor.json; os.ErrNotExist when there is none.
	enrollment func() (hostfloor.Enrollment, error)
	// roster reads the signed roster.
	roster func(context.Context) (hostactors.Roster, error)
	// guard is the guard's view of this process (hostfloor.Disk.Running).
	// hostfloor.ErrFloor or os.ErrNotExist: not armed, or not for this
	// binary.
	guard func() (hostfloor.Running, error)
	// invited reports whether the console has written box's invitation.
	invited func(context.Context, string) (bool, error)
	// report reads box's own published status report; os.ErrNotExist when
	// there is none.
	report func(context.Context, string) (status.Box, error)
	// relaunch says why this instance is not the launch the roster entry
	// row was enrolled on ("" when it is): asked only of a box its
	// region's group launched (row.Group set).
	relaunch func(context.Context, hostactors.Box) (string, error)
	// marked is host.env's group marker (CARLOS_BOX_REFERENCE=group), and
	// region its CARLOS_REGION: values from the parameter store, which
	// select this box's authority, so they are checked against the signed
	// roster before the box hosts (crossCheck).
	marked bool
	region string
	now    func() time.Time
}

// selectMode chooses this start's mode.
func selectMode(ctx context.Context, f modeFacts) (modeDecision, error) {
	d := modeDecision{Mode: modeJoining, Box: f.box}
	e, err := f.enrollment()
	if errors.Is(err, os.ErrNotExist) {
		invited, err := f.invited(ctx, f.box)
		if err != nil {
			return d, modeUnknown("the invitation", err)
		}
		d.Stage = stageNoInvitation
		if invited {
			d.Stage = stageInvited
		}
		return d, nil
	}
	if err != nil {
		return d, modeUnknown(hostActorPath, err)
	}
	if e.Box != f.box {
		d.Stage, d.Why = stageHeld, fmt.Sprintf("%s names %s, not this box (%s)", hostActorPath, e.Box, f.box)
		return d, nil
	}
	r, err := f.roster(ctx)
	if err != nil {
		return d, modeUnknown("the roster", err)
	}
	g, err := f.guard()
	if err != nil && !errors.Is(err, hostfloor.ErrFloor) && !errors.Is(err, os.ErrNotExist) {
		return d, modeUnknown("the host lifecycle guard", err)
	}
	armed := err == nil && g.GuardReady && g.FloorSHA256 == r.FloorSHA256
	row, listed := r.Boxes[e.Box]
	switch {
	case listed && row.Enrollment == e.Enrollment && row.Cloud.InstanceID == e.InstanceID && row.State == "active" && armed:
		if why := crossCheck(f, row); why != "" {
			d.Stage, d.Why = stageHeld, why
			return d, nil
		}
		if row.Group != "" {
			why, err := f.relaunch(ctx, row)
			if err != nil {
				return d, modeUnknown("this instance's launch", err)
			}
			if why != "" {
				d.Stage, d.Why = stageHeld, why
				return d, nil
			}
		}
		d.Mode, d.Stage = modeHosting, ""
	case listed:
		d.Stage = stageHeld
		d.Why = fmt.Sprintf("the roster's entry for %s is %s, enrollment %s on %s; this box holds enrollment %s on %s, guard armed %t",
			e.Box, row.State, row.Enrollment, row.Cloud.InstanceID, e.Enrollment, e.InstanceID, armed)
	case !armed:
		// guard-arm writes host-actor.json before it persists the armed
		// policy: an arm that was cut off is still the invited stage.
		d.Stage = stageInvited
	default:
		rep, err := f.report(ctx, f.box)
		if err != nil && !errors.Is(err, os.ErrNotExist) {
			return d, modeUnknown("this box's status report", err)
		}
		d.Stage = stageGuardInstalled
		if err == nil && guardReadyReport(rep, e, g, f.now()) {
			d.Stage = stageGuardReady
		}
	}
	return d, nil
}

// crossCheck says why host.env's authority-selecting values disagree with
// the roster entry the box would host under ("" when they agree). The
// group marker must match whether the console enrolled the box from a
// group, and a group box's CARLOS_REGION must name the region it was
// enrolled in. It is made here, at the agent's use, and not when
// host-env write assembles the file: at boot the box has no roster entry
// to check against (it is not enrolled yet), and before enrollment
// neither value grants anything, since the console derives the box's
// reference and region from its proven instance itself. A box an operator
// enrolled keeps its own CARLOS_REGION, unchecked, as on main.
func crossCheck(f modeFacts, row hostactors.Box) string {
	switch {
	case f.marked && row.Group == "":
		return "host.env marks this box as one its region's group launched, but the roster enrolled it by hand; it does not host"
	case !f.marked && row.Group != "":
		return "the roster enrolled this box from group " + row.Group + ", but host.env does not mark it so; it does not host"
	case f.marked && f.region != "" && f.region != row.Region:
		return "host.env names region " + f.region + ", but the roster enrolled this box in " + row.Region + "; it does not host"
	}
	return ""
}

// relaunched says why current, this instance's identity record read from
// IMDS now, is not the launch row was enrolled on: the record published
// under the box's reference (enrolled) must be the one the roster entry
// binds (its digest), and the two identity documents must name the same
// pendingTime. EC2 sets pendingTime each time an instance enters the
// pending state: at launch, and again at every start after a stop, but
// not at a reboot. "" when they agree.
func relaunched(row hostactors.Box, enrolled []byte, current boxid.Record) string {
	if hostfloor.Digest(enrolled) != row.EvidenceSHA256 {
		return "the identity record under this box's reference is not the one it was enrolled on"
	}
	var rec boxid.Record
	if json.Unmarshal(enrolled, &rec) != nil {
		return "the identity record it was enrolled on cannot be read"
	}
	then, now := recordPendingTime(rec), recordPendingTime(current)
	switch {
	case then == "" || now == "":
		return "an identity document names no pendingTime, so this launch cannot be matched to its enrollment"
	case then != now:
		return fmt.Sprintf("this instance was stopped and started since it was enrolled (launched %s, enrolled on the launch of %s): it must prove its group membership again, so it does not host", now, then)
	}
	return ""
}

// recordPendingTime is the pendingTime of a record's identity document, ""
// when it has none.
func recordPendingTime(rec boxid.Record) string {
	doc, err := base64.StdEncoding.DecodeString(rec.IdentityDocument)
	if err != nil {
		return ""
	}
	var d struct {
		PendingTime string `json:"pendingTime"`
	}
	if json.Unmarshal(doc, &d) != nil {
		return ""
	}
	return d.PendingTime
}

// guardReadyReport reports whether rep is this box's fresh guard-ready
// report for enrollment e under the guard g.
func guardReadyReport(rep status.Box, e hostfloor.Enrollment, g hostfloor.Running, now time.Time) bool {
	m := rep.Metrics
	return m != nil && m.Enrollment == e.Enrollment && m.FloorGuardReady && m.FloorSHA256 == g.FloorSHA256 && m.BinarySHA256 == g.Hash &&
		!rep.GeneratedAt.IsZero() && now.Sub(rep.GeneratedAt) <= reportFreshness
}

// productionModeFacts reads the deployment store, the status store and
// this box's own files.
func productionModeFacts(ctx context.Context, getenv func(string) string) (modeFacts, error) {
	store, err := openDeployStoreEnv(ctx, getenv)
	if err != nil {
		return modeFacts{}, err
	}
	statusStore, _, err := openStatusStore(ctx, getenv)
	if err != nil {
		return modeFacts{}, err
	}
	pub, err := release.ParsePublicKey(getenv("CARLOS_RELEASE_PUBKEY"), "CARLOS_RELEASE_PUBKEY")
	if err != nil {
		return modeFacts{}, err
	}
	disk, err := floorDisk(release.OptDir())
	if err != nil {
		return modeFacts{}, err
	}
	bucket := getenv("CARLOS_DEPLOYMENT_BUCKET")
	box := metricsBoxLabel(getenv, readMachineID())
	if box == "" {
		return modeFacts{}, errors.New("this box's group reference could not be read")
	}
	imds := &http.Client{Timeout: 2 * time.Second}
	return modeFacts{
		box:        box,
		marked:     groupReferenced(getenv),
		region:     strings.TrimSpace(getenv("CARLOS_REGION")),
		enrollment: func() (hostfloor.Enrollment, error) { return hostfloor.ReadEnrollment(hostActorPath) },
		roster: func(ctx context.Context) (hostactors.Roster, error) {
			return readGuardRoster(ctx, store, pub, bucket)
		},
		guard: func() (hostfloor.Running, error) {
			return disk.Running(version.Build, runtime.GOOS+"-"+runtime.GOARCH)
		},
		invited: func(ctx context.Context, box string) (bool, error) {
			_, _, err := store.Get(ctx, invitationKey(box))
			if errors.Is(err, os.ErrNotExist) {
				return false, nil
			}
			return err == nil, err
		},
		report: func(ctx context.Context, box string) (status.Box, error) {
			raw, _, err := statusStore.Get(ctx, "status/boxes/"+box+".json")
			if err != nil {
				return status.Box{}, err
			}
			var b status.Box
			if err := json.Unmarshal(raw, &b); err != nil {
				return status.Box{}, err
			}
			return b, nil
		},
		relaunch: func(ctx context.Context, row hostactors.Box) (string, error) {
			enrolled, err := boxid.Load(ctx, store, box)
			if err != nil {
				return "", err
			}
			current, err := readIdentityRecord(imds, imdsBase, box, time.Now())
			if err != nil {
				return "", err
			}
			return relaunched(row, enrolled, current), nil
		},
		now: time.Now,
	}, nil
}

// invitationKey is where the console writes box's invitation
// (hostactors.Directory.Invite).
func invitationKey(box string) string { return "control/host-actor-invitations/" + box + ".json" }

// agentHosting is this process's mode, decided once by RunAgent before
// the agent starts (hostingGate): true for the claim agent, false while
// joining. The claim wiring reports it to the load balancer's health
// answer (ClaimWiring.startRegional).
var agentHosting atomic.Bool
````

`internal/host/boxreference.go`:

````go
package host

import (
	"context"
	"encoding/base64"
	"encoding/json"
	"errors"
	"net/http"
	"strings"
	"sync"
	"time"

	"github.com/carlosframework/platform/internal/hostactors"
	"github.com/carlosframework/platform/internal/hostenv"
	"github.com/carlosframework/platform/internal/registry"
	"github.com/carlosframework/platform/internal/release"
	"github.com/carlosframework/platform/internal/status"
)

// A box its region's group launched is referenced by its group reference
// (census ruling region-ha-2b): hostactors.GroupBoxReference of its account
// and instance, "g-" and 20 hex digits. Its host.env says so:
// carlos system host-env write adds CARLOS_BOX_REFERENCE=group. Status
// labels come from 1,600 values and identity records are never deleted, so
// as a group replaces its boxes, labels collide and a box whose label
// another record holds can never publish its own; group references don't
// collide, and are a pseudonym, so the public status feed shows no
// instance id. Every other box, fleet boxes included, keeps its label and
// its status key exactly as before.

// groupReferenced reports whether this box takes its group reference.
func groupReferenced(getenv func(string) string) bool {
	return strings.TrimSpace(getenv(hostenv.BoxReference)) == hostenv.GroupReference
}

// groupIdentity reads this box's account and instance from its signed
// identity document (IMDS); a seam for tests.
var groupIdentity = func() (account, instance string, err error) {
	rec, err := readIdentityRecord(&http.Client{Timeout: 2 * time.Second}, imdsBase, "", time.Now())
	if err != nil {
		return "", "", err
	}
	doc, err := base64.StdEncoding.DecodeString(rec.IdentityDocument)
	if err != nil {
		return "", "", err
	}
	var d struct {
		AccountID  string `json:"accountId"`
		InstanceID string `json:"instanceId"`
	}
	if err := json.Unmarshal(doc, &d); err != nil {
		return "", "", err
	}
	if d.AccountID == "" || d.InstanceID == "" {
		return "", "", errors.New("the identity document names no account or instance")
	}
	return d.AccountID, d.InstanceID, nil
}

var groupRef struct {
	sync.Mutex
	ref string
}

// groupBoxReference is this box's group reference, read once and kept. ""
// while IMDS has not answered: never a label in its place.
func groupBoxReference() string {
	groupRef.Lock()
	defer groupRef.Unlock()
	if groupRef.ref != "" {
		return groupRef.ref
	}
	account, instance, err := groupIdentity()
	if err != nil {
		return ""
	}
	groupRef.ref = hostactors.GroupBoxReference(account, instance)
	return groupRef.ref
}

// errNoGroupReference is a tick of a group box whose reference has not
// resolved: it publishes nothing.
var errNoGroupReference = errors.New("this group box's reference could not be read, so it publishes no status report this tick")

// groupStatusTick is statusLoop's tick (census ruling region-ha-2b). A box
// whose host.env carries the group marker publishes its status report
// only under its group reference, the key the console derives, resolved
// afresh on every tick until it resolves (groupBoxReference keeps only a
// success). Until then it publishes nothing at all: never under
// status.Label, which may be another box's key, since WriteBox replaces
// whatever is there. That holds whatever the claim and region HA switches
// say. Every other box's tick is statusTick's with statusLoop's label,
// exactly as before.
func groupStatusTick(ctx context.Context, s release.Store, reg *registry.Registry, p status.Probes, publish bool, label string, getenv func(string) string) error {
	if groupReferenced(getenv) {
		ref := groupBoxReference()
		if ref == "" {
			return errNoGroupReference
		}
		label = ref
	}
	return statusTick(ctx, s, reg, p, publish, label)
}
````

`internal/host/metrics_loop.go`:

````diff
--- a/internal/host/metrics_loop.go
+++ b/internal/host/metrics_loop.go
@@ -715,6 +715,10 @@
 // label rather than disabling metrics outright, mirroring statusLoop's own
 // degrade-don't-disable choice.
 func metricsBoxLabel(getenv func(string) string, machineID string) string {
+	if groupReferenced(getenv) {
+		// A box its region's group launched (boxreference.go).
+		return groupBoxReference()
+	}
 	if identity, err := resolveFleetIdentity(getenv); err == nil {
 		return identity.Label
 	} else if !errors.Is(err, errNotFleetBox) {
````

`internal/host/boxrecord.go`:

````diff
--- a/internal/host/boxrecord.go
+++ b/internal/host/boxrecord.go
@@ -170,11 +170,17 @@
 		log.Printf("agent: box record not published: %v", err)
 		return
 	}
-	box := metricsBoxLabel(os.Getenv, readMachineID())
 	client := &http.Client{Timeout: 2 * time.Second}
-	read := func() (boxid.Record, error) { return readIdentityRecord(client, imdsBase, box, time.Now()) }
 	for attempt := 1; ; attempt++ {
-		done, err := publishBoxRecord(ctx, store, box, read)
+		// A group box's reference comes from its identity document, which
+		// IMDS may not answer yet: that is a retry, never a refusal
+		// (boxreference.go).
+		box := metricsBoxLabel(os.Getenv, readMachineID())
+		read := func() (boxid.Record, error) { return readIdentityRecord(client, imdsBase, box, time.Now()) }
+		done, err := false, errors.New("this box's group reference could not be read")
+		if box != "" {
+			done, err = publishBoxRecord(ctx, store, box, read)
+		}
 		if done {
 			if err != nil {
 				log.Printf("agent: box record for %s: %v (region HA cannot enroll this box until an operator resolves it)", box, err)
````

`statusLoop`'s one call, under census ruling region-ha-2b:

`internal/host/agent.go`:

````diff
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -3708,7 +3708,10 @@
 			}
 		}
 
-		if err := statusTick(ctx, store, reg, p, publish, fleetLabel); err != nil {
+		// groupStatusTick is statusTick for every box but one its region's
+		// group launched, which publishes only under its group reference
+		// (census ruling region-ha-2b, boxreference.go).
+		if err := groupStatusTick(ctx, store, reg, p, publish, fleetLabel, os.Getenv); err != nil {
 			log.Printf("agent: status tick: %v", err)
 		}
 
````

The status listing:

`internal/status/fleet.go`:

````diff
--- a/internal/status/fleet.go
+++ b/internal/status/fleet.go
@@ -235,8 +235,42 @@
 	return loadBoxesReport(ctx, s)
 }
 
+// statusListingHorizon is how long ago a report's object may last have
+// been written and still be listed as a box (region HA plan 2b). Each box
+// rewrites its own report every SnapshotPeriod, so an object older than a
+// day is a box that has gone: with automatic replacement such reports pile
+// up, one per replaced box, and could fill the key cap below, hiding the
+// boxes that are running. They are dropped from the listing before any
+// read and before the cap, so the cap bounds current reports only. This is
+// an optimisation, never authority: every reader still judges each
+// report's own GeneratedAt as before (BoxStale, the console's
+// VerifyReport), and a day keeps every box an operator would still look
+// for as stale. A store that cannot say when an object was written
+// (release.ModLister) is listed whole, as before.
+const statusListingHorizon = 24 * time.Hour
+
+// listBoxKeys lists status/boxes/, without the reports the horizon drops.
+func listBoxKeys(ctx context.Context, s release.Store) ([]string, error) {
+	ml, ok := s.(release.ModLister)
+	if !ok {
+		return s.List(ctx, "status/boxes/")
+	}
+	mods, err := ml.ListMod(ctx, "status/boxes/")
+	if err != nil {
+		return nil, err
+	}
+	cut := time.Now().Add(-statusListingHorizon)
+	keys := make([]string, 0, len(mods))
+	for _, m := range mods {
+		if m.Mod.IsZero() || !m.Mod.Before(cut) {
+			keys = append(keys, m.Key)
+		}
+	}
+	return keys, nil
+}
+
 func loadBoxesReport(ctx context.Context, s release.Store) (boxes []Box, complete bool, err error) {
-	keys, err := s.List(ctx, "status/boxes/")
+	keys, err := listBoxKeys(ctx, s)
 	if err != nil {
 		return nil, false, err
 	}
````

- [ ] **Step 4: Run the tests**

Run: `nice -n 19 go test ./internal/host/ -run 'TestSelectMode|TestRelaunched|TestAGroupBox|TestABoxThatIsNot|TestPublishBoxRecord|TestMetricsBoxLabel|TestStatusTick' -count=1 && nice -n 19 go test ./internal/status/ -count=1`
Expected: PASS.

- [ ] **Step 5: The provider boundary**

`region_ha_boundary_test.go`:

````diff
--- a/region_ha_boundary_test.go
+++ b/region_ha_boundary_test.go
@@ -39,6 +39,9 @@
 	"internal/hostactors/groupref.go",
 	"internal/hostactors/autoenroll.go",
 	"internal/console/enrollpass.go",
+	"internal/host/joinmode.go",
+	"internal/host/boxreference.go",
+	"internal/status/fleet.go",
 }
 
 // regionHAAdapterFiles are spec 2's AWS adapters. Each is named for AWS so
````

Run: `nice -n 19 go test . -run 'TestRegionHA(Core|Adapters|S3)' -count=1`
Expected: PASS.

- [ ] **Step 6: Census**

`statusLoop` is re-sealed as platform-1e ruled: its `@legacy` row is replaced in the 2b overlay with basis `census-ruling-region-ha-2b`, its kinds (the same read sites at main's offsets: the change is one call) and its new digest, and the seal moves. `internal/status` gains an `@coverage` row; `loadBoxesReport` is not a census unit. No other root-read unit is added: the facts read through `hostfloor.ReadEnrollment`, `readGuardRoster`, `Disk.Running`, `boxid.Load` and `readIdentityRecord`, unchanged, and `metricsBoxLabel` and `boxRecordLoop` are not census units.

`internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv`:

````diff
--- a/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv
+++ b/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv
@@ -3,15 +3,19 @@
 # Task 3: carlos system install-channel (internal/host installchannel.go, installchannel_linux.go): a fresh box installs its signed channel release into both protected slots through verifyPointer, hostfloor.CheckImage and stageCandidate, all unchanged, and on every run hands each directory on the agent's cache path (system, system/router and the version's) to the service user through bootstrap's own descriptor-anchored open, refusing a symlinked parent. One new root-read unit, channelInstall.run, whose one read site opens root's apply lock (systemApplier's lockPath). Ruled authority F,J4. stageCandidate, accepted-legacy, is called and not changed. The legacy seal stands.
 # Task 4: group membership, read by the console (internal/hostactors membershipread.go: MembershipReader, the unexported confirmation and the write bound, the single enforcement point of the 60-second deadline, each store call cut off at 15 seconds; enrollment_confirmation.go: EnrollAutomatic and CheckConfirmed make the read themselves and write on the bound store, and the box-written confirmation object is removed; groupref.go: GroupBoxReference; private.go: a group box's report read under the reference the console derives; awsmembership, the Auto Scaling and EC2 describe adapter) and the Auto Scaling SDK in go.mod. No root-read unit is added or changed: the read is the cloud's, through the reader. Envelope-only, and the seal stands. The record-write census gains deadlineStore.Put (nonrecord, ruled).
 # Task 5: the console's automatic enrollment pass (internal/hostactors autoenroll.go: AutoEnroller: an enrolling write the deadline left unknown is settled by the next pass's roster read; internal/console enrollpass.go, started in StartClaimRecoveryPass with region HA on, and awsmembership.go, its membership reader). One new root-read unit, AutoEnroller.inputs, whose two read sites are the signed roster (Directory.Read) and the host floor (hostfloor.Read), in the console. Ruled accepted-legacy (J4 root-input). Invite, BeginEnrollmentAttempt and checkMembership, accepted-legacy, are called and not changed. The seal moves.
+# Task 6: mode selection and group references (internal/host joinmode.go: selectMode, productionModeFacts, relaunched and crossCheck: a group box stopped and started since its enrollment, or whose host.env disagrees with the roster, is held; boxreference.go, metrics_loop.go's metricsBoxLabel and boxrecord.go's boxRecordLoop: a group box takes its group reference; internal/status fleet.go: the status listing drops reports no box has rewritten in a day before the key cap). statusLoop, accepted-legacy, is re-sealed as ruled: its tick is groupStatusTick, which publishes a group box's report only under its group reference, resolved afresh each tick, and nothing until it resolves; every other box's tick is statusTick's, unchanged. No other root-read unit is added or changed. The seal moves.
 @coverage	.	3678cfe22582d35fe0d8e93d3fed898cdffa6fb2ce2d81e0252c8a8da77993b3
 @coverage	internal/hostenv	a3f3274a86cdf8194afb871301c1ba3289dfbdc9b703dd547178273df151cdaa
 internal/hostenv/cli.go	runHostEnv	authority	A,J4	rev3=census-ruling-region-ha-2b;basis=carlos system host-env: write runs as root at a box's start-up and writes /etc/carlos/host.env, 0600, whole (temporary file and rename), from allowlisted parameters with safe values only, refusing a missing required value; its one read site is seed's --from file, an operator's host.env whose values go only to the parameter store; write's path is fixed and reached only through writeHostEnv	file:ReadFile@1500	df4470176b229b311927ff0ac4f268d354c3a2357cac4300ac0992d2ebb90899
 internal/hostenv/write_linux.go	checkedDir	authority	A,J4	rev3=census-ruling-region-ha-2b;basis=carlos system host-env write (root, Linux): opens one directory under one already held, O_NOFOLLOW|O_DIRECTORY, creating it 0755 first only when asked and missing, and refuses it unless fstat shows a directory owned by this process's user (root, as write requires) that no group or other can write	file:Fstat@576,file:Openat@168,file:Openat@372	012257ac0866f3385044785d8abae9c31e61134928c394e22a2aeaef39ec9f3c
 internal/hostenv/write_linux.go	writeHostEnv	authority	A,J4	rev3=census-ruling-region-ha-2b;basis=carlos system host-env write (root, Linux): writes /etc/carlos/host.env only through /etc and /etc/carlos held by descriptor and checked by checkedDir; the new file is created 0600, O_EXCL|O_NOFOLLOW, in that directory, synced, renamed over host.env within it, and the directory synced; its read sites open the root and the temporary file	file:Open@68,file:Openat@509	1245fc0f819ba73fb45545ffdcf92d5d4941802175e73bac209ab4d4238adff1
-@coverage	internal/host	64ee4f561f2a28d584a6f7c82e068e57db4822f6cbb130116284bca583d2d390
+@coverage	internal/host	7f17f1c86771c44641869ff808c4befe2698bbb6ed81c6acbcf1e8cfde570729
 internal/host/installchannel.go	channelInstall.run	authority	F,J4	rev3=census-ruling-region-ha-2b;basis=carlos system install-channel (root, at a fresh box's start-up): its one read site opens root's apply lock; the install goes through verifyPointer, hostfloor.CheckImage and stageCandidate into both protected slots, and refuses a box whose guard is armed or that already runs another release	file:OpenFile@196	f0cdb6dd28e83da282c3b0d7bc2012dcd80db181a8cecbf1a2db6bcc41d1d5e3
 @coverage	internal/hostactors	a1088ca1b4e6bac902a9d2419c101181860381b7749596f2c3a9392894cee91d
 @coverage	internal/hostactors/awsmembership	a3defad7179245492a3c1dcfce5e20f0f85db2e240b474c654acf211da16df98
 @coverage	internal/console	80fa5bba8bd23325ababaedc2b8ce810b25161c93a81c6d97dd7033bcc5df103
 @legacy	2f4ca2a51661d8aae1e0f38a41efdcc9df9f1dd75411ca6742d6ee3ae71f1756	internal/hostactors/autoenroll.go	AutoEnroller.inputs	legacy	accepted-legacy	accepted-legacy;rev3=J4;basis=root-input;census-ruling-region-ha-2b	file:Read@102,file:Read@305	c0ae921ee9d8df9de403d488d9708c565d659a7bf880199313f50bdc7c8f8127
 internal/hostactors/autoenroll.go	AutoEnroller.inputs	legacy	accepted-legacy	accepted-legacy;rev3=J4;basis=root-input;census-ruling-region-ha-2b	file:Read@102,file:Read@305	c0ae921ee9d8df9de403d488d9708c565d659a7bf880199313f50bdc7c8f8127
+@coverage	internal/status	a1eee66fc906d6a7275017fd30c346b64aaafe4f88a501538248e99e9d95c33d
+@legacy	30a3ee6b3365c202bf93b26087888a582c6b8d6df1a9f1fc6ba12e639ddd8684	internal/host/agent.go	statusLoop	legacy	accepted-legacy	accepted-legacy;rev3=census-ruling-region-ha-2b;basis=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos): main's body with its tick through groupStatusTick, so a box whose host.env carries the group marker (CARLOS_BOX_REFERENCE=group) publishes its status report only under its group reference, resolved afresh each tick, and nothing until it resolves, never under status.Label; every other box, fleet boxes included, gets statusTick with today's label and key; read sites unchanged	whole:Route@6154,whole:Route@6181,whole:Route@7423,whole:Route@9437	666ddbc4779442db4c868253cf5062c690e20ad643197688305e0742f6de9283
+internal/host/agent.go	statusLoop	legacy	accepted-legacy	accepted-legacy;rev3=census-ruling-region-ha-2b;basis=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos): main's body with its tick through groupStatusTick, so a box whose host.env carries the group marker (CARLOS_BOX_REFERENCE=group) publishes its status report only under its group reference, resolved afresh each tick, and nothing until it resolves, never under status.Label; every other box, fleet boxes included, gets statusTick with today's label and key; read sites unchanged	whole:Route@6154,whole:Route@6181,whole:Route@7423,whole:Route@9437	666ddbc4779442db4c868253cf5062c690e20ad643197688305e0742f6de9283
````

`internal/provision/plan14_root_reads_test.go`:

````diff
--- a/internal/provision/plan14_root_reads_test.go
+++ b/internal/provision/plan14_root_reads_test.go
@@ -816,7 +816,7 @@
 	if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv"), &inv, true); err != nil {
 		t.Fatal(err)
 	}
-	seal = "8afff5c9874f6d31079ffb2e722274b0b1f82eef7ae228e3c816245c239a93e1"
+	seal = "fc584e113d0c6786bdeb3344354ae567c112c607e68f85056352545557a91b5d"
 	errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...)
 	errs = append(errs, checkRootReadDeclarations(got, inv)...)
 	if len(errs) > 0 {
````

Run: `nice -n 19 go test ./internal/provision/ -run 'TestPlan9B8RecordWriteCensus|TestPlan14RootReadCensus$' -count=1 && nice -n 19 go test ./internal/console/ -run TestClaimAuthoritySourceCensus -count=1`
Expected: PASS.

- [ ] **Step 7: Commit**

```bash
git add internal/host/joinmode.go internal/host/joinmode_test.go internal/host/boxreference.go internal/host/boxreference_test.go internal/host/metrics_loop.go internal/host/boxrecord.go internal/host/agent.go internal/status/fleet.go internal/status/fleet_listing_test.go region_ha_boundary_test.go internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv internal/provision/plan14_root_reads_test.go
git commit -m "region HA 2b: mode selection and group references"
```

---

### Task 7: Root's half of joining: `carlos system join`

**Files:**
- Create: `internal/host/joinroot.go`, `internal/host/joinroot_test.go`
- Create: `internal/host/units/carlos-system-join.path`, `internal/host/units/carlos-system-join.service`
- Modify: `internal/host/bootstrap.go` (`enabledUnits` arms the path), `internal/host/host_test.go` (`expectedUnits`)
- Modify: `region_ha_boundary_test.go`, `approved_region_ha_2b_copy_test.go`, the overlay

**Interfaces:**
- Consumes: `RunSystemGuardArm` (unchanged, in process), `channelIdentityPath` (Task 3), `invitationKey` and `hostActorPath` (Task 6), `recordInstanceID`, `cachedInstanceID`, `openSystemStore`, `floorDisk`; `hostactors.Invitation`; `boxid.Load`; `hostfloor.Verify`, `Policy`, `Enrollment`, `Durable`, `Digest`; `release.VerifyRootEnvelope`.
- Produces: `RunSystemJoin([]string)` (wired in Task 9); `joinDir(optDir)` (`/opt/carlos/system/join`, 0700); `rootJoin{box, optDir, bucket, store, pub, instance, armed, arm}` and its `run`; `joinOutcome` (`joinArmedAlready`, `joinNotInvited`, `joinArmed`); the units `carlos-system-join.path` (on `/data/system-join-requested`) and `carlos-system-join.service` (root oneshot).

This task writes rows RHB-C40 to RHB-C49 of the copy record, and RHB-C62 and RHB-C63, the two units' `Description=` lines, which an operator reads in `systemctl status` and the journal. Build it only once Paul has approved them, and where his text differs from a draft, use his in `joinroot.go` and the unit files too: the strings in the code below are the drafts. Adding the ids to `regionHA2bCopyBuilt` makes the gate hold the code to the approved text.

The agent is sandboxed (`User=carlos`, `ProtectSystem=strict`): it can see its invitation but cannot write `/etc` or arm the guard. So, as for a self-update, it touches a marker (Task 8) and a path unit starts a root oneshot. Root takes nothing from the marker. It reads the invitation itself and checks it: signed by the release key, for this deployment and this box, for the identity record this box published (by digest), whose instance is this one (IMDS). It writes the enrollment the invitation names and an armed policy built from the published, verified host floor (both 0600 under `/opt/carlos/system/join`), and runs guard-arm in process with the release identity install-channel kept. guard-arm checks both slots against that identity, and writes `/etc/carlos/host-actor.json` and the guarded units' drop-ins. A box already armed reads nothing; a box with no invitation, or with claim mode or region HA off, does nothing.

**The trigger is only a trigger (census condition).** The path unit's file, `/data/system-join-requested`, is written by the agent, which root does not trust. Root's join reads nothing from it, takes no parameter from it, and decides only from root-verified state: the signed invitation, the published identity record (by digest), the signed host floor and IMDS. `TestRootJoinTakesNothingFromItsTrigger` writes hostile contents into it (another box's enrollment, a path, a policy, NUL bytes and newlines, a megabyte of junk) and finds the same outcome and the same enrollment each time, and checks that `joinroot.go` names neither the trigger nor its path.

- [ ] **Step 1: Write the failing tests**

`internal/host/joinroot_test.go`:

````go
package host

import (
	"bytes"
	"context"
	"crypto/ed25519"
	"encoding/json"
	"errors"
	"os"
	"path/filepath"
	"strings"
	"testing"

	"github.com/carlosframework/platform/internal/boxid"
	"github.com/carlosframework/platform/internal/boxid/boxidtest"
	"github.com/carlosframework/platform/internal/hostactors"
	"github.com/carlosframework/platform/internal/hostfloor"
	"github.com/carlosframework/platform/internal/release"
)

// rootJoinWorld is a deployment that has invited box "ada-hiking", whose
// identity record says it is instance i-ada.
type rootJoinWorld struct {
	j      rootJoin
	store  *release.DirStore
	sg     release.Signer
	armed  []string
	invite hostactors.Invitation
}

func newRootJoinWorld(t *testing.T, invite bool) *rootJoinWorld {
	t.Helper()
	ctx := context.Background()
	pub, key, err := ed25519.GenerateKey(nil)
	if err != nil {
		t.Fatal(err)
	}
	w := &rootJoinWorld{store: release.NewDirStore(t.TempDir()), sg: release.RootSigner(key)}
	_, raw := boxidtest.NewSigner(t).Record(t, "ada-hiking", "123", "eu-west-1", "i-ada")
	if _, err := w.store.Put(ctx, boxid.Key("ada-hiking"), raw, ""); err != nil {
		t.Fatal(err)
	}
	if err := hostfloor.Publish(ctx, w.store, w.sg, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.0.0"}); err != nil {
		t.Fatal(err)
	}
	if invite {
		if w.invite, err = (hostactors.Directory{Store: w.store, PublicKey: pub, Bucket: "bucket"}).Invite(ctx, w.sg, "ada-hiking", raw); err != nil {
			t.Fatal(err)
		}
	}
	w.j = rootJoin{box: "ada-hiking", optDir: t.TempDir(), bucket: "bucket", store: w.store, pub: pub,
		instance: func() string { return "i-ada" }, armed: func() bool { return false },
		arm: func(policy, identity, enrollment string) error {
			w.armed = []string{policy, identity, enrollment}
			return nil
		}}
	return w
}

func TestRootJoinArmsWithTheInvitationsEnrollment(t *testing.T) {
	w := newRootJoinWorld(t, true)
	got, err := w.j.run(context.Background())
	if err != nil || got != joinArmed || len(w.armed) != 3 {
		t.Fatalf("%s %v, armed %v", got, err, w.armed)
	}
	raw, err := os.ReadFile(w.armed[0])
	if err != nil {
		t.Fatal(err)
	}
	var p hostfloor.Policy
	if json.Unmarshal(raw, &p) != nil || p.Mode != "armed" {
		t.Fatalf("policy %s", raw)
	}
	if _, err := p.Verify(w.j.pub, "bucket"); err != nil {
		t.Fatalf("the policy does not verify: %v", err)
	}
	if w.armed[1] != channelIdentityPath(w.j.optDir) {
		t.Fatalf("identity %s", w.armed[1])
	}
	e, err := hostfloor.ReadEnrollment(w.armed[2])
	if err != nil || e != (hostfloor.Enrollment{Box: "ada-hiking", Enrollment: w.invite.Enrollment, InstanceID: "i-ada"}) {
		t.Fatalf("enrollment %+v %v", e, err)
	}
	for _, p := range []string{w.armed[0], w.armed[2]} {
		if fi, err := os.Stat(p); err != nil || fi.Mode().Perm() != 0o600 {
			t.Fatalf("%s: %v %v", p, fi, err)
		}
	}
}

func TestRootJoinDoesNothingUntilInvitedOrOnceArmed(t *testing.T) {
	w := newRootJoinWorld(t, false)
	if got, err := w.j.run(context.Background()); err != nil || got != joinNotInvited || w.armed != nil {
		t.Fatalf("not invited: %s %v %v", got, err, w.armed)
	}
	v := newRootJoinWorld(t, true)
	v.j.armed = func() bool { return true }
	v.j.store = nil // an armed box reads nothing
	if got, err := v.j.run(context.Background()); err != nil || got != joinArmedAlready || v.armed != nil {
		t.Fatalf("armed: %s %v %v", got, err, v.armed)
	}
}

func TestRootJoinRefusesAnInvitationThatIsNotThisBoxs(t *testing.T) {
	for name, spoil := range map[string]func(*rootJoinWorld){
		"another instance": func(w *rootJoinWorld) { w.j.instance = func() string { return "i-other" } },
		"another record": func(w *rootJoinWorld) {
			_, raw := boxidtest.NewSigner(t).Record(t, "ada-hiking", "123", "eu-west-1", "i-ada")
			_, etag, _ := w.store.Get(context.Background(), boxid.Key("ada-hiking"))
			w.store.Put(context.Background(), boxid.Key("ada-hiking"), raw, etag)
		},
		"another release key": func(w *rootJoinWorld) {
			other, _, _ := ed25519.GenerateKey(nil)
			w.j.pub = other
		},
		"another deployment": func(w *rootJoinWorld) { w.j.bucket = "other-bucket" },
	} {
		t.Run(name, func(t *testing.T) {
			w := newRootJoinWorld(t, true)
			spoil(w)
			if got, err := w.j.run(context.Background()); err == nil || w.armed != nil {
				t.Fatalf("%s %v, armed %v", got, err, w.armed)
			}
		})
	}
	// A store that cannot be read is an error, never "not invited".
	w := newRootJoinWorld(t, true)
	w.j.store = joinOutageStore{}
	if got, err := w.j.run(context.Background()); err == nil || got == joinNotInvited {
		t.Fatalf("unreadable store: %s %v", got, err)
	}
}

type joinOutageStore struct{ release.Store }

func (joinOutageStore) Get(context.Context, string) ([]byte, string, error) {
	return nil, "", errors.New("connection reset")
}

// The join units' census condition (platform-1e, region-ha-2b): the path
// unit's file is only a trigger. Whatever the agent, or anything else,
// writes into it, root's join reads none of it, takes no parameter from
// it, and decides from the signed invitation, the published identity
// record, the signed host floor and IMDS alone.
func TestRootJoinTakesNothingFromItsTrigger(t *testing.T) {
	data := t.TempDir()
	t.Setenv("CARLOS_DATA_DIR", data)
	clean := newRootJoinWorld(t, true)
	want, err := clean.j.run(context.Background())
	if err != nil || want != joinArmed {
		t.Fatalf("baseline: %s %v", want, err)
	}
	for name, body := range map[string]string{
		"another box":        `{"box":"grace-hiking","instance":"i-grace","enrollment":"ffffffffffffffffffffffffffffffff"}`,
		"a path":             "../../etc/carlos/host-actor.json\n",
		"a policy":           `{"schema":1,"mode":"armed","deployment_bucket":"other-bucket"}`,
		"nul and newlines":   "a\x00b\n\n--policy=/tmp/x\n",
		"a megabyte of junk": strings.Repeat("x", 1<<20),
	} {
		t.Run(name, func(t *testing.T) {
			if err := os.WriteFile(filepath.Join(data, "system-join-requested"), []byte(body), 0o644); err != nil {
				t.Fatal(err)
			}
			w := newRootJoinWorld(t, true)
			got, err := w.j.run(context.Background())
			if err != nil || got != want || len(w.armed) != 3 || w.armed[1] != channelIdentityPath(w.j.optDir) {
				t.Fatalf("%s %v, armed %v", got, err, w.armed)
			}
			e, err := hostfloor.ReadEnrollment(w.armed[2])
			if err != nil || e != (hostfloor.Enrollment{Box: "ada-hiking", Enrollment: w.invite.Enrollment, InstanceID: "i-ada"}) {
				t.Fatalf("enrollment %+v %v", e, err)
			}
		})
	}
	// And by construction: root's join names neither the trigger nor its
	// path anywhere in its source.
	src, err := os.ReadFile("joinroot.go")
	if err != nil {
		t.Fatal(err)
	}
	if bytes.Contains(src, []byte("joinRequestMarker")) || bytes.Contains(src, []byte("system-join-requested")) {
		t.Fatal("joinroot.go reads its trigger")
	}
}
````

`internal/host/host_test.go`:

````diff
--- a/internal/host/host_test.go
+++ b/internal/host/host_test.go
@@ -57,6 +57,8 @@
 	"carlos-restore-verify.timer",
 	"carlos-stage.path",
 	"carlos-stage.service",
+	"carlos-system-join.path",
+	"carlos-system-join.service",
 	"carlos-system-recover.service",
 	"carlos-system-update.path",
 	"carlos-system-update.service",
````

- [ ] **Step 2: Run them to see them fail**

Run: `nice -n 19 go test ./internal/host/ -run 'TestRootJoin|TestUnitsEmbedComplete' -count=1`
Expected: FAIL, the package does not build (`undefined: rootJoin`).

- [ ] **Step 3: Write the command and the units**

`internal/host/joinroot.go`:

````go
package host

import (
	"context"
	"crypto/ed25519"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"log"
	"os"
	"path/filepath"
	"runtime"

	"github.com/carlosframework/platform/internal/boxid"
	"github.com/carlosframework/platform/internal/cliflag"
	"github.com/carlosframework/platform/internal/hostactors"
	"github.com/carlosframework/platform/internal/hostfloor"
	"github.com/carlosframework/platform/internal/hostwrite"
	"github.com/carlosframework/platform/internal/release"
	"github.com/carlosframework/platform/internal/version"
)

// carlos system join (region HA spec 2 §5 step 5.3): root's half of
// joining, run by carlos-system-join.service when the joining agent asks.
//
// The invitation grants no membership: it is the console's signed nonce
// for this box's identity record, with which the box installs its guard
// and reports. Root reads it itself and checks it: signed by the release
// key, for this deployment and this box, for the identity record this box
// published, which must be this instance's. Then it writes the enrollment
// the invitation names, builds the armed policy from the published host
// floor, and runs guard-arm with the release identity install-channel
// kept (channelIdentityPath). guard-arm checks both protected slots against
// that identity and writes /etc/carlos/host-actor.json.
//
// A box already armed, a box with no invitation yet, and a box with claim
// mode or region HA off have nothing to do.

const joinUsage = "usage: carlos system join\n\n" +
	"Arm the host lifecycle guard of a joining box, as root, once the console has invited it.\n"

const joinNotRoot = "carlos system join runs as root: it arms the host lifecycle guard"

// joinDir holds what root hands guard-arm: the enrollment and the policy.
func joinDir(optDir string) string { return filepath.Join(optDir, "system", "join") }

// rootJoin is one run; production's is RunSystemJoin's.
type rootJoin struct {
	box      string
	optDir   string
	bucket   string
	store    release.Store
	pub      ed25519.PublicKey
	instance func() string
	// armed reports whether the guard is armed for host-actor.json.
	armed func() bool
	// arm is guard-arm, given the three files.
	arm func(policy, identity, enrollment string) error
}

// joinOutcome is what a run did.
type joinOutcome string

const (
	joinArmedAlready joinOutcome = "armed already"
	joinNotInvited   joinOutcome = "not invited"
	joinArmed        joinOutcome = "armed"
)

// RunSystemJoin is `carlos system join`.
func RunSystemJoin(args []string) {
	fs := flag.NewFlagSet("system join", flag.ExitOnError)
	fs.Usage = cliflag.Usage(fs, joinUsage)
	_ = fs.Parse(args)
	if fs.NArg() != 0 {
		fs.Usage()
		os.Exit(2)
	}
	if os.Geteuid() != 0 {
		fmt.Fprintln(os.Stderr, joinNotRoot)
		os.Exit(1)
	}
	if !hostwrite.Enabled(os.Getenv) || !regionHAEnabled(os.Getenv) {
		fmt.Println("CARLOS_HOST_CLAIM or CARLOS_REGION_HA is off, so this box does not join")
		return
	}
	ctx := context.Background()
	store, pub, err := openSystemStore(ctx)
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
	optDir := release.OptDir()
	j := rootJoin{
		box: metricsBoxLabel(os.Getenv, readMachineID()), optDir: optDir, bucket: os.Getenv("CARLOS_DEPLOYMENT_BUCKET"),
		store: store, pub: pub, instance: cachedInstanceID,
		armed: func() bool {
			if _, err := hostfloor.ReadEnrollment(hostActorPath); err != nil {
				return false
			}
			d, err := floorDisk(optDir)
			if err != nil {
				return false
			}
			r, err := d.Running(version.Build, runtime.GOOS+"-"+runtime.GOARCH)
			return err == nil && r.GuardReady
		},
		arm: func(policy, identity, enrollment string) error {
			// guard-arm exits the process on any refusal, with its reason.
			RunSystemGuardArm([]string{"-policy", policy, "-identity", identity, "-enrollment", enrollment})
			return nil
		},
	}
	got, err := j.run(ctx)
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
	log.Printf("system join: %s: %s", j.box, got)
}

func (j rootJoin) run(ctx context.Context) (joinOutcome, error) {
	if j.armed() {
		return joinArmedAlready, nil
	}
	wire, _, err := j.store.Get(ctx, invitationKey(j.box))
	if errors.Is(err, os.ErrNotExist) {
		return joinNotInvited, nil
	}
	if err != nil {
		return "", fmt.Errorf("the invitation for %s could not be read: %w", j.box, err)
	}
	var inv hostactors.Invitation
	if release.VerifyRootEnvelope(j.pub, wire, &inv) != nil || inv.V != 1 || inv.Bucket != j.bucket || inv.Reference != j.box || inv.Enrollment == "" {
		return "", fmt.Errorf("the invitation for %s does not verify, so the guard was not armed", j.box)
	}
	raw, err := boxid.Load(ctx, j.store, j.box)
	if err != nil {
		return "", fmt.Errorf("the identity record for %s could not be read: %w", j.box, err)
	}
	var rec boxid.Record
	if hostfloor.Digest(raw) != inv.EvidenceSHA256 || json.Unmarshal(raw, &rec) != nil {
		return "", fmt.Errorf("the invitation for %s is for another identity record, so the guard was not armed", j.box)
	}
	instance, err := recordInstanceID(rec)
	if err != nil || instance == "" || instance != j.instance() {
		return "", fmt.Errorf("the identity record for %s is not this instance's, so the guard was not armed", j.box)
	}
	// guard-arm refuses, naming the file, when install-channel has not
	// kept the release identity.
	identity := channelIdentityPath(j.optDir)
	floorRaw, _, err := j.store.Get(ctx, hostfloor.Key)
	if err != nil {
		return "", fmt.Errorf("the host floor could not be read: %w", err)
	}
	auth, _, err := j.store.Get(ctx, hostfloor.AuthKey)
	if err != nil {
		return "", fmt.Errorf("the host floor could not be read: %w", err)
	}
	if _, err := hostfloor.Verify(j.pub, j.bucket, floorRaw, auth); err != nil {
		return "", fmt.Errorf("the host floor does not verify, so the guard was not armed: %w", err)
	}
	policy, err := json.Marshal(hostfloor.Policy{Schema: 1, DeploymentBucket: j.bucket, Mode: "armed", Floor: floorRaw, Authorization: auth})
	if err != nil {
		return "", err
	}
	enrollment, err := json.Marshal(hostfloor.Enrollment{Box: j.box, Enrollment: inv.Enrollment, InstanceID: instance})
	if err != nil {
		return "", err
	}
	dir := joinDir(j.optDir)
	if err := os.MkdirAll(dir, 0o700); err != nil {
		return "", err
	}
	policyPath, enrollmentPath := filepath.Join(dir, "policy.json"), filepath.Join(dir, "enrollment.json")
	if err := hostfloor.Durable(policyPath, policy, 0o600); err != nil {
		return "", err
	}
	if err := hostfloor.Durable(enrollmentPath, enrollment, 0o600); err != nil {
		return "", err
	}
	if err := j.arm(policyPath, identity, enrollmentPath); err != nil {
		return "", err
	}
	return joinArmed, nil
}
````

`internal/host/units/carlos-system-join.path`:

````ini
# Region HA spec 2 §5 step 5.3: the relay from a joining box's sandboxed
# agent to root's half of joining. The agent (User=carlos,
# ProtectSystem=strict) sees the console's invitation but cannot write /etc
# or arm the host lifecycle guard; it touches this marker and root does the
# rest, as carlos-system-update.path relays a self-update. Root takes
# nothing from the marker: it reads and verifies the invitation itself.
[Unit]
Description=carlos joining: arm the host lifecycle guard on agent request

[Path]
PathChanged=/data/system-join-requested
Unit=carlos-system-join.service

[Install]
WantedBy=multi-user.target
````

`internal/host/units/carlos-system-join.service`:

````ini
# Root's half of joining (region HA spec 2 §5 step 5.3): `carlos system
# join` verifies this box's signed invitation, writes the enrollment it
# names, and arms the host lifecycle guard (guard-arm) with the release
# identity `carlos system install-channel` kept. On a box that is armed
# already, has no invitation yet, or has CARLOS_HOST_CLAIM or
# CARLOS_REGION_HA off, it does nothing. Started only by
# carlos-system-join.path; no [Install] section, because nothing else
# should want it.
#
# Root, with no filesystem sandboxing, like carlos-system-update.service:
# guard-arm writes /opt/carlos/system/guard, /etc/carlos/host-actor.json and
# the guarded units' drop-ins under /etc/systemd/system.
# StartLimitIntervalSec=0 because the path unit can fire in bursts.
[Unit]
Description=carlos joining (root: arm the host lifecycle guard)
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=0

[Service]
Type=oneshot
TimeoutStartSec=5min
EnvironmentFile=-/etc/carlos/host.env
ExecStart=/opt/carlos/carlos system join

# --- hardening (the carlos-system-update.service set) ---
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectClock=yes
ProtectHostname=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=yes
RestrictRealtime=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
MemoryMax=256M
TasksMax=64
````

`internal/host/bootstrap.go`:

````diff
--- a/internal/host/bootstrap.go
+++ b/internal/host/bootstrap.go
@@ -166,6 +166,9 @@
 
 var enabledUnits = []string{"carlos-edge.service", relayUnitName, "carlos-adopt.timer", "carlos-adopt.path", "carlos-stage.path", "carlos-restart.path",
 	"carlos-system-update.path", "carlos-system-recover.service",
+	// Region HA joining (spec 2 §5 step 5.3): root's half, on the agent's
+	// request. Idle on every box that is not joining.
+	"carlos-system-join.path",
 	// The isolated-exec mounts (design §3.2): the edge Requires= them.
 	"data-instances.mount", "run-carlos-instances.mount", "run-carlos-logs.mount",
 	// The root instance-control channel (design §5.2); socket-activated, so
````

- [ ] **Step 4: Run the tests**

Run: `nice -n 19 go test ./internal/host/ -run 'TestRootJoin|TestUnitsEmbedComplete|TestEmbeddedUnits|TestBootstrapPlanJSON|TestProbedUnits|TestPlanCarriesArmedList|TestSystemctlPlan|TestEnabledUnits|TestControlUnitsShape' -count=1`
Expected: PASS.

- [ ] **Step 5: The provider boundary**

`region_ha_boundary_test.go`:

````diff
--- a/region_ha_boundary_test.go
+++ b/region_ha_boundary_test.go
@@ -42,6 +42,7 @@
 	"internal/host/joinmode.go",
 	"internal/host/boxreference.go",
 	"internal/status/fleet.go",
+	"internal/host/joinroot.go",
 }
 
 // regionHAAdapterFiles are spec 2's AWS adapters. Each is named for AWS so
````

Run: `nice -n 19 go test . -run 'TestRegionHA(Core|Adapters|S3)' -count=1`
Expected: PASS.

- [ ] **Step 6: Mark the rows built**

Add RHB-C40 to RHB-C49, RHB-C62 and RHB-C63 to `regionHA2bCopyBuilt`, in the same commit as their text:

`approved_region_ha_2b_copy_test.go`:

````diff
--- a/approved_region_ha_2b_copy_test.go
+++ b/approved_region_ha_2b_copy_test.go
@@ -38,7 +38,7 @@
 // regionHA2bCopyBuilt is every id whose text the code or docs carry. The
 // task that writes a row's text adds its ids here in the same commit, once
 // Paul has approved the rows.
-var regionHA2bCopyBuilt = []string{"RHB-C1", "RHB-C2", "RHB-C3", "RHB-C4", "RHB-C5", "RHB-C6", "RHB-C7", "RHB-C8", "RHB-C9", "RHB-C10", "RHB-C11", "RHB-C12", "RHB-C13", "RHB-C14", "RHB-C15", "RHB-C16", "RHB-C17", "RHB-C18", "RHB-C64", "RHB-C67", "RHB-C68", "RHB-C19", "RHB-C20", "RHB-C21", "RHB-C22", "RHB-C23", "RHB-C24", "RHB-C25", "RHB-C26", "RHB-C27", "RHB-C28", "RHB-C29", "RHB-C30", "RHB-C31", "RHB-C32", "RHB-C33", "RHB-C34", "RHB-C35", "RHB-C36", "RHB-C37", "RHB-C38", "RHB-C39"}
+var regionHA2bCopyBuilt = []string{"RHB-C1", "RHB-C2", "RHB-C3", "RHB-C4", "RHB-C5", "RHB-C6", "RHB-C7", "RHB-C8", "RHB-C9", "RHB-C10", "RHB-C11", "RHB-C12", "RHB-C13", "RHB-C14", "RHB-C15", "RHB-C16", "RHB-C17", "RHB-C18", "RHB-C64", "RHB-C67", "RHB-C68", "RHB-C19", "RHB-C20", "RHB-C21", "RHB-C22", "RHB-C23", "RHB-C24", "RHB-C25", "RHB-C26", "RHB-C27", "RHB-C28", "RHB-C29", "RHB-C30", "RHB-C31", "RHB-C32", "RHB-C33", "RHB-C34", "RHB-C35", "RHB-C36", "RHB-C37", "RHB-C38", "RHB-C39", "RHB-C40", "RHB-C41", "RHB-C42", "RHB-C43", "RHB-C44", "RHB-C45", "RHB-C46", "RHB-C47", "RHB-C48", "RHB-C49", "RHB-C62", "RHB-C63"}
 
 func mustParseRegionHA2bRecord(t *testing.T) []copyRow {
 	t.Helper()
````

Run: `nice -n 19 go test . -run 'RegionHA2b' -count=1`
Expected: PASS once the rows are approved. While any built row is pending, `TestRegionHA2bBuiltCopyIsApproved` fails naming each one, and nothing else fails.

- [ ] **Step 7: Census**

The two unit files are new root inputs, which census ruling region-ha-2b is asked to class as `carlos-system-update.path` and `.service` are. `internal/host/units`' row is over every file there (`coverage-all.sh`): the embed brings in its mounts and slices too. `joinroot.go` adds no root-read unit. The seal stands.

`internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv`:

````diff
--- a/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv
+++ b/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv
@@ -4,12 +4,13 @@
 # Task 4: group membership, read by the console (internal/hostactors membershipread.go: MembershipReader, the unexported confirmation and the write bound, the single enforcement point of the 60-second deadline, each store call cut off at 15 seconds; enrollment_confirmation.go: EnrollAutomatic and CheckConfirmed make the read themselves and write on the bound store, and the box-written confirmation object is removed; groupref.go: GroupBoxReference; private.go: a group box's report read under the reference the console derives; awsmembership, the Auto Scaling and EC2 describe adapter) and the Auto Scaling SDK in go.mod. No root-read unit is added or changed: the read is the cloud's, through the reader. Envelope-only, and the seal stands. The record-write census gains deadlineStore.Put (nonrecord, ruled).
 # Task 5: the console's automatic enrollment pass (internal/hostactors autoenroll.go: AutoEnroller: an enrolling write the deadline left unknown is settled by the next pass's roster read; internal/console enrollpass.go, started in StartClaimRecoveryPass with region HA on, and awsmembership.go, its membership reader). One new root-read unit, AutoEnroller.inputs, whose two read sites are the signed roster (Directory.Read) and the host floor (hostfloor.Read), in the console. Ruled accepted-legacy (J4 root-input). Invite, BeginEnrollmentAttempt and checkMembership, accepted-legacy, are called and not changed. The seal moves.
 # Task 6: mode selection and group references (internal/host joinmode.go: selectMode, productionModeFacts, relaunched and crossCheck: a group box stopped and started since its enrollment, or whose host.env disagrees with the roster, is held; boxreference.go, metrics_loop.go's metricsBoxLabel and boxrecord.go's boxRecordLoop: a group box takes its group reference; internal/status fleet.go: the status listing drops reports no box has rewritten in a day before the key cap). statusLoop, accepted-legacy, is re-sealed as ruled: its tick is groupStatusTick, which publishes a group box's report only under its group reference, resolved afresh each tick, and nothing until it resolves; every other box's tick is statusTick's, unchanged. No other root-read unit is added or changed. The seal moves.
+# Task 7: root's half of joining (internal/host joinroot.go: carlos system join, which runs guard-arm in process; units carlos-system-join.path and .service; bootstrap's enabledUnits arms the path). joinroot.go adds no root-read unit: RunSystemGuardArm, authority F,J4, is called and not changed. The two unit files are new root inputs, ruled authority F,J4 on the condition that the path unit's file is only a trigger: root's join reads nothing from it. The legacy seal stands.
 @coverage	.	3678cfe22582d35fe0d8e93d3fed898cdffa6fb2ce2d81e0252c8a8da77993b3
 @coverage	internal/hostenv	a3f3274a86cdf8194afb871301c1ba3289dfbdc9b703dd547178273df151cdaa
 internal/hostenv/cli.go	runHostEnv	authority	A,J4	rev3=census-ruling-region-ha-2b;basis=carlos system host-env: write runs as root at a box's start-up and writes /etc/carlos/host.env, 0600, whole (temporary file and rename), from allowlisted parameters with safe values only, refusing a missing required value; its one read site is seed's --from file, an operator's host.env whose values go only to the parameter store; write's path is fixed and reached only through writeHostEnv	file:ReadFile@1500	df4470176b229b311927ff0ac4f268d354c3a2357cac4300ac0992d2ebb90899
 internal/hostenv/write_linux.go	checkedDir	authority	A,J4	rev3=census-ruling-region-ha-2b;basis=carlos system host-env write (root, Linux): opens one directory under one already held, O_NOFOLLOW|O_DIRECTORY, creating it 0755 first only when asked and missing, and refuses it unless fstat shows a directory owned by this process's user (root, as write requires) that no group or other can write	file:Fstat@576,file:Openat@168,file:Openat@372	012257ac0866f3385044785d8abae9c31e61134928c394e22a2aeaef39ec9f3c
 internal/hostenv/write_linux.go	writeHostEnv	authority	A,J4	rev3=census-ruling-region-ha-2b;basis=carlos system host-env write (root, Linux): writes /etc/carlos/host.env only through /etc and /etc/carlos held by descriptor and checked by checkedDir; the new file is created 0600, O_EXCL|O_NOFOLLOW, in that directory, synced, renamed over host.env within it, and the directory synced; its read sites open the root and the temporary file	file:Open@68,file:Openat@509	1245fc0f819ba73fb45545ffdcf92d5d4941802175e73bac209ab4d4238adff1
-@coverage	internal/host	7f17f1c86771c44641869ff808c4befe2698bbb6ed81c6acbcf1e8cfde570729
+@coverage	internal/host	7650e67baecb9b440c10993539befcb35992e55c48c87c90fbdff687818599f0
 internal/host/installchannel.go	channelInstall.run	authority	F,J4	rev3=census-ruling-region-ha-2b;basis=carlos system install-channel (root, at a fresh box's start-up): its one read site opens root's apply lock; the install goes through verifyPointer, hostfloor.CheckImage and stageCandidate into both protected slots, and refuses a box whose guard is armed or that already runs another release	file:OpenFile@196	f0cdb6dd28e83da282c3b0d7bc2012dcd80db181a8cecbf1a2db6bcc41d1d5e3
 @coverage	internal/hostactors	a1088ca1b4e6bac902a9d2419c101181860381b7749596f2c3a9392894cee91d
 @coverage	internal/hostactors/awsmembership	a3defad7179245492a3c1dcfce5e20f0f85db2e240b474c654acf211da16df98
@@ -19,3 +20,6 @@
 @coverage	internal/status	a1eee66fc906d6a7275017fd30c346b64aaafe4f88a501538248e99e9d95c33d
 @legacy	30a3ee6b3365c202bf93b26087888a582c6b8d6df1a9f1fc6ba12e639ddd8684	internal/host/agent.go	statusLoop	legacy	accepted-legacy	accepted-legacy;rev3=census-ruling-region-ha-2b;basis=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos): main's body with its tick through groupStatusTick, so a box whose host.env carries the group marker (CARLOS_BOX_REFERENCE=group) publishes its status report only under its group reference, resolved afresh each tick, and nothing until it resolves, never under status.Label; every other box, fleet boxes included, gets statusTick with today's label and key; read sites unchanged	whole:Route@6154,whole:Route@6181,whole:Route@7423,whole:Route@9437	666ddbc4779442db4c868253cf5062c690e20ad643197688305e0742f6de9283
 internal/host/agent.go	statusLoop	legacy	accepted-legacy	accepted-legacy;rev3=census-ruling-region-ha-2b;basis=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos): main's body with its tick through groupStatusTick, so a box whose host.env carries the group marker (CARLOS_BOX_REFERENCE=group) publishes its status report only under its group reference, resolved afresh each tick, and nothing until it resolves, never under status.Label; every other box, fleet boxes included, gets statusTick with today's label and key; read sites unchanged	whole:Route@6154,whole:Route@6181,whole:Route@7423,whole:Route@9437	666ddbc4779442db4c868253cf5062c690e20ad643197688305e0742f6de9283
+@coverage	internal/host/units	18a7bb1fe57b2044517e7acd3f97780beae35fd55e695c593f87a4ae7f3af27d
+internal/host/units/carlos-system-join.path	external	authority	F,J4	rev3=census-ruling-region-ha-2b;basis=systemd (PID 1) starts carlos-system-join.service when the joining agent touches /data/system-join-requested; the file is only a trigger: root reads nothing from it and takes no parameter from it	file:external@0	8af1bea945a0ec5a475b766e6b55d7168bf6eff1bf963bd5c7d46f90130f4c5c
+internal/host/units/carlos-system-join.service	external	authority	F,J4	rev3=census-ruling-region-ha-2b;basis=systemd (PID 1) runs carlos system join as root: it verifies the signed invitation and this box's identity record itself, then runs guard-arm with the identity install-channel kept	file:external@0	98c405ad69c7fd3e265ddfcaf176b0a5b98f0e61e35c4ae29a553211f63d230b
````

Run: `nice -n 19 go test ./internal/provision/ -run 'TestPlan9B8RecordWriteCensus|TestPlan14RootReadCensus$' -count=1 && nice -n 19 go test ./internal/console/ -run TestClaimAuthoritySourceCensus -count=1`
Expected: PASS.

- [ ] **Step 8: Commit**

```bash
git add internal/host/joinroot.go internal/host/joinroot_test.go internal/host/units/carlos-system-join.path internal/host/units/carlos-system-join.service internal/host/bootstrap.go internal/host/host_test.go region_ha_boundary_test.go approved_region_ha_2b_copy_test.go internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv
git commit -m "region HA 2b: root's half of joining"
```

---

### Task 8: Joining mode, the gate, and the hosting flag

**Files:**
- Create: `internal/host/joining.go`, `internal/host/joining_test.go`
- Modify: `internal/host/agent.go` (`RunAgent` only), `internal/host/claimwiring.go`, `internal/host/claimwiring_hosting_test.go`
- Modify: `region_ha_boundary_test.go`, the overlay

**Interfaces:**
- Consumes: `selectMode`, `productionModeFacts`, `agentHosting` (Task 6); `boxRecordLoop`, `claimReport`, `status.CollectBox`, `status.WriteBox`, `openStatusStore`, `cachedInstanceID`, `boxSystemChannel`, `lbhealth.Serve`, `lbhealth.Default`.
- Produces: `joining{facts, every, serve, record, report, requestArm, logf}` and its `run(ctx) (modeDecision, error)`; `productionJoining(args, facts, getenv)`; `joiningHTTPAddr(args)`; `decideAgentMode(ctx, getenv, open, join, wait, logf) bool`; `hostingGate(args) bool`; consts `joinEvery = 15s`, `joinNudgeEvery = 1m`, `joinRequestMarker`.

`RunAgent` asks the gate first. With claim mode or region HA off it returns "hosting" at once and `runAgent` starts as on main; `runAgent` itself is not changed. Otherwise the gate selects the mode, waiting out an unknown one, and on joining runs joining until the box can host, then returns, so `RunAgent` ends and systemd (`Restart=always`) starts the agent again through the guard's drop-in, as the claim agent.

Joining runs the identity record loop, a listener on the edge's `-http` address that answers only `/.carlos/lb-health` (503: `lbhealth.Default` is cleared at the start and never set), and a check every 15 seconds. Invited, it touches `/data/system-join-requested` (Task 7's path unit), at most once a minute. Guard installed or reported, it publishes the guard-ready report: the status report every hosting box publishes, with the guard's facts from `claimReport`, once a minute so it stays inside the console's 15-minute window. A group box writes it under its group reference (Task 6), the key the console reads it from. It never starts the edges file loop, opens no registry and starts no activator.

On a hosting box, `ClaimWiring.startRegional` now reports the gate's decision to the health answer, before the claim worker starts, and clears it when the worker ends (2a's seam). A hosting box does nothing for membership: the console reads it (H28).

- [ ] **Step 1: Write the failing tests**

`internal/host/joining_test.go`:

````go
package host

import (
	"context"
	"errors"
	"io"
	"io/fs"
	"net"
	"net/http"
	"os"
	"path/filepath"
	"strings"
	"sync"
	"testing"
	"time"

	"github.com/carlosframework/platform/internal/hostactors"
	"github.com/carlosframework/platform/internal/hostfloor"
	"github.com/carlosframework/platform/internal/lbhealth"
	"github.com/carlosframework/platform/internal/status"
)

// stagedFacts walks a box through the joining stages, one per selectMode
// call: no invitation, invited, guard installed, guard-ready reported,
// then hosting.
type stagedFacts struct {
	mu    sync.Mutex
	calls int
	steps []joinFacts
}

func (s *stagedFacts) facts() modeFacts {
	pick := func() joinFacts {
		s.mu.Lock()
		defer s.mu.Unlock()
		i := min(s.calls/5, len(s.steps)-1) // each stage for five checks
		s.calls++
		return s.steps[i]
	}
	cur := joinFacts{}
	f := modeFacts{box: "ada-hiking", now: time.Now}
	f.enrollment = func() (hostfloor.Enrollment, error) { cur = pick(); return cur.facts().enrollment() }
	f.roster = func(ctx context.Context) (hostactors.Roster, error) { return cur.facts().roster(ctx) }
	f.guard = func() (hostfloor.Running, error) { return cur.facts().guard() }
	f.invited = func(ctx context.Context, b string) (bool, error) { return cur.facts().invited(ctx, b) }
	f.report = func(ctx context.Context, b string) (status.Box, error) { return cur.facts().report(ctx, b) }
	return f
}

func freePort(t *testing.T) string {
	t.Helper()
	l, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Skipf("no TCP listener in this sandbox: %v", err)
	}
	addr := l.Addr().String()
	l.Close()
	return addr
}

// Joining publishes no edges file and admits nothing: it answers the load
// balancer 503 throughout, asks root to arm the guard once invited,
// publishes its report once armed, and returns as soon as it can host.
func TestJoiningServes503UntilItCanHostAndPublishesNoEdgesFile(t *testing.T) {
	deploy, data := t.TempDir(), t.TempDir()
	t.Setenv("CARLOS_DEPLOYMENT_DIR", deploy)
	t.Setenv("CARLOS_DATA_DIR", data)
	t.Cleanup(func() { lbhealth.Default.SetHosting(false) })
	addr := freePort(t)
	f := enrolledFacts()
	f.report = freshReport(time.Now())
	steps := &stagedFacts{steps: []joinFacts{{}, {invited: true}, guardInstalled(), func() joinFacts { g := guardInstalled(); g.report = freshReport(time.Now()); return g }(), f}}
	j := productionJoining([]string{"-http", addr, "-https", ":0"}, steps.facts(), os.Getenv)
	j.every = 20 * time.Millisecond
	var mu sync.Mutex
	var reports int
	records := make(chan struct{}, 1)
	j.record = func(context.Context) { records <- struct{}{} }
	j.report = func(context.Context) error { mu.Lock(); reports++; mu.Unlock(); return nil }
	j.logf = t.Logf
	lbhealth.Default.SetHosting(true) // a stale flag from an earlier process state is cleared
	done := make(chan modeDecision, 1)
	go func() { d, _ := j.run(t.Context()); done <- d }()
	// The health check answers 503, naming hosting, while joining.
	var body string
	for i := 0; i < 100; i++ {
		resp, err := http.Get("http://" + addr + lbhealth.Path)
		if err == nil {
			b, _ := io.ReadAll(resp.Body)
			resp.Body.Close()
			if resp.StatusCode != http.StatusServiceUnavailable {
				t.Fatalf("joining box answered %d", resp.StatusCode)
			}
			body = string(b)
			break
		}
		time.Sleep(10 * time.Millisecond)
	}
	if !strings.Contains(body, lbhealth.CondHosting) {
		t.Fatalf("health body %q", body)
	}
	if resp, err := http.Get("http://" + addr + "/"); err != nil || resp.StatusCode != http.StatusNotFound {
		t.Fatalf("a joining box served something other than its health check: %v %v", resp, err)
	}
	select {
	case d := <-done:
		if d.Mode != modeHosting {
			t.Fatalf("returned %+v", d)
		}
	case <-time.After(10 * time.Second):
		t.Fatal("joining never returned")
	}
	<-records
	if _, err := os.Stat(filepath.Join(data, joinRequestMarker)); err != nil {
		t.Fatalf("root was never asked to arm the guard: %v", err)
	}
	mu.Lock()
	if reports == 0 {
		t.Fatal("the guard-ready report was never published")
	}
	mu.Unlock()
	if lbhealth.Default.Hosting() {
		t.Fatal("joining marked the box hosting")
	}
	err := filepath.WalkDir(deploy, func(p string, d fs.DirEntry, err error) error {
		if err == nil && strings.Contains(filepath.ToSlash(p), "control/edges") {
			t.Errorf("joining wrote %s", p)
		}
		return nil
	})
	if err != nil {
		t.Fatal(err)
	}
}

func TestDecideAgentMode(t *testing.T) {
	on := func(k string) string {
		switch k {
		case "CARLOS_HOST_CLAIM", "CARLOS_REGION_HA":
			return "1"
		}
		return ""
	}
	noHA := func(k string) string {
		if k == "CARLOS_HOST_CLAIM" {
			return "1"
		}
		return ""
	}
	never := func(context.Context, func(string) string) (modeFacts, error) {
		t.Fatal("facts read with joining mode off")
		return modeFacts{}, nil
	}
	noJoin := func(context.Context, modeFacts, modeDecision) error { t.Fatal("joined"); return nil }
	quiet := func(string, ...any) {}
	ctx := context.Background()
	for name, env := range map[string]func(string) string{"claim off": func(string) string { return "" }, "region HA off": noHA} {
		if !decideAgentMode(ctx, env, never, noJoin, time.Millisecond, quiet) {
			t.Fatalf("%s: not hosting", name)
		}
	}
	open := func(f joinFacts) func(context.Context, func(string) string) (modeFacts, error) {
		return func(context.Context, func(string) string) (modeFacts, error) { return f.facts(), nil }
	}
	if !decideAgentMode(ctx, on, open(enrolledFacts()), noJoin, time.Millisecond, quiet) {
		t.Fatal("an enrolled, armed box does not host")
	}
	var joined modeDecision
	join := func(_ context.Context, _ modeFacts, d modeDecision) error { joined = d; return nil }
	if decideAgentMode(ctx, on, open(joinFacts{invited: true}), join, time.Millisecond, quiet) || joined.Stage != stageInvited {
		t.Fatalf("an invited box: joined %+v", joined)
	}
	// Unknown, then known: the gate waits, asks again, and logs the
	// reason once.
	reads, logs := 0, 0
	flaky := func(context.Context, func(string) string) (modeFacts, error) {
		reads++
		f := enrolledFacts()
		if reads < 3 {
			f.rosterErr = errors.New("connection reset")
		}
		return f.facts(), nil
	}
	if !decideAgentMode(ctx, on, flaky, noJoin, time.Millisecond, func(string, ...any) { logs++ }) || reads != 3 || logs != 1 {
		t.Fatalf("flaky roster: %d reads, %d log lines", reads, logs)
	}
	// Stopped while unknown: no claim agent.
	stopped, cancel := context.WithCancel(ctx)
	cancel()
	failing := func(context.Context, func(string) string) (modeFacts, error) {
		return modeFacts{}, errors.New("no bucket")
	}
	if decideAgentMode(stopped, on, failing, noJoin, time.Hour, quiet) {
		t.Fatal("a stopped gate started the claim agent")
	}
}
````

`internal/host/claimwiring_hosting_test.go` (replace the whole file):

````go
package host

import (
	"testing"

	"github.com/carlosframework/platform/internal/hostwrite"
	"github.com/carlosframework/platform/internal/lbhealth"
)

// Region HA spec 2 §5, §6: the health answer's hosting condition is the
// mode RunAgent's gate chose (agentHosting), held only while the claim
// worker runs. Claim mode off never says hosting, a joining process never
// does, and a hosting box's answer turns 503 when its worker ends.
func TestClaimWiringHostingFollowsTheModeAndTheWorker(t *testing.T) {
	lbhealth.Default.SetHosting(false)
	was := agentHosting.Load()
	t.Cleanup(func() { lbhealth.Default.SetHosting(false); agentHosting.Store(was) })

	agentHosting.Store(true)
	off := &ClaimWiring{}
	if err := off.startRegional(t.Context(), nil, nil); err != nil {
		t.Fatal(err)
	}
	if lbhealth.Default.Hosting() {
		t.Fatal("claim mode off marked the box hosting")
	}

	start := func() *ClaimWiring {
		loop := testRegionalLoop{started: make(chan struct{})}
		w := &ClaimWiring{Writes: hostwrite.Config{Enabled: true}, Providers: ClaimProviders{Regional: loop}}
		if err := w.startRegional(t.Context(), nil, nil); err != nil {
			t.Fatal(err)
		}
		<-loop.started
		return w
	}

	agentHosting.Store(false)
	w := start()
	if lbhealth.Default.Hosting() {
		t.Fatal("a process that is not hosting marked the box hosting")
	}
	if err := w.close(t.Context()); err != nil {
		t.Fatal(err)
	}

	agentHosting.Store(true)
	w = start()
	if !lbhealth.Default.Hosting() {
		t.Fatal("the claim agent of an enrolled box is not hosting")
	}
	if err := w.close(t.Context()); err != nil {
		t.Fatal(err)
	}
	if lbhealth.Default.Hosting() {
		t.Fatal("the box still says hosting after its claim worker ended")
	}
}
````

- [ ] **Step 2: Run them to see them fail**

Run: `nice -n 19 go test ./internal/host/ -run 'TestJoining|TestDecideAgentMode|TestClaimWiring' -count=1`
Expected: FAIL, the package does not build (`undefined: productionJoining`).

- [ ] **Step 3: Write joining, the gate and the wiring**

`internal/host/joining.go`:

````go
package host

import (
	"context"
	"errors"
	"flag"
	"io"
	"log"
	"net/http"
	"os"
	"os/signal"
	"path/filepath"
	"runtime"
	"strings"
	"syscall"
	"time"

	"github.com/carlosframework/platform/internal/hostwrite"
	"github.com/carlosframework/platform/internal/lbhealth"
	"github.com/carlosframework/platform/internal/status"
	"github.com/carlosframework/platform/internal/version"
)

// The agent's joining mode (region HA spec 2 §5 step 4) and the gate that
// chooses it (joinmode.go).
//
// A joining box publishes its signed identity record (boxRecordLoop) and,
// once its guard is armed, its guard-ready report: the private status
// report every hosting box publishes, carrying the guard's facts
// (claimReport). It never publishes an edges file: a box an edges file
// names is referenced, and automatic enrollment refuses a referenced box
// (hostactors references.go, enroll.go). It opens no registry, starts no
// activator and serves no instance. Its only listener is the load
// balancer's health check on the edge's HTTP address, which answers 503:
// lbhealth.Default is never marked hosting here.
//
// When the box is invited it asks root to arm the guard by touching
// joinRequestMarker (carlos-system-join.path). Once a start check finds it
// enrolled and armed, it returns, and RunAgent ends the process so systemd
// starts the agent again, now as the claim agent through the guard.

// joinEvery is how often joining checks its mode.
const joinEvery = 15 * time.Second

// joinNudgeEvery is how often joining re-publishes its report or re-asks
// root, at most: the report stays well inside reportFreshness, and root
// is not asked again while it is still arming.
const joinNudgeEvery = time.Minute

// joinRequestMarker is the file carlos-system-join.path watches, under the
// data dir.
const joinRequestMarker = "system-join-requested"

// joining is one joining run's steps; production's are productionJoining.
type joining struct {
	facts modeFacts
	every time.Duration
	// serve answers the load balancer until ctx ends.
	serve func(ctx context.Context) error
	// record publishes the box's identity record, retrying until it is in
	// place.
	record func(ctx context.Context)
	// report publishes the guard-ready report.
	report func(ctx context.Context) error
	// requestArm asks root to arm the guard.
	requestArm func() error
	logf       func(format string, args ...any)
}

// run joins until the box can host (the decision is returned) or ctx
// ends (the last decision, and ctx's error).
func (j joining) run(ctx context.Context) (modeDecision, error) {
	lbhealth.Default.SetHosting(false)
	go func() {
		if err := j.serve(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
			j.logf("agent: joining: the load balancer health check is not answered: %v", err)
		}
	}()
	go j.record(ctx)
	var last modeDecision
	var lastErr string
	var nudged time.Time
	for {
		d, err := selectMode(ctx, j.facts)
		now := j.facts.now()
		switch {
		case err != nil:
			if err.Error() != lastErr {
				j.logf("agent: joining: %v; checking again", err)
				lastErr = err.Error()
			}
		case d.Mode == modeHosting:
			j.logf("agent: joining: %s is enrolled and its guard is armed; restarting as the claim agent", d.Box)
			return d, nil
		default:
			lastErr = ""
			if d.Stage != last.Stage || d.Why != last.Why {
				j.logf("agent: joining: %s: %s %s", d.Box, d.Stage, d.Why)
				nudged = time.Time{}
			}
			last = d
			if now.Sub(nudged) >= joinNudgeEvery {
				switch d.Stage {
				case stageInvited:
					if err := j.requestArm(); err != nil {
						j.logf("agent: joining: could not ask root to arm the guard: %v", err)
					}
				case stageGuardInstalled, stageGuardReady:
					if err := j.report(ctx); err != nil {
						j.logf("agent: joining: the guard-ready report was not published: %v", err)
					}
				}
				nudged = now
			}
		}
		select {
		case <-ctx.Done():
			return last, ctx.Err()
		case <-time.After(j.every):
		}
	}
}

// productionJoining is joining on a real box. args are the agent's own
// (edge.Run's flags): the health check listens on -http, as the edge will.
func productionJoining(args []string, facts modeFacts, getenv func(string) string) joining {
	addr := joiningHTTPAddr(args)
	return joining{
		facts: facts,
		every: joinEvery,
		serve: func(ctx context.Context) error {
			srv := &http.Server{Addr: addr, ReadHeaderTimeout: 10 * time.Second, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
				if !lbhealth.Serve(w, r, lbhealth.Default, time.Now()) {
					http.NotFound(w, r)
				}
			})}
			go func() { <-ctx.Done(); _ = srv.Close() }()
			return srv.ListenAndServe()
		},
		record: func(context.Context) { boxRecordLoop() },
		report: func(ctx context.Context) error {
			store, _, err := openStatusStore(ctx, getenv)
			if err != nil {
				return err
			}
			p := status.Probes{MachineID: readMachineID(), InstanceID: cachedInstanceID(), Region: strings.TrimSpace(getenv("CARLOS_REGION")),
				Release: version.Build, Platform: runtime.GOOS + "-" + runtime.GOARCH, SystemChannel: boxSystemChannel(getenv)}
			claimReport(&p)
			box := status.CollectBox(nil, p, time.Now())
			if groupReferenced(getenv) {
				// Under the group reference the console derives (census
				// ruling region-ha-2b), as statusLoop publishes it.
				box.Label = facts.box
			}
			return status.WriteBox(ctx, store, box)
		},
		requestArm: func() error {
			return os.WriteFile(filepath.Join(dataDir(), joinRequestMarker), []byte(time.Now().UTC().Format(time.RFC3339Nano)+"\n"), 0o644)
		},
		logf: log.Printf,
	}
}

// joiningHTTPAddr is the agent's -http flag, ":80" when it is not given.
func joiningHTTPAddr(args []string) string {
	fs := flag.NewFlagSet("agent", flag.ContinueOnError)
	fs.SetOutput(io.Discard)
	addr := fs.String("http", ":80", "")
	fs.String("registry", "", "")
	fs.String("acme-cache", "", "")
	fs.String("https", "", "")
	fs.String("fallback", "", "")
	fs.Bool("dev", false, "")
	_ = fs.Parse(args)
	return *addr
}

// decideAgentMode is RunAgent's gate. With claim mode or region HA off
// there is no joining mode, and the agent hosts as on main. Otherwise it
// selects the mode, waiting out an unknown one, and on joining runs join
// until the box can host or ctx ends; it reports whether this process
// should go on to start the claim agent.
func decideAgentMode(ctx context.Context, getenv func(string) string, open func(context.Context, func(string) string) (modeFacts, error), join func(context.Context, modeFacts, modeDecision) error, wait time.Duration, logf func(string, ...any)) bool {
	if !hostwrite.Enabled(getenv) || !regionHAEnabled(getenv) {
		return true
	}
	var last string
	note := func(err error) bool {
		if err.Error() != last {
			logf("agent: mode: %v; checking again", err)
			last = err.Error()
		}
		select {
		case <-ctx.Done():
			return false
		case <-time.After(wait):
			return true
		}
	}
	for {
		facts, err := open(ctx, getenv)
		if err != nil {
			if !note(modeUnknown("the deployment's stores and this box's guard", err)) {
				return false
			}
			continue
		}
		d, err := selectMode(ctx, facts)
		if err != nil {
			if !note(err) {
				return false
			}
			continue
		}
		if d.Mode == modeHosting {
			return true
		}
		logf("agent: %s is joining (%s): it hosts nothing until it is enrolled", d.Box, d.Stage)
		if err := join(ctx, facts, d); err != nil && !errors.Is(err, context.Canceled) {
			logf("agent: joining: %v", err)
		}
		return false
	}
}

// hostingGate decides, for RunAgent, whether this process starts the claim
// agent, and records the answer in agentHosting. SIGTERM or SIGINT while
// joining ends the process cleanly.
func hostingGate(args []string) bool {
	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
	defer stop()
	hosting := decideAgentMode(ctx, os.Getenv, productionModeFacts, func(ctx context.Context, facts modeFacts, _ modeDecision) error {
		_, err := productionJoining(args, facts, os.Getenv).run(ctx)
		return err
	}, joinEvery, log.Printf)
	agentHosting.Store(hosting)
	return hosting
}
````

`internal/host/agent.go`:

````diff
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -210,7 +210,17 @@
 // edge.Run's (-registry, -dev, -http, -https, ...): the args pass through to
 // edge.RunWithActivator, which owns the serving loop and blocks until
 // SIGTERM/SIGINT.
-func RunAgent(args []string) { runAgent(args, productionClaimFactory) }
+//
+// With CARLOS_HOST_CLAIM and CARLOS_REGION_HA on, a box that is not yet
+// enrolled with its guard armed runs region HA's joining mode instead, and
+// RunAgent returns once it can host, so systemd starts it again as the
+// claim agent (hostingGate, joining.go).
+func RunAgent(args []string) {
+	if !hostingGate(args) {
+		return
+	}
+	runAgent(args, productionClaimFactory)
+}
 
 func runAgent(args []string, buildClaims ClaimFactory) {
 	agentCtx, cancelAgent := context.WithCancel(context.Background())
````

`internal/host/claimwiring.go`:

````diff
--- a/internal/host/claimwiring.go
+++ b/internal/host/claimwiring.go
@@ -94,13 +94,18 @@
 	workerCtx, cancel := context.WithCancel(ctx)
 	w.cancel = cancel
 	w.joined = make(chan struct{})
-	go func() { defer close(w.joined); w.Providers.Regional.Loop(workerCtx, serial, report) }()
-	// The claim agent is running: it refused to start without its
-	// host-actor enrollment and a matching roster row, so this box is
-	// hosting (region HA spec 2 §5, §6). Plan 2b's mode selection owns
-	// this flag from then on: joining mode leaves it false, and only an
-	// active roster entry with an armed guard sets it.
-	lbhealth.Default.SetHosting(true)
+	// The load balancer's hosting condition (region HA spec 2 §5, §6) is
+	// this process's mode, as RunAgent's gate chose it (agentHosting):
+	// the claim agent of an enrolled box with an armed guard. It is set
+	// before the worker starts and cleared when the worker ends, so a box
+	// whose claim worker has stopped never answers healthy.
+	hosting := agentHosting.Load()
+	lbhealth.Default.SetHosting(hosting)
+	go func() {
+		defer close(w.joined)
+		defer lbhealth.Default.SetHosting(false)
+		w.Providers.Regional.Loop(workerCtx, serial, report)
+	}()
 	return nil
 }
 func (w *ClaimWiring) close(ctx context.Context) error {
````

- [ ] **Step 4: Run the tests**

Run: `nice -n 19 go test ./internal/host/ -run 'TestJoining|TestDecideAgentMode|TestClaimWiring|TestSelectMode|TestRelaunched|TestRootJoin|TestInstallChannel' -count=1`
Expected: PASS. The joining test listens on a loopback port; in a sandbox with no TCP listener it skips.

- [ ] **Step 5: The provider boundary**

`region_ha_boundary_test.go`:

````diff
--- a/region_ha_boundary_test.go
+++ b/region_ha_boundary_test.go
@@ -43,6 +43,7 @@
 	"internal/host/boxreference.go",
 	"internal/status/fleet.go",
 	"internal/host/joinroot.go",
+	"internal/host/joining.go",
 }
 
 // regionHAAdapterFiles are spec 2's AWS adapters. Each is named for AWS so
````

Run: `nice -n 19 go test . -run 'TestRegionHA(Core|Adapters|S3)' -count=1`
Expected: PASS.

- [ ] **Step 6: Census**

No root-read unit changed or was added: `runAgent` is untouched, `RunAgent` and the claim wiring are not census units, and joining reads through `claimReport` (diagnostic, unchanged) and Task 6's facts.

`internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv`:

````diff
--- a/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv
+++ b/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv
@@ -5,12 +5,13 @@
 # Task 5: the console's automatic enrollment pass (internal/hostactors autoenroll.go: AutoEnroller: an enrolling write the deadline left unknown is settled by the next pass's roster read; internal/console enrollpass.go, started in StartClaimRecoveryPass with region HA on, and awsmembership.go, its membership reader). One new root-read unit, AutoEnroller.inputs, whose two read sites are the signed roster (Directory.Read) and the host floor (hostfloor.Read), in the console. Ruled accepted-legacy (J4 root-input). Invite, BeginEnrollmentAttempt and checkMembership, accepted-legacy, are called and not changed. The seal moves.
 # Task 6: mode selection and group references (internal/host joinmode.go: selectMode, productionModeFacts, relaunched and crossCheck: a group box stopped and started since its enrollment, or whose host.env disagrees with the roster, is held; boxreference.go, metrics_loop.go's metricsBoxLabel and boxrecord.go's boxRecordLoop: a group box takes its group reference; internal/status fleet.go: the status listing drops reports no box has rewritten in a day before the key cap). statusLoop, accepted-legacy, is re-sealed as ruled: its tick is groupStatusTick, which publishes a group box's report only under its group reference, resolved afresh each tick, and nothing until it resolves; every other box's tick is statusTick's, unchanged. No other root-read unit is added or changed. The seal moves.
 # Task 7: root's half of joining (internal/host joinroot.go: carlos system join, which runs guard-arm in process; units carlos-system-join.path and .service; bootstrap's enabledUnits arms the path). joinroot.go adds no root-read unit: RunSystemGuardArm, authority F,J4, is called and not changed. The two unit files are new root inputs, ruled authority F,J4 on the condition that the path unit's file is only a trigger: root's join reads nothing from it. The legacy seal stands.
+# Task 8: joining mode and the gate (internal/host joining.go; RunAgent calls hostingGate before runAgent; ClaimWiring.startRegional sets the health answer's hosting condition from the gate's decision and clears it when the worker ends). runAgent, authority B,C,D,F,J4, is not changed. No root-read unit changed or was added. Envelope-only, and the seal stands.
 @coverage	.	3678cfe22582d35fe0d8e93d3fed898cdffa6fb2ce2d81e0252c8a8da77993b3
 @coverage	internal/hostenv	a3f3274a86cdf8194afb871301c1ba3289dfbdc9b703dd547178273df151cdaa
 internal/hostenv/cli.go	runHostEnv	authority	A,J4	rev3=census-ruling-region-ha-2b;basis=carlos system host-env: write runs as root at a box's start-up and writes /etc/carlos/host.env, 0600, whole (temporary file and rename), from allowlisted parameters with safe values only, refusing a missing required value; its one read site is seed's --from file, an operator's host.env whose values go only to the parameter store; write's path is fixed and reached only through writeHostEnv	file:ReadFile@1500	df4470176b229b311927ff0ac4f268d354c3a2357cac4300ac0992d2ebb90899
 internal/hostenv/write_linux.go	checkedDir	authority	A,J4	rev3=census-ruling-region-ha-2b;basis=carlos system host-env write (root, Linux): opens one directory under one already held, O_NOFOLLOW|O_DIRECTORY, creating it 0755 first only when asked and missing, and refuses it unless fstat shows a directory owned by this process's user (root, as write requires) that no group or other can write	file:Fstat@576,file:Openat@168,file:Openat@372	012257ac0866f3385044785d8abae9c31e61134928c394e22a2aeaef39ec9f3c
 internal/hostenv/write_linux.go	writeHostEnv	authority	A,J4	rev3=census-ruling-region-ha-2b;basis=carlos system host-env write (root, Linux): writes /etc/carlos/host.env only through /etc and /etc/carlos held by descriptor and checked by checkedDir; the new file is created 0600, O_EXCL|O_NOFOLLOW, in that directory, synced, renamed over host.env within it, and the directory synced; its read sites open the root and the temporary file	file:Open@68,file:Openat@509	1245fc0f819ba73fb45545ffdcf92d5d4941802175e73bac209ab4d4238adff1
-@coverage	internal/host	7650e67baecb9b440c10993539befcb35992e55c48c87c90fbdff687818599f0
+@coverage	internal/host	43a8c2fe1403991e94c9254c9c08b3fd71dc16e5dc8225356d96042765e42022
 internal/host/installchannel.go	channelInstall.run	authority	F,J4	rev3=census-ruling-region-ha-2b;basis=carlos system install-channel (root, at a fresh box's start-up): its one read site opens root's apply lock; the install goes through verifyPointer, hostfloor.CheckImage and stageCandidate into both protected slots, and refuses a box whose guard is armed or that already runs another release	file:OpenFile@196	f0cdb6dd28e83da282c3b0d7bc2012dcd80db181a8cecbf1a2db6bcc41d1d5e3
 @coverage	internal/hostactors	a1088ca1b4e6bac902a9d2419c101181860381b7749596f2c3a9392894cee91d
 @coverage	internal/hostactors/awsmembership	a3defad7179245492a3c1dcfce5e20f0f85db2e240b474c654acf211da16df98
````

Run: `nice -n 19 go test ./internal/provision/ -run 'TestPlan9B8RecordWriteCensus|TestPlan14RootReadCensus$' -count=1 && nice -n 19 go test ./internal/console/ -run TestClaimAuthoritySourceCensus -count=1`
Expected: PASS.

- [ ] **Step 7: Commit**

```bash
git add internal/host/joining.go internal/host/joining_test.go internal/host/agent.go internal/host/claimwiring.go internal/host/claimwiring_hosting_test.go region_ha_boundary_test.go internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv
git commit -m "region HA 2b: joining mode, the gate and the hosting flag"
```

---

### Task 9: The `carlos system` verbs and the docs

**Files:**
- Modify: `system.go`, `system_test.go`
- Modify: `docs/site/cli.md`
- Modify: `approved_region_ha_2b_copy_test.go`, the overlay

**Interfaces:**
- Consumes: `host.RunSystemInstallChannel` (Task 3), `host.RunSystemJoin` (Task 7), `hostenv.RunSystemHostEnv` (Task 2).
- Produces: `carlos system install-channel`, `carlos system join`, `carlos system host-env write|seed`; their usage lines; the docs section "Boxes a region's group launches".

This task writes rows RHB-C50 to RHB-C61 of the copy record. Build it only once Paul has approved them, and where his text differs from a draft, use his in `system.go` and `docs/site/cli.md` too: the strings in the code below are the drafts. Adding the ids to `regionHA2bCopyBuilt` makes the gate hold the code to the approved text.

- [ ] **Step 1: Write the failing test**

`system_test.go`:

````diff
--- a/system_test.go
+++ b/system_test.go
@@ -30,6 +30,11 @@
 		{[]string{"layout", "migrate", "--box", "x"}, "layout", "router", 3},
 		{[]string{"layout", "revert", "--force", "--yes"}, "layout", "router", 3},
 		{[]string{"layout", "status"}, "layout", "router", 1},
+		// Region HA joining (spec 2 §5): host-env is a family; join and
+		// install-channel take no arguments.
+		{[]string{"host-env", "write", "--deployment", "flagship", "--region", "ie"}, "host-env", "router", 5},
+		{[]string{"install-channel"}, "install-channel", "router", 0},
+		{[]string{"join"}, "join", "router", 0},
 		{[]string{}, "", "router", 0},
 		{[]string{"router"}, "", "router", 0},
 	}
````

- [ ] **Step 2: Run it to see it fail**

Run: `nice -n 19 go test . -run TestSystemVerb -count=1`
Expected: FAIL: `systemVerb([host-env write ...]) = "" ...` and the same for `install-channel` and `join`.

- [ ] **Step 3: Wire the verbs and the usage**

`system.go`:

````diff
--- a/system.go
+++ b/system.go
@@ -15,6 +15,7 @@
 	"time"
 
 	"github.com/carlosframework/platform/internal/host"
+	"github.com/carlosframework/platform/internal/hostenv"
 )
 
 // carlos system: what is not an app (spec §0, the console-as-an-app
@@ -49,7 +50,7 @@
 	// untouched in rest for runSystemAntivirusVerb to read.
 	// layout is a family too: migrate|revert|status stay in rest for
 	// runSystemLayout.
-	case "files", "guard-arm", "guard-exec", "journal-migrate", "floor-allows-prev", "update", "status", "rollback", "cancel-activation", "apply", "antivirus", "layout", "geoip", "seeds-floor", "legacy-routes", "box", "group", "help", "-h", "--help":
+	case "files", "guard-arm", "guard-exec", "journal-migrate", "floor-allows-prev", "update", "status", "rollback", "cancel-activation", "apply", "antivirus", "layout", "geoip", "seeds-floor", "legacy-routes", "box", "group", "install-channel", "join", "host-env", "help", "-h", "--help":
 		return args[0], args[1:], component
 	}
 	return "", args, component
@@ -94,6 +95,15 @@
 		runSystemBoxVerb(rest)
 	case "group":
 		runSystemGroupVerb(rest)
+	case "install-channel":
+		// A joining box's own release (region HA spec 2 §5 step 3); root.
+		host.RunSystemInstallChannel(rest)
+	case "join":
+		// Root's half of joining, behind carlos-system-join.service.
+		host.RunSystemJoin(rest)
+	case "host-env":
+		// host.env from and into the deployment's parameters (§5 step 2).
+		hostenv.RunSystemHostEnv(rest)
 	case "help", "-h", "--help":
 		systemUsage()
 	default:
@@ -130,6 +140,7 @@
        carlos system legacy-routes assign --name <name> --to <account>/<app>
        carlos system box role <box> <default|legacy|platform>
        carlos system group role <region> <default|legacy>
+       carlos system host-env <write|seed> --deployment <name> --region <code> [flags]
 
 The platform's own binary (ops account owners and CARLOS_SYSTEM_OPERATORS):
 
@@ -152,6 +163,13 @@
   group role <region> <default|legacy>
             the same for every box a region's group has enrolled, and for
             every box it enrolls later
+
+A box a region's group launches (region HA):
+
+  host-env write   build host.env from the deployment's parameters (root)
+  host-env seed    copy a box's host.env into the deployment's parameters
+  install-channel  install the release this box's channel names (root)
+  join             (root oneshot behind carlos-system-join.service; not for operators)
 
 Deployment-wide settings (CARLOS_STORE_OPERATORS):
 
````

- [ ] **Step 4: Run the test**

Run: `nice -n 19 go test . -run 'TestSystemVerb|TestRegionHA(Core|Adapters|S3)' -count=1`
Expected: PASS.

- [ ] **Step 5: The docs**

`docs/site/cli.md`:

````diff
--- a/docs/site/cli.md
+++ b/docs/site/cli.md
@@ -2213,6 +2213,29 @@
 
 Ops account owners and `CARLOS_SYSTEM_OPERATORS`.
 
+#### Boxes a region's group launches
+
+In a region with HA on, the boxes its Auto Scaling group launches set themselves up with no hand steps. A box's start-up runs two commands as root before it starts the agent.
+
+carlos system host-env write builds /etc/carlos/host.env from the deployment's parameters: SecureStrings in AWS SSM under /carlos/<deployment>/<region>/ and, for the values every region shares, /carlos/<deployment>/shared/. If a required value is missing, it stops and writes nothing.
+
+carlos system install-channel installs the release the box's channel names, as both carlos and carlos.prev, once it passes the published host floor. A box that already runs a release is refused: carlos system update moves it.
+
+```sh
+carlos system host-env write --deployment prod --region ie
+carlos system install-channel
+```
+
+The agent then joins. It publishes the box's identity record and hosts nothing. Once the console has checked with AWS that the new box belongs to the region's group, it invites the box. The box arms its host lifecycle guard, the console checks the group again and enrolls it, and the box restarts as a hosting box. Only then does the load balancer send it traffic.
+
+carlos system host-env seed copies a box's existing host.env into those parameters, once per region. It writes the region's own values and any shared value not yet set. It refuses to change a shared value another region has set.
+
+```sh
+carlos system host-env seed --deployment prod --region ie --kms-key alias/carlos-prod-config
+```
+
+Seeding needs ssm:GetParametersByPath and ssm:PutParameter on the deployment's two parameter paths, and kms:Encrypt and kms:Decrypt on its configuration key.
+
 ### carlos move
 
 Moves one instance to another region. The instance keeps serving while its
````

- [ ] **Step 6: Mark the rows built**

Add RHB-C50 to RHB-C61 to `regionHA2bCopyBuilt`, in the same commit as their text:

`approved_region_ha_2b_copy_test.go`:

````diff
--- a/approved_region_ha_2b_copy_test.go
+++ b/approved_region_ha_2b_copy_test.go
@@ -38,7 +38,7 @@
 // regionHA2bCopyBuilt is every id whose text the code or docs carry. The
 // task that writes a row's text adds its ids here in the same commit, once
 // Paul has approved the rows.
-var regionHA2bCopyBuilt = []string{"RHB-C1", "RHB-C2", "RHB-C3", "RHB-C4", "RHB-C5", "RHB-C6", "RHB-C7", "RHB-C8", "RHB-C9", "RHB-C10", "RHB-C11", "RHB-C12", "RHB-C13", "RHB-C14", "RHB-C15", "RHB-C16", "RHB-C17", "RHB-C18", "RHB-C64", "RHB-C67", "RHB-C68", "RHB-C19", "RHB-C20", "RHB-C21", "RHB-C22", "RHB-C23", "RHB-C24", "RHB-C25", "RHB-C26", "RHB-C27", "RHB-C28", "RHB-C29", "RHB-C30", "RHB-C31", "RHB-C32", "RHB-C33", "RHB-C34", "RHB-C35", "RHB-C36", "RHB-C37", "RHB-C38", "RHB-C39", "RHB-C40", "RHB-C41", "RHB-C42", "RHB-C43", "RHB-C44", "RHB-C45", "RHB-C46", "RHB-C47", "RHB-C48", "RHB-C49", "RHB-C62", "RHB-C63"}
+var regionHA2bCopyBuilt = []string{"RHB-C1", "RHB-C2", "RHB-C3", "RHB-C4", "RHB-C5", "RHB-C6", "RHB-C7", "RHB-C8", "RHB-C9", "RHB-C10", "RHB-C11", "RHB-C12", "RHB-C13", "RHB-C14", "RHB-C15", "RHB-C16", "RHB-C17", "RHB-C18", "RHB-C64", "RHB-C67", "RHB-C68", "RHB-C19", "RHB-C20", "RHB-C21", "RHB-C22", "RHB-C23", "RHB-C24", "RHB-C25", "RHB-C26", "RHB-C27", "RHB-C28", "RHB-C29", "RHB-C30", "RHB-C31", "RHB-C32", "RHB-C33", "RHB-C34", "RHB-C35", "RHB-C36", "RHB-C37", "RHB-C38", "RHB-C39", "RHB-C40", "RHB-C41", "RHB-C42", "RHB-C43", "RHB-C44", "RHB-C45", "RHB-C46", "RHB-C47", "RHB-C48", "RHB-C49", "RHB-C62", "RHB-C63", "RHB-C50", "RHB-C51", "RHB-C52", "RHB-C53", "RHB-C54", "RHB-C55", "RHB-C56", "RHB-C57", "RHB-C58", "RHB-C59", "RHB-C60", "RHB-C61"}
 
 func mustParseRegionHA2bRecord(t *testing.T) []copyRow {
 	t.Helper()
````

Run: `nice -n 19 go test . -run 'RegionHA2b' -count=1`
Expected: PASS once the rows are approved. While any built row is pending, `TestRegionHA2bBuiltCopyIsApproved` fails naming each one, and nothing else fails.

- [ ] **Step 7: Census**

`runSystem` and `systemVerb` are re-digested; class, task, basis and (empty) kinds are unchanged.

`internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv`:

````diff
--- a/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv
+++ b/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv
@@ -6,7 +6,8 @@
 # Task 6: mode selection and group references (internal/host joinmode.go: selectMode, productionModeFacts, relaunched and crossCheck: a group box stopped and started since its enrollment, or whose host.env disagrees with the roster, is held; boxreference.go, metrics_loop.go's metricsBoxLabel and boxrecord.go's boxRecordLoop: a group box takes its group reference; internal/status fleet.go: the status listing drops reports no box has rewritten in a day before the key cap). statusLoop, accepted-legacy, is re-sealed as ruled: its tick is groupStatusTick, which publishes a group box's report only under its group reference, resolved afresh each tick, and nothing until it resolves; every other box's tick is statusTick's, unchanged. No other root-read unit is added or changed. The seal moves.
 # Task 7: root's half of joining (internal/host joinroot.go: carlos system join, which runs guard-arm in process; units carlos-system-join.path and .service; bootstrap's enabledUnits arms the path). joinroot.go adds no root-read unit: RunSystemGuardArm, authority F,J4, is called and not changed. The two unit files are new root inputs, ruled authority F,J4 on the condition that the path unit's file is only a trigger: root's join reads nothing from it. The legacy seal stands.
 # Task 8: joining mode and the gate (internal/host joining.go; RunAgent calls hostingGate before runAgent; ClaimWiring.startRegional sets the health answer's hosting condition from the gate's decision and clears it when the worker ends). runAgent, authority B,C,D,F,J4, is not changed. No root-read unit changed or was added. Envelope-only, and the seal stands.
-@coverage	.	3678cfe22582d35fe0d8e93d3fed898cdffa6fb2ce2d81e0252c8a8da77993b3
+# Task 9 (system.go): the install-channel, join and host-env verbs join systemVerb's list and runSystem's dispatch, and the usage gains their lines. The two seed-overlay authority declarations are re-digested, class, task, basis and (empty) kinds unchanged: no root read is added.
+@coverage	.	374a07016556167813b331508ee60f696872c43a09ba10904b6f3d949e0b70a6
 @coverage	internal/hostenv	a3f3274a86cdf8194afb871301c1ba3289dfbdc9b703dd547178273df151cdaa
 internal/hostenv/cli.go	runHostEnv	authority	A,J4	rev3=census-ruling-region-ha-2b;basis=carlos system host-env: write runs as root at a box's start-up and writes /etc/carlos/host.env, 0600, whole (temporary file and rename), from allowlisted parameters with safe values only, refusing a missing required value; its one read site is seed's --from file, an operator's host.env whose values go only to the parameter store; write's path is fixed and reached only through writeHostEnv	file:ReadFile@1500	df4470176b229b311927ff0ac4f268d354c3a2357cac4300ac0992d2ebb90899
 internal/hostenv/write_linux.go	checkedDir	authority	A,J4	rev3=census-ruling-region-ha-2b;basis=carlos system host-env write (root, Linux): opens one directory under one already held, O_NOFOLLOW|O_DIRECTORY, creating it 0755 first only when asked and missing, and refuses it unless fstat shows a directory owned by this process's user (root, as write requires) that no group or other can write	file:Fstat@576,file:Openat@168,file:Openat@372	012257ac0866f3385044785d8abae9c31e61134928c394e22a2aeaef39ec9f3c
@@ -24,3 +25,5 @@
 @coverage	internal/host/units	18a7bb1fe57b2044517e7acd3f97780beae35fd55e695c593f87a4ae7f3af27d
 internal/host/units/carlos-system-join.path	external	authority	F,J4	rev3=census-ruling-region-ha-2b;basis=systemd (PID 1) starts carlos-system-join.service when the joining agent touches /data/system-join-requested; the file is only a trigger: root reads nothing from it and takes no parameter from it	file:external@0	8af1bea945a0ec5a475b766e6b55d7168bf6eff1bf963bd5c7d46f90130f4c5c
 internal/host/units/carlos-system-join.service	external	authority	F,J4	rev3=census-ruling-region-ha-2b;basis=systemd (PID 1) runs carlos system join as root: it verifies the signed invitation and this box's identity record itself, then runs guard-arm with the identity install-channel kept	file:external@0	98c405ad69c7fd3e265ddfcaf176b0a5b98f0e61e35c4ae29a553211f63d230b
+system.go	runSystem	authority	B,C,D,F,J4	rev3=PROPOSED-seed; command arguments, claim flags, service identity and root delegation capability before constructing agent-mode resources; one-shot skips Configure, Start, duties and listeners; close resources only after private runtime joins		85cee6496231fa0ecf7648dfab68b7a082902f023a951851682c56b4de06adf7
+system.go	systemVerb	authority	B,C,D,F,J4	rev3=PROPOSED-seed; command arguments, claim flags, service identity and root delegation capability before constructing agent-mode resources; one-shot skips Configure, Start, duties and listeners; close resources only after private runtime joins		79c67d7158b89c2b24ab59a72c0ab036f6b3450911959e8c4944e3d9bc36c2c9
````

Run: `nice -n 19 go test ./internal/provision/ -run 'TestPlan9B8RecordWriteCensus|TestPlan14RootReadCensus$' -count=1 && nice -n 19 go test ./internal/console/ -run TestClaimAuthoritySourceCensus -count=1`
Expected: PASS.

- [ ] **Step 8: Commit**

```bash
git add system.go system_test.go docs/site/cli.md approved_region_ha_2b_copy_test.go internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv
git commit -m "region HA 2b: the carlos system verbs and the docs"
```

---

## Before handing off

- [ ] Every task's census checks pass on the final tree, and the copy gate passes: every built row is approved, so `TestRegionHA2bBuiltCopyIsApproved` no longer fails.
- [ ] Build for linux, darwin and windows; vet for linux and darwin:

````bash
for os in linux darwin windows; do GOOS=$os nice -n 19 go build ./... || break; done
for os in linux darwin; do GOOS=$os nice -n 19 go vet . ./internal/host/ ./internal/hostenv/ ./internal/hostactors/... ./internal/console/ || break; done
````

- [ ] Run once, on the final tree: `nice -n 19 go test ./internal/hostenv/ ./internal/hostactors/... -count=1`, the console's enrolment, capacity and authority-census tests with a `-run` filter, and the targeted `internal/host` tests of Tasks 3, 6, 7 and 8. Never the whole `internal/host` suite; CI's `make test` is that run.
- [ ] Put the census overlay's rows and the three claim-authority sites in front of platform-1e as census ruling region-ha-2b (the table above), recomputed on the build base, and record its ruling in the overlay's first line, as 2a's overlay does.
- [ ] Delete `$TMPDIR/census` by exact path.

## 2c's seams

What 2c builds, and exactly what 2b needs from it:

1. **The start-up script's order** (§5, R5). The launch template's user data names the deployment, its bucket, the region's placement code and its AWS region (tofu knows all four), so the script needs no carlos binary to begin:
   1. Harden.
   2. Fetch `s3://<deployment bucket>/bootstrap/carlos` with the AWS CLI (`aws s3 cp`) to a path of its own, outside both protected slots (below).
   3. Read `/carlos/<deployment>/shared/bootstrap-sha256` with the AWS CLI (`aws ssm get-parameter --name /carlos/<deployment>/shared/bootstrap-sha256 --with-decryption --query Parameter.Value --output text`) and check the file against it (`sha256sum`). A mismatch, or a read that fails, stops the script.
   4. `<bootstrap binary> system host-env write --deployment <name> --region <code> --aws-region <aws region>` as root. It needs only the box role's SSM and KMS read, and creates `/etc/carlos`. It needs the AWS region from `--aws-region` or `AWS_REGION`: the SDK does not read it from IMDS, so the script passes the flag.
   5. `<bootstrap binary> bootstrap`: users, directories (with `/data` handed to the service user), units, including `carlos-system-join.path`.
   6. With host.env's variables in the environment, `<bootstrap binary> system install-channel` as root. It hands the directories it creates under `/data` to the service user.
   7. Delete the bootstrap binary by its own path, and start `carlos-edge.service`. The agent joins on its own from there.

   **The bootstrap object must be a release with 2b in it**: it runs `system host-env write` and `system install-channel`, and its `bootstrap` installs the join units.

   **The bootstrap binary sits outside both protected slots**, `/opt/carlos/carlos` and `/opt/carlos/carlos.prev`, for example at `/root/carlos-bootstrap` (final review). `install-channel` refuses a slot that holds a build it did not install, so a bootstrap binary fetched into a slot stops the install. And step 7's delete must never touch an installed binary: the bootstrap file is neither a slot nor a link to one.
2. **The parameter layout.** One SecureString per variable, named for it, under `/carlos/<deployment>/<region>/`, and under `/carlos/<deployment>/shared/` for `CARLOS_DEPLOYMENT_BUCKET`, `CARLOS_HOST_CLAIM`, `CARLOS_HOST_CLAIM_ORIGINS`, `CARLOS_RELEASE_PUBKEY` and `CARLOS_SECRETS_KEY`. The required set is `hostenv.Required`. `bootstrap-sha256` under shared is the script's, and `host-env write` skips it (not a variable name). Deployment and region names are lower-case labels. An operator seeds a region with `carlos system host-env seed --deployment <name> --region <code> --kms-key <the configuration key>` from one of that region's existing boxes; shared values are written create-only, so two operators seeding at once cannot leave different shared values behind.
3. **IAM, box role** (§4):
   - `ssm:GetParametersByPath` on exactly `/carlos/<deployment>/<region>` and `/carlos/<deployment>/shared`, and `ssm:GetParameter` on `/carlos/<deployment>/shared/bootstrap-sha256` for the script; `kms:Decrypt` on the configuration key, conditioned on `kms:ViaService = ssm.<region>.amazonaws.com` and the `PARAMETER_ARN` encryption context; the explicit SSM Deny kept for everything outside the two paths.
   - **No** Auto Scaling or EC2 describe, and **no** write under `control/box-enrollment/` (H28, C4). §4's rev 15 box grant of `autoscaling:DescribeAutoScalingInstances` and `DescribeAutoScalingGroups` is withdrawn; 2c must not add it.
   - **No** `ssm:PutParameter` and **no** `ssm:DeleteParameter` on either parameter path (census condition 1 on `runHostEnv`): a box reads the parameters it is configured from and never writes them; only the operator's principal (seam 4), and the console's if 2c gives it one, write there.
   - S3 in the deployment bucket: `GetObject` on `control/host-actor-invitations/*`, `control/boxes/*`, `control/host-actors.json`, `control/moves-protocol.json`, `control/moves-protocol-auth.json`, `control/system/router/*` (the channel pointer and release identities), `bootstrap/router/*` (artifacts), `bootstrap/carlos` (the script's fetch), `control/box-fences/*`, and `status/boxes/*`; `PutObject` on the box's own `status/boxes/<label>.json` (as today) and `control/boxes/<label>.json` (create-only, as today). Most of these the box role already has on main; 2c should check the list against the module rather than add duplicates.
   - **The negative check, 2c's infrastructure test (C4).** With the box role's credentials, both of these must be denied: creating an object under `control/box-enrollment/` (`aws s3api put-object --bucket <deployment bucket> --key control/box-enrollment/probe/probe/probe.json --if-none-match '*'`), and `aws autoscaling describe-auto-scaling-instances --instance-ids <a group instance>` in the region's AWS region. `aws ec2 describe-instances` should be checked the same way. Add, for census condition 1: with the same credentials, `aws ssm put-parameter --name /carlos/<deployment>/<region>/CARLOS_SWAP --type SecureString --value probe --overwrite` and `aws ssm delete-parameter --name /carlos/<deployment>/<region>/CARLOS_SWAP` are both denied, and the same for one name under `/carlos/<deployment>/shared/`.
4. **IAM, operator seeding** (S4, Astra round 2 new #2): `ssm:PutParameter` (with and without overwrite) and `ssm:GetParametersByPath` on the two paths; `kms:Encrypt` on the configuration key; and `kms:Decrypt` on it, conditioned on `kms:ViaService = ssm.<region>.amazonaws.com` and the `PARAMETER_ARN` encryption context matching the two paths. `seed` reads the shared values decrypted (`ByPath` asks with `WithDecryption`) to compare them, and reads one back after a lost create race, so without `kms:Decrypt` seeding a second region, or resolving a race, fails. RHB-C61 names all four.
5. **IAM, console role** (H28, C4): `autoscaling:DescribeAutoScalingInstances` and `ec2:DescribeInstances`, read-only, conditioned on `aws:RequestedRegion` being one of the deployment's AWS regions (every region the roster's `Regions` name, so an emptied region recovers the same way). Neither action takes a resource ARN, so the region condition is the limit. No other new AWS call. The enrolment pass reads and writes the deployment bucket as the console already does: `control/host-actors.json` (CAS replace), `control/host-actor-invitations/*` (create-only), and reads `control/boxes/*`, `status/boxes/*`, `control/moves-protocol*.json`, `control/system/router/releases/*`. It no longer reads `control/box-enrollment/*`. 2c should confirm the console role's bucket statement covers the invitation prefix, and add the two describes.
6. **The health check and the grace period.** A joining box answers `/.carlos/lb-health` on port 80 with 503 until it restarts as the claim agent. The grace period must cover the boot, the install, and joining: the console's pass every 10 seconds (an attempt, then its membership read and the invitation on the next pass), root's arm on the path unit, the report within a minute, the console's second read and the enrolment, and the restart. A few minutes on dev; §4's 20 minutes is ample, and dev should measure it. **No launch lifecycle hook on the group**: a box held in `Pending:Wait` reads to the console as not a member (only `Pending` and `InService` are), so its attempt is refused and waits out the backoff, and joining stalls behind the hook. Dev should also stop and start one enrolled group box once and see it held at 503 and replaced, and reboot one and see it host again (Open question 7).
7. **Status report expiry (T2).** A lifecycle rule on the deployment bucket expiring objects under `status/boxes/` 30 days after they were last written (beside the existing rules in `infra/modules/deployment-buckets/main.tf`). It is hygiene only: the status reader already drops reports no box has rewritten in a day before its key cap, so nothing depends on the rule, and a self-hosted store without lifecycle rules works the same. Nothing reads old reports for audit or history: every reader of the prefix wants current reports (Task 6), so the expiry loses nothing.
8. **The roster.** Each region with a group needs, in the signed roster, `Region{Account, AWSRegion, GroupName, GroupARN, Namespaces (the console's namespace), Pools ("default")}`, and the deployment's host floor published. 2b reads these; the `region-group` phase writes the group's fields (Open question 1).
9. **`control/box-enrollment/` leaves the deployment bucket's module.** Remove it from `box_evidence_prefixes` in `infra/modules/deployment-buckets/main.tf`, with its comment and the rows in `tests/evidence.tftest.hcl`. Nothing writes membership confirmations there any more, and the console no longer reads them (H28). Seam 3's negative check still runs: the box role is denied a create there whatever the bucket policy lists.

## Open questions

1. **Who writes a region's group into the roster?** Answered (2026-10-16): a `region-group` phase of `carlos system claims run`, in a later, separate plan built on `feat/claim-activation-tools` once that branch merges. It takes the group's name and ARN from tofu's outputs (2c) and writes them, with the region's account, AWS region, namespaces and pools, in one signed roster write. 2b does not write it. Until it lands, no region has a group, the pass begins no attempt, and automatic enrolment stays dormant even with region HA on.
2. **Namespace and pool.** The pass enrolls a group box into the console's own namespace and the `default` pool, and refuses a region that does not serve both. Is that right for every carloku.com region, or does a region's group need its own pool?
3. **Census classes.** Ruled (census-ruling-region-ha-2b), round 4's `writeHostEnv` and `checkedDir` included (A,J4, round 5). The classes in the census table (A,J4 for `runHostEnv`, F,J4 for `channelInstall.run` and the join units, accepted-legacy for `AutoEnroller.inputs`, the re-sealed `statusLoop` row, nonrecord for `deadlineStore.Put`, reviewed sites for the claim-authority census) follow the nearest precedents; platform-1e may class them otherwise. In particular `runHostEnv` has no close precedent: it writes the root-owned host.env every unit reads.
4. **Box references outside the label space.** Answered and built (round 2). platform-1e approved the `statusLoop` change round 1 stopped at, as a re-sealed `@legacy` row under census ruling region-ha-2b, on four conditions, all met: only group boxes take the override; the reference is `g-` and 20 hex digits of `sha256("carlos-box-ref-v1|" + account + "|" + instance)`, a pseudonym in the public feed; the console derives the key itself from the pinned instance (`PrivateBoxReport`, the pass, `checkConfirmed`) and never trusts the box's; and the four tests (Review Focus 13). See "Global Constraints: Box references" and Task 6. Automatic enrolment stays off for a region until a release with it is rolled there ("Activation gates", 1).
5. **`DescribeAutoScalingGroups`.** Not needed: the console's read binds to the group name and ARN the signed roster names. Spec rev 16 drops it from §4.
6. **The invitation's confirmation.** Resolved by H28: the console reads membership itself immediately before the invitation and again before the enrolment, so the same attempt serves both and no fresh attempt is begun after the guard-ready report. Spec rev 16's §5 says so.
7. **`pendingTime` on a start: an activation gate.** The held-after-restart rule (C3) rests on EC2 setting the identity document's `pendingTime` at every start and never at a reboot. AWS documents the field as the launch time, but not that exact invariant (Astra round 2). Verify on dev before the first region: stop and start a group box (expect held, 503, replaced) and reboot one (expect hosting). Until then automatic enrolment stays off ("Activation gates", 2).
8. **The allowlist's completeness.** `hostenv.Optional` was drawn from README's host.env surface and the variables the agent reads. A deployment whose boxes carry another variable will find `seed` refusing it by name; adding it is a code change and a review, which is the point. Check carloku.com's regions' host.env files against the list before the first seed: a box that carries `CARLOS_DNSIMPLE_TOKEN`, `CARLOS_ALERT_WEBHOOK` or a status destination override has to drop it, or the region cannot seed ("Activation gates", 4).
9. **Detached but running instances: spec 3's requirement (U1, platform-1e).** 2b removes nothing from the roster and pages no one: an instance detached from its group that keeps running keeps its enrolment until an operator removes it. Spec 3's requirements list carries it, and spec 2's hand-off to spec 3 (§12) says so: **the regional worker reconciles roster membership against Auto Scaling group membership, including detached-but-running instances.**
