carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

File too large to show in full — download it above (showing a truncated preview below).

1# Region HA 2b: Joining Mode and Automatic Enrolment Implementation Plan
2
3> **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.
4
5**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.
6
7**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`).
8
9**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).
10
11**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.
12
13**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):
14
15| Finding | What changed |
16|---|---|
17| 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) |
18| 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) |
19| 3 (P2): 1,600 labels collide | Deferred in round 1 (a legacy-sealed unit); built in round 2 under platform-1e's ruling (below) |
20| 4 (P2): racing seeds overwrite shared values | Shared values are written create-only and read back on a conflict (Task 2) |
21| 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 |
22| 6 (P2): the console's new signing path escapes its census | Its construction, start and invocation are in the claim-authority census (Task 5) |
23| 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) |
24| 8 (P3): the join units' descriptions have no copy rows | RHB-C62 and RHB-C63 (Tasks 1, 7) |
25
26**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):
27
28| Finding | What changed |
29|---|---|
30| 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) |
31| 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") |
32| 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) |
33| 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) |
34| 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) |
35
36**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:
37
38| Finding or condition | What changed |
39|---|---|
40| 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) |
41| 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) |
42| 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) |
43| 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) |
44| 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) |
45| `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) |
46| The join trigger condition | Root's join reads nothing from its trigger file; hostile contents change nothing (Task 7) |
47
48**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):
49
50| Finding or ruling | What changed |
51|---|---|
52| #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) |
53| #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) |
54| #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) |
55
56**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):
57
58| Finding or ruling | What changed |
59|---|---|
60| 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) |
61| 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) |
62| platform-1e: the two host.env rows | V3: `writeHostEnv` and `checkedDir` re-marked `census-ruling-region-ha-2b` (Task 2) |
63| 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" |
64
65**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):
66
67| Finding or ruling | What changed |
68|---|---|
69| 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) |
70
71**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):
72
73| Finding | What changed |
74|---|---|
75| 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) |
76| 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) |
77| 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) |
78| 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 |
79| 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` |
80| 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 |
81
82The 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.
83
84## Scope
85
86Spec 2 is four plans. This is **2b**. What 2a left it, and what it leaves the others:
87
88| Plan | 2b's relation |
89|---|---|
90| 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) |
91| 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 |
92| 2d | The §8 single-box fixes. Nothing in 2b depends on them |
93
94**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).
95
96**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).
97
98## Global Constraints
99
100- **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.
101- **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.
102- **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.
103- **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.
104 - **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.
105 - **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.
106 - **Every other box**, fleet boxes included, keeps exactly today's label and status key.
107- **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.
108- **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).
109- **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.
110- **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/`.
111 - `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.
112 - 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.
113 - **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.
114 - **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`).
115 - **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`).
116 - **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.
117- **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.
118- **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.
119- **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.
120- **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.
121- **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.
122- **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.
123- **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.
124- **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`.
125- **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`).
126
127## Activation gates
128
1292b ships dormant. Automatic enrolment must not be switched on for any region until all four hold:
130
1311. **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.
1322. **`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.
1333. **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.
1344. **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.
135
136**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).
137
138## Census items for platform-1e (census ruling region-ha-2b)
139
140**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.
141
142**Census rows.**
143
144| Task | Census | Unit | Class (ruled) | Basis, change and the ruling's conditions | Seal |
145|---|---|---|---|---|---|
146| 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 |
147| 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 |
148| 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 |
149| 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 |
150| 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 |
151| 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 |
152| 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 |
153| 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 |
154| 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 |
155| 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 |
156
157**Changed code no census selects**, listed so the ruling covers everything 2b changes:
158
159| Task | Unit | Change | Why no census row |
160|---|---|---|---|
161| 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 |
162| 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) |
163| 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) |
164| 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 |
165| 4 | `GroupBoxReference` (`groupref.go`) | New: the reference function | No I/O |
166| 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 |
167| 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 |
168| 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 |
169| 8 | `productionJoining`'s report | The guard-ready report under the group reference | Not a census unit |
170
171**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`.
172
173**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.
174
175## Census tooling
176
177Use plan 2a's tooling (its "Census tooling" section: `coverage.sh`, `unitdigest`, `legacyrow.py`), created under `$TMPDIR/census` and deleted afterwards, with two additions:
178
179- **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:
180
181````bash
182# coverage-all.sh <dir>: the @coverage row of a directory whose every file is embedded.
183dir=$1
184hash=$(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)
185printf '@coverage\t%s\t%s\n' "$dir" "$hash"
186````
187
188- **The seal.** `seal.py` prints the seal over every overlay `TestPlan14RootReadCensus` loads, in its order, plus the ones named:
189
190````python
191import hashlib, re, sys, os
192# seal.py <repo root> [extra overlay ...]: the Plan 14 legacy seal after every
193# overlay TestPlan14RootReadCensus loads, in its order.
194root=sys.argv[1]
195t=open(os.path.join(root,'internal/provision/plan14_root_reads_test.go')).read()
196body=t[t.index('func TestPlan14RootReadCensus('):t.index('func TestPlan14RootReadCensusRejectsDrift')]
197files=re.findall(r'"internal/provision/testdata/(plan14-root-reads[^"]*\.tsv)"',body)
198if not os.path.exists(os.path.join(root,'internal/host/movesheldrelease_production.go')):
199 files=[f for f in files if f!='plan14-root-reads-moves.tsv']
200files+= [f for f in sys.argv[2:] if f not in files]
201fps=set()
202for f in files:
203 for line in open(os.path.join(root,'internal/provision/testdata',f)):
204 c=line.rstrip('\n').split('\t')
205 if c[0]=='@legacy':
206 fps.add(c[1])
207print(hashlib.sha256('\n'.join(sorted(fps)).encode()).hexdigest())
208````
209
210A 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.
211
212The census checks every task runs:
213
214````bash
215nice -n 19 go test ./internal/provision/ -run 'TestPlan9B8RecordWriteCensus|TestPlan14RootReadCensus$' -count=1
216nice -n 19 go test ./internal/console/ -run 'TestClaimAuthoritySourceCensus' -count=1
217````
218
219## Review Focus
220
2211. **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).
2222. **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.
2233. **The health answer must drop when the claim worker stops.** Task 8's `TestClaimWiringHostingFollowsTheModeAndTheWorker`.
2244. **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.
2255. **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.
2266. **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).
2277. **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.
2288. **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).
2299. **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.
23010. **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.
23111. **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`.
23212. **A group box started again never hosts on its old enrolment (C3).** Task 6's `TestSelectModeHoldsAGroupBoxStartedAgain` and `TestRelaunchedComparesTheLaunchTheRosterBinds`.
23313. **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`).
23414. **Ownership is reconciled on a retry (S2).** Task 3's `TestInstallChannelReconcilesOwnershipOnARetry` and `TestInstallChannelRefusesASymlinkedParent`.
23515. **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.
23616. **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.
23717. **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.
23818. **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`.
239
240## Dry run
241
242Every 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.
243
244Rounds 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.
245
246---
247
248### Task 1: The copy record and its gate
249
250**Files:**
251- Create: `docs/superpowers/plans/2026-10-17-region-ha-2b-copy.md`
252- Create: `approved_region_ha_2b_copy_test.go`
253
254**Interfaces:**
255- 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.
256- 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).
257
258Every 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.
259
260- [ ] **Step 1: Write the record**
261
262`docs/superpowers/plans/2026-10-17-region-ha-2b-copy.md`:
263
264````markdown
265# Region HA plan 2b: copy record (pending Paul's review)
266
267Every user-facing string plan 2b (`2026-10-17-region-ha-2b-joining.md`)
268writes: the output and refusals of `carlos system host-env`,
269`install-channel` and `join`, the first line of the host.env file it
270writes, the operator alert the console's enrolment pass sends, the
271descriptions systemd shows for the two join units, the
272`carlos system` usage lines, and the docs prose. The gate is
273`approved_region_ha_2b_copy_test.go` at the repo root, which reuses the
274moves gate's parser, so the format is the moves record's: one table per
275heading, columns id, file, text, status.
276
277The rules:
278
279- A row is `pending` until Paul approves it in the copy review (the
280 `copy-review` skill). His edits are applied to the text here verbatim
281 and the row becomes `approved`.
282- No pending string reaches code, CLI help or docs. The task that writes
283 a row's text adds its id to `regionHA2bCopyBuilt` in the gate in the
284 same commit, and from then on the gate fails unless the row is
285 `approved` and its text is in the file it names. Tasks 2, 3, 7 and 9
286 therefore wait for their rows' approval.
287- In a Go file the text is one string literal, or one `fmt.Sprintf`,
288 `fmt.Errorf` or `errors.New` format, equal to the row with a format
289 verb where the row has a `<placeholder>`. A row whose file cell says
290 "a fragment" is a piece of a larger literal (a usage text).
291- Command syntax is exempt: the `usage:` lines, flag names and the
292 commands in the docs' code blocks are not rows.
293- Log lines are not rows, as in plan 2a: the joining agent's and the
294 console enrollment pass's.
295- Ids are not renumbered: a row added in review takes the next free id
296 and goes in its task's table (RHB-C62 to RHB-C64, round 1; RHB-C65 to
297 RHB-C67, round 3; RHB-C68, round 4). A withdrawn row's id is not
298 reused: RHB-C65 and RHB-C66, the operator alert of round 3, went with
299 the read-back they belonged to in round 4.
300- No em dashes. A full stop and a new sentence instead. Refusals start
301 lower case, as the CLI's others do.
302
303Placeholders: `<reason>` the text of the error underneath; `<path>` a
304file; `<region>` a placement code; `<deployment>` a deployment's name;
305`<scope>` a region or `shared`; `<prefix>` a parameter path such as
306`/carlos/prod/ie/`; `<n>` a count; `<name>` and `<names>` one or more
307variable names; `<variable>` and `<value>` a variable and its value;
308`<version>` a platform release such as v0.31.0; `<tag>` its release tag;
309`<release>` the host floor's minimum release; `<channel>` a channel such
310as stable; `<platform>` such as linux-arm64; `<box>` a box's reference.
311
312## host.env from the deployment's parameters (Task 2)
313
314| id | file | text | status |
315|---|---|---|---|
316| RHB-C1 | internal/hostenv/hostenv.go | # Written by carlos system host-env write from the deployment's parameters. Do not edit by hand. | pending |
317| RHB-C2 | internal/hostenv/hostenv.go | required values are missing | pending |
318| RHB-C3 | internal/hostenv/hostenv.go | values conflict | pending |
319| RHB-C4 | internal/hostenv/hostenv.go | a name or value host.env cannot hold | pending |
320| RHB-C5 | internal/hostenv/hostenv.go | <reason>: <deployment> and <scope> must be lower-case labels | pending |
321| RHB-C6 | internal/hostenv/hostenv.go | <reason>: <names> set both for the region and shared | pending |
322| RHB-C7 | internal/hostenv/hostenv.go | <reason>: line <n> is not KEY=value | pending |
323| RHB-C8 | internal/hostenv/hostenv.go | <reason>: <name> is set twice | pending |
324| RHB-C9 | internal/hostenv/hostenv.go | <reason>: <region> is not a region | pending |
325| RHB-C10 | internal/hostenv/hostenv.go | parameters under <prefix>: <reason> | pending |
326| RHB-C11 | internal/hostenv/hostenv.go | <reason>: the shared <names> already differ | pending |
327| RHB-C12 | internal/hostenv/cli.go (a fragment of the usage) | build host.env from the deployment's parameters (root) | pending |
328| RHB-C13 | internal/hostenv/cli.go (a fragment of the usage) | copy a box's host.env into the deployment's parameters | pending |
329| RHB-C14 | internal/hostenv/cli.go | carlos system host-env write runs as root: it writes a root-owned file | pending |
330| RHB-C15 | internal/hostenv/cli.go | host.env was not written: <reason> | pending |
331| RHB-C16 | internal/hostenv/cli.go | wrote <path> from the parameters for <region> | pending |
332| RHB-C17 | internal/hostenv/cli.go | the parameters were not all written: <reason> | pending |
333| RHB-C18 | internal/hostenv/cli.go | wrote <n> parameters under <prefix> and <n> under <prefix> | pending |
334| RHB-C64 | internal/hostenv/hostenv.go | already set by another seed | pending |
335| RHB-C67 | internal/hostenv/write_linux.go | <path> is not a directory only root can write | pending |
336| RHB-C68 | internal/hostenv/write_other.go | carlos system host-env write runs only on Linux | pending |
337
338## carlos system install-channel (Task 3)
339
340| id | file | text | status |
341|---|---|---|---|
342| 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 |
343| RHB-C20 | internal/host/installchannel.go | carlos system install-channel runs as root: it installs the platform binary | pending |
344| RHB-C21 | internal/host/installchannel.go | <variable>=<value> is not a channel, so nothing was installed | pending |
345| RHB-C22 | internal/host/installchannel.go | <version> (<tag>) from the <channel> channel is already installed | pending |
346| RHB-C23 | internal/host/installchannel.go | installed <version> (<tag>) from the <channel> channel as carlos and carlos.prev | pending |
347| RHB-C24 | internal/host/installchannel.go | an update is running on this box, so nothing was installed: <reason> | pending |
348| RHB-C25 | internal/host/installchannel.go | the <channel> channel could not be read, so nothing was installed: <reason> | pending |
349| RHB-C26 | internal/host/installchannel.go | the <channel> channel names <version> with no artifact for <platform>, so nothing was installed | pending |
350| RHB-C27 | internal/host/installchannel.go | this box's host lifecycle guard is armed, so carlos system update installs its releases | pending |
351| RHB-C28 | internal/host/installchannel.go | this box's host lifecycle guard could not be read, so nothing was installed: <reason> | pending |
352| RHB-C29 | internal/host/installchannel.go | this box's install record could not be read, so nothing was installed: <reason> | pending |
353| RHB-C30 | internal/host/installchannel.go | this box already runs <version>, so carlos system update installs its releases | pending |
354| RHB-C31 | internal/host/installchannel.go | <version> could not be fetched, so nothing was installed: <reason> | pending |
355| RHB-C32 | internal/host/installchannel.go | <version> did not verify, so nothing was installed: <reason> | pending |
356| RHB-C33 | internal/host/installchannel.go | <path> does not hold <version> after the copy | pending |
357| RHB-C34 | internal/host/installchannel.go | no host floor is published, so this box could not arm its guard and nothing was installed | pending |
358| RHB-C35 | internal/host/installchannel.go | the host floor could not be read, so nothing was installed: <reason> | pending |
359| RHB-C36 | internal/host/installchannel.go | the host floor does not verify, so nothing was installed: <reason> | pending |
360| RHB-C37 | internal/host/installchannel.go | the <channel> channel names <version> with no release tag, so nothing was installed | pending |
361| RHB-C38 | internal/host/installchannel.go | the release identity for <tag> could not be read, so nothing was installed: <reason> | pending |
362| 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 |
363
364## carlos system join (Task 7)
365
366| id | file | text | status |
367|---|---|---|---|
368| 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 |
369| RHB-C41 | internal/host/joinroot.go | carlos system join runs as root: it arms the host lifecycle guard | pending |
370| RHB-C42 | internal/host/joinroot.go | CARLOS_HOST_CLAIM or CARLOS_REGION_HA is off, so this box does not join | pending |
371| RHB-C43 | internal/host/joinroot.go | the invitation for <box> could not be read: <reason> | pending |
372| RHB-C44 | internal/host/joinroot.go | the invitation for <box> does not verify, so the guard was not armed | pending |
373| RHB-C45 | internal/host/joinroot.go | the identity record for <box> could not be read: <reason> | pending |
374| RHB-C46 | internal/host/joinroot.go | the invitation for <box> is for another identity record, so the guard was not armed | pending |
375| RHB-C47 | internal/host/joinroot.go | the identity record for <box> is not this instance's, so the guard was not armed | pending |
376| RHB-C48 | internal/host/joinroot.go | the host floor could not be read: <reason> | pending |
377| RHB-C49 | internal/host/joinroot.go | the host floor does not verify, so the guard was not armed: <reason> | pending |
378| RHB-C62 | internal/host/units/carlos-system-join.path | carlos joining: arm the host lifecycle guard on agent request | pending |
379| RHB-C63 | internal/host/units/carlos-system-join.service | carlos joining (root: arm the host lifecycle guard) | pending |
380
381## The carlos system usage and the docs (Task 9)
382
383| id | file | text | status |
384|---|---|---|---|
385| RHB-C50 | system.go (a fragment of the usage) | A box a region's group launches (region HA): | pending |
386| RHB-C51 | system.go (a fragment of the usage) | build host.env from the deployment's parameters (root) | pending |
387| RHB-C52 | system.go (a fragment of the usage) | copy a box's host.env into the deployment's parameters | pending |
388| RHB-C53 | system.go (a fragment of the usage) | install the release this box's channel names (root) | pending |
389| RHB-C54 | system.go (a fragment of the usage) | (root oneshot behind carlos-system-join.service; not for operators) | pending |
390| RHB-C55 | docs/site/cli.md | Boxes a region's group launches | pending |
391| 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 |
392| 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 |
393| 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 |
394| 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 |
395| 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 |
396| 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 |
397````
398
399- [ ] **Step 2: Write the gate**
400
401`approved_region_ha_2b_copy_test.go`:
402
403````go
404// approved_region_ha_2b_copy_test.go
405package main
406
407import (
408 "os"
409 "regexp"
410 "strconv"
411 "testing"
412)
413
414// The copy gate of region HA plan 2b (Task 1), plan 2a's gate over plan
415// 2b's record. The record is a markdown table in the moves record's
416// format, read with the moves gate's parser and checked with the run
417// gate's helpers, so "approved" and "applied" mean what they mean there:
418//
419// - TestRegionHA2bCopyRecordIsComplete: the record names exactly
420// regionHA2bCopyIDs;
421// - TestRegionHA2bCopyHasNoEmDash;
422// - TestRegionHA2bBuiltCopyIsApproved: an id in regionHA2bCopyBuilt is an
423// approved row. No pending string reaches code, CLI help or docs;
424// - TestRegionHA2bBuiltCopyIsApplied: a built row's text is in the file
425// it names.
426const regionHA2bCopyRecordPath = "docs/superpowers/plans/2026-10-17-region-ha-2b-copy.md"
427
428var regionHA2bCopyIDPattern = regexp.MustCompile(`^RHB-C[0-9]+$`)
429
430// regionHA2bCopyIDs is the record's inventory, pinned: RHB-C1 to RHB-C64,
431// RHB-C67 and RHB-C68. RHB-C65 and RHB-C66 were withdrawn in review and
432// are not reused.
433var regionHA2bCopyIDs = func() []string {
434 ids := make([]string, 0, 66)
435 for i := 1; i <= 64; i++ {
436 ids = append(ids, "RHB-C"+strconv.Itoa(i))
437 }
438 return append(ids, "RHB-C67", "RHB-C68")
439}()
440
441// regionHA2bCopyBuilt is every id whose text the code or docs carry. The
442// task that writes a row's text adds its ids here in the same commit, once
443// Paul has approved the rows.
444var regionHA2bCopyBuilt = []string{}
445
446func mustParseRegionHA2bRecord(t *testing.T) []copyRow {
447 t.Helper()
448 raw, err := os.ReadFile(regionHA2bCopyRecordPath)
449 if err != nil {
450 t.Fatal(err)
451 }
452 rows, err := parseCopyTable(string(raw), regionHA2bCopyRecordPath, regionHA2bCopyIDPattern, "an RHB-C<n>")
453 if err != nil {
454 t.Fatal(err)
455 }
456 return rows
457}
458
459func TestRegionHA2bCopyRecordIsComplete(t *testing.T) {
460 reportAll(t, checkMovesInventory(mustParseRegionHA2bRecord(t), regionHA2bCopyIDs))
461}
462
463func TestRegionHA2bCopyHasNoEmDash(t *testing.T) {
464 reportAll(t, checkMovesNoEmDash(mustParseRegionHA2bRecord(t)))
465}
466
467func TestRegionHA2bBuiltCopyIsApproved(t *testing.T) {
468 reportAll(t, checkRunBuilt(mustParseRegionHA2bRecord(t), regionHA2bCopyBuilt))
469}
470
471func TestRegionHA2bBuiltCopyIsApplied(t *testing.T) {
472 reportAll(t, checkRunApplied(builtRows(mustParseRegionHA2bRecord(t), regionHA2bCopyBuilt), "."))
473}
474````
475
476- [ ] **Step 3: Run it**
477
478Run: `nice -n 19 go test . -run 'RegionHA2b' -count=1`
479Expected: PASS (nothing is built yet).
480
481- [ ] **Step 4: Put the record in front of Paul**
482
483Run 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.
484
485- [ ] **Step 5: Commit**
486
487```bash
488git add docs/superpowers/plans/2026-10-17-region-ha-2b-copy.md approved_region_ha_2b_copy_test.go
489git commit -m "region HA 2b: the copy record and its gate"
490```
491
492No package envelope changes: neither file is a non-test Go file.
493
494---
495
496### Task 2: host.env from the deployment's parameters (`internal/hostenv`)
497
498**Files:**
499- 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`
500- Modify: `go.mod`, `go.sum` (`github.com/aws/aws-sdk-go-v2/service/ssm v1.74.0`)
501- Modify: `region_ha_boundary_test.go`, `approved_region_ha_2b_copy_test.go`
502- Create: `internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv`; Modify: `internal/provision/plan14_root_reads_test.go` (load it last)
503
504**Interfaces:**
505- Consumes: `golang.org/x/sys/unix` (already required) for the descriptor-held write.
506- 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`.
507- 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`.
508- Produces: the census overlay every later task edits, and its load in `TestPlan14RootReadCensus`.
509
510This 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.
511
512The 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.
513
514Shared 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.
515
516`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.
517
518**The ruling's conditions on `runHostEnv` (census ruling region-ha-2b).**
5191. 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).
5202. `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.
5213. 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).
522
523- [ ] **Step 1: Write the failing tests**
524
525`internal/hostenv/hostenv_test.go`:
526
527````go
528package hostenv
529
530import (
531 "bytes"
532 "context"
533 "errors"
534 "os"
535 "path/filepath"
536 "reflect"
537 "runtime"
538 "slices"
539 "strings"
540 "testing"
541)
542
543// fakeParams is a parameter store in memory. failPrefix makes ByPath fail
544// for one prefix, as an unreachable or refused SSM call would. staleOnce
545// makes the next ByPath of that prefix answer as it stood before every
546// value now under it was written: a seed that read just before another
547// seed wrote.
548type fakeParams struct {
549 vals map[string]string
550 sets []string
551 failPrefix string
552 staleOnce string
553}
554
555func (f *fakeParams) ByPath(_ context.Context, prefix string) (map[string]string, error) {
556 if prefix == f.failPrefix {
557 return nil, errors.New("AccessDeniedException")
558 }
559 if prefix == f.staleOnce {
560 f.staleOnce = ""
561 return map[string]string{}, nil
562 }
563 out := map[string]string{}
564 for name, v := range f.vals {
565 if rest, ok := strings.CutPrefix(name, prefix); ok && !strings.Contains(rest, "/") {
566 out[rest] = v
567 }
568 }
569 return out, nil
570}
571
572func (f *fakeParams) Set(_ context.Context, name, value string) error {
573 f.vals[name] = value
574 f.sets = append(f.sets, name)
575 return nil
576}
577
578func (f *fakeParams) SetNew(_ context.Context, name, value string) error {
579 if _, ok := f.vals[name]; ok {
580 return ErrExists
581 }
582 f.vals[name] = value
583 f.sets = append(f.sets, name)
584 return nil
585}
586
587// today is a hosting box's host.env as an operator has it today.
588func today() map[string]string {
589 return map[string]string{
590 "CARLOS_ALERT_SNS_TOPIC_ARN": "arn:aws:sns:eu-west-1:123456789012:carlos-alerts",
591 "CARLOS_DEPLOYMENT_BUCKET": "deploy-bucket",
592 "CARLOS_HIBERNATE_BUCKET": "data-ie",
593 "CARLOS_HIBERNATE_REGION": "eu-west-1",
594 "CARLOS_HOST_CLAIM": "1",
595 "CARLOS_HOST_CLAIM_ORIGINS": "1",
596 "CARLOS_REGION": "ie",
597 "CARLOS_REGION_HA": "1",
598 "CARLOS_RELEASE_PUBKEY": "q3J0bXlwdWJsaWNrZXk+/=",
599 "CARLOS_SECRETS_KEY": "c2VjcmV0c2tleQ==",
600 "CARLOS_SYSTEM_CHANNEL": "stable",
601 "CARLOS_HIBERNATE_PREFIX": "fleet",
602 }
603}
604
605func TestSeedThenFetchIsTodaysFile(t *testing.T) {
606 ctx := context.Background()
607 p := &fakeParams{vals: map[string]string{}}
608 s, err := Seed(ctx, p, "flagship", "ie", today())
609 if err != nil {
610 t.Fatal(err)
611 }
612 if s.Shared != len(SharedKeys) || s.Regional != len(today())-len(SharedKeys) || s.SharedPrefix != "/carlos/flagship/shared/" || s.RegionPrefix != "/carlos/flagship/ie/" {
613 t.Fatalf("seeded %+v", s)
614 }
615 if p.vals["/carlos/flagship/shared/CARLOS_RELEASE_PUBKEY"] != today()["CARLOS_RELEASE_PUBKEY"] || p.vals["/carlos/flagship/ie/CARLOS_REGION"] != "ie" {
616 t.Fatalf("layout %v", p.vals)
617 }
618 // Another region's parameter, and a non-variable under shared, are not
619 // this region's host.env.
620 p.vals["/carlos/flagship/uk/CARLOS_REGION"] = "uk"
621 p.vals["/carlos/flagship/shared/bootstrap-sha256"] = strings.Repeat("a", 64)
622 body, err := Fetch(ctx, p, "flagship", "ie")
623 if err != nil {
624 t.Fatal(err)
625 }
626 got, err := Parse(body)
627 if err != nil {
628 t.Fatal(err)
629 }
630 // A box write assembles host.env for is one its group launched: it
631 // takes its group reference (BoxReference), which is never a parameter.
632 want := today()
633 want[BoxReference] = GroupReference
634 if !reflect.DeepEqual(got, want) {
635 t.Fatalf("round trip:\n got %v\nwant %v", got, want)
636 }
637 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")) {
638 t.Fatalf("not sorted KEY=value lines:\n%s", body)
639 }
640}
641
642func TestFetchRefusesAMissingRequiredValue(t *testing.T) {
643 ctx := context.Background()
644 for _, k := range Required {
645 p := &fakeParams{vals: map[string]string{}}
646 env := today()
647 if _, err := Seed(ctx, p, "flagship", "ie", env); err != nil {
648 t.Fatal(err)
649 }
650 for name := range p.vals {
651 if strings.HasSuffix(name, "/"+k) {
652 delete(p.vals, name)
653 }
654 }
655 _, err := Fetch(ctx, p, "flagship", "ie")
656 if !errors.Is(err, ErrMissing) || !strings.Contains(err.Error(), k) {
657 t.Fatalf("without %s: %v", k, err)
658 }
659 }
660}
661
662func TestFetchRefusesAFailedReadAndAConflict(t *testing.T) {
663 ctx := context.Background()
664 p := &fakeParams{vals: map[string]string{}}
665 if _, err := Seed(ctx, p, "flagship", "ie", today()); err != nil {
666 t.Fatal(err)
667 }
668 // A refused read is never an empty scope: nothing is assembled.
669 p.failPrefix = "/carlos/flagship/shared/"
670 if _, err := Fetch(ctx, p, "flagship", "ie"); err == nil || errors.Is(err, ErrMissing) {
671 t.Fatalf("failed read: %v", err)
672 }
673 p.failPrefix = ""
674 // A variable in both scopes is one of them wrong.
675 p.vals["/carlos/flagship/ie/CARLOS_RELEASE_PUBKEY"] = "other"
676 if _, err := Fetch(ctx, p, "flagship", "ie"); !errors.Is(err, ErrConflict) || !strings.Contains(err.Error(), "CARLOS_RELEASE_PUBKEY") {
677 t.Fatalf("conflict: %v", err)
678 }
679}
680
681func TestRenderRefusesAnUnsafeValueWithoutPrintingIt(t *testing.T) {
682 env := today()
683 env["CARLOS_SECRETS_KEY"] = "abc$(reboot)"
684 _, err := Render(env)
685 if !errors.Is(err, ErrValue) || !strings.Contains(err.Error(), "CARLOS_SECRETS_KEY") || strings.Contains(err.Error(), "reboot") {
686 t.Fatalf("unsafe value: %v", err)
687 }
688 for _, v := range []string{"a b", "a'b", `a"b`, "a\\b", "a\nb", "a#b", "a;b"} {
689 if _, err := Render(map[string]string{"CARLOS_X": v}); !errors.Is(err, ErrValue) {
690 t.Fatalf("%q rendered: %v", v, err)
691 }
692 }
693}
694
695func TestSeedNeverChangesASharedValue(t *testing.T) {
696 ctx := context.Background()
697 p := &fakeParams{vals: map[string]string{}}
698 if _, err := Seed(ctx, p, "flagship", "ie", today()); err != nil {
699 t.Fatal(err)
700 }
701 // A second region with the same shared values writes only its own.
702 uk := today()
703 uk["CARLOS_REGION"], uk["CARLOS_HIBERNATE_BUCKET"] = "uk", "data-uk"
704 p.sets = nil
705 s, err := Seed(ctx, p, "flagship", "uk", uk)
706 if err != nil {
707 t.Fatal(err)
708 }
709 if s.Shared != 0 {
710 t.Fatalf("rewrote shared values: %v", p.sets)
711 }
712 // A third region whose file disagrees on a shared value writes nothing.
713 au := today()
714 au["CARLOS_REGION"], au["CARLOS_SECRETS_KEY"] = "au", "b3RoZXI="
715 p.sets = nil
716 if _, err := Seed(ctx, p, "flagship", "au", au); !errors.Is(err, ErrConflict) || len(p.sets) != 0 {
717 t.Fatalf("conflicting shared value: %v, wrote %v", err, p.sets)
718 }
719 // A group box's own host.env seeds no BoxReference.
720 grp := today()
721 grp[BoxReference] = GroupReference
722 p.sets = nil
723 if _, err := Seed(ctx, p, "flagship", "ie", grp); err != nil || slices.ContainsFunc(p.sets, func(n string) bool { return strings.HasSuffix(n, BoxReference) }) {
724 t.Fatalf("seeded %v: %v", p.sets, err)
725 }
726 // An incomplete file writes nothing either.
727 partial := today()
728 delete(partial, "CARLOS_REGION_HA")
729 p.sets = nil
730 if _, err := Seed(ctx, p, "flagship", "au", partial); !errors.Is(err, ErrMissing) || len(p.sets) != 0 {
731 t.Fatalf("incomplete file: %v, wrote %v", err, p.sets)
732 }
733}
734
735// R4: two seeds racing for a shared value. The second read before the
736// first wrote, so both try to create it; the second's SetNew is refused,
737// it reads the value back, and it goes on only if the value is its own.
738func TestSeedRacesForASharedValueAndOnlyOneWins(t *testing.T) {
739 ctx := context.Background()
740 p := &fakeParams{vals: map[string]string{}}
741 if _, err := Seed(ctx, p, "flagship", "ie", today()); err != nil {
742 t.Fatal(err)
743 }
744 // The same shared values: nothing shared is written, the region's are.
745 uk := today()
746 uk["CARLOS_REGION"] = "uk"
747 p.staleOnce, p.sets = "/carlos/flagship/shared/", nil
748 s, err := Seed(ctx, p, "flagship", "uk", uk)
749 if err != nil || s.Shared != 0 || s.Regional != len(today())-len(SharedKeys) {
750 t.Fatalf("same values: %+v %v, wrote %v", s, err, p.sets)
751 }
752 // A different secrets key: refused, naming the variable and never
753 // either value, and the first seed's value stands.
754 au := today()
755 au["CARLOS_REGION"], au["CARLOS_SECRETS_KEY"] = "au", "b3RoZXI="
756 p.staleOnce, p.sets = "/carlos/flagship/shared/", nil
757 _, err = Seed(ctx, p, "flagship", "au", au)
758 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"]) {
759 t.Fatalf("racing seed: %v", err)
760 }
761 if p.vals["/carlos/flagship/shared/CARLOS_SECRETS_KEY"] != today()["CARLOS_SECRETS_KEY"] || len(p.sets) != 0 {
762 t.Fatalf("the second seed changed or wrote %v", p.sets)
763 }
764}
765
766func TestPrefixAndParse(t *testing.T) {
767 if _, err := Prefix("flagship", "../x"); !errors.Is(err, ErrValue) {
768 t.Fatalf("bad scope: %v", err)
769 }
770 if _, err := Fetch(context.Background(), &fakeParams{}, "flagship", SharedScope); !errors.Is(err, ErrValue) {
771 t.Fatalf("shared as a region: %v", err)
772 }
773 got, err := Parse([]byte("# comment\n\nexport CARLOS_REGION=\"ie\"\nCARLOS_HOST_CLAIM='1'\n"))
774 if err != nil || !reflect.DeepEqual(got, map[string]string{"CARLOS_REGION": "ie", "CARLOS_HOST_CLAIM": "1"}) {
775 t.Fatalf("parse: %v %v", got, err)
776 }
777 for _, body := range []string{"CARLOS_REGION=ie\nCARLOS_REGION=uk\n", "not an assignment\n", "lower=x\n"} {
778 if _, err := Parse([]byte(body)); !errors.Is(err, ErrValue) {
779 t.Fatalf("%q parsed: %v", body, err)
780 }
781 }
782}
783
784// The command: write refuses without root and leaves no file on a failed
785// assembly; with root it writes 0600; seed reads the named file.
786func TestHostEnvCommand(t *testing.T) {
787 ctx := context.Background()
788 dir := withHostEnvRoot(t)
789 out := filepath.Join(dir, "etc", "carlos", "host.env")
790 p := &fakeParams{vals: map[string]string{}}
791 open := func(context.Context, string, string) (Params, error) { return p, nil }
792 var stdout bytes.Buffer
793 args := []string{"write", "--deployment", "flagship", "--region", "ie"}
794 if err := runHostEnv(ctx, args, &stdout, 1000, open); err == nil || !strings.Contains(err.Error(), "root") {
795 t.Fatalf("non-root write: %v", err)
796 }
797 if err := runHostEnv(ctx, args, &stdout, 0, open); !errors.Is(err, ErrMissing) {
798 t.Fatalf("write with no parameters: %v", err)
799 }
800 if _, err := os.Stat(out); !os.IsNotExist(err) {
801 t.Fatalf("a failed write left a file: %v", err)
802 }
803 from := filepath.Join(dir, "today.env")
804 body, _ := Render(today())
805 if err := os.WriteFile(from, body, 0o600); err != nil {
806 t.Fatal(err)
807 }
808 seed := []string{"seed", "--deployment", "flagship", "--region", "ie", "--kms-key", "alias/carlos-flagship-config", "--from", from}
809 if err := runHostEnv(ctx, seed, &stdout, 1000, open); err != nil {
810 t.Fatal(err)
811 }
812 if !strings.Contains(stdout.String(), "wrote 7 parameters under /carlos/flagship/ie/ and 5 under /carlos/flagship/shared/\n") {
813 t.Fatalf("output %q", stdout.String())
814 }
815 err := runHostEnv(ctx, args, &stdout, 0, open)
816 if runtime.GOOS != "linux" {
817 if err == nil || !strings.Contains(err.Error(), "runs only on Linux") {
818 t.Fatalf("write off Linux: %v", err)
819 }
820 } else {
821 if err != nil {
822 t.Fatal(err)
823 }
824 fi, err := os.Stat(out)
825 if err != nil || fi.Mode().Perm() != 0o600 {
826 t.Fatalf("host.env: %v %v", fi, err)
827 }
828 if !strings.Contains(stdout.String(), "wrote "+out+" from the parameters for ie\n") {
829 t.Fatalf("output %q", stdout.String())
830 }
831 }
832 // The path is fixed: there is no flag to move it.
833 for _, bad := range [][]string{nil, {"seed", "--deployment", "flagship", "--region", "ie"}, {"write", "--region", "ie"}, {"read"}, append(args, "--out", filepath.Join(dir, "elsewhere"))} {
834 if err := runHostEnv(ctx, bad, &stdout, 0, open); err == nil || !strings.HasPrefix(err.Error(), "usage: carlos system host-env") {
835 t.Fatalf("%v: %v", bad, err)
836 }
837 }
838}
839
840// runHostEnv's census conditions (platform-1e, region-ha-2b): write takes
841// only allowlisted names, refuses values that could start a second line,
842// end the file or change how systemd reads it, and writes through no
843// symlinked directory. A refusal names the variable, never the value.
844func TestWriteTakesOnlyAllowedNamesAndSafeValues(t *testing.T) {
845 ctx := context.Background()
846 p := &fakeParams{vals: map[string]string{}}
847 if _, err := Seed(ctx, p, "flagship", "ie", today()); err != nil {
848 t.Fatal(err)
849 }
850 // A name off the allowlist, under either scope, is refused by name.
851 for _, name := range []string{"/carlos/flagship/ie/CARLOS_PRIVATE_IP", "/carlos/flagship/shared/CARLOS_FLEET"} {
852 p.vals[name] = "10.0.0.9"
853 _, err := Fetch(ctx, p, "flagship", "ie")
854 short := name[strings.LastIndex(name, "/")+1:]
855 if !errors.Is(err, ErrValue) || !strings.Contains(err.Error(), short) || strings.Contains(err.Error(), "10.0.0.9") {
856 t.Fatalf("%s: %v", name, err)
857 }
858 delete(p.vals, name)
859 }
860 // Seeding a file with such a name writes nothing.
861 odd := today()
862 odd["CARLOS_POOLS"] = "gold"
863 p.sets = nil
864 if _, err := Seed(ctx, p, "flagship", "uk", odd); !errors.Is(err, ErrValue) || !strings.Contains(err.Error(), "CARLOS_POOLS") || len(p.sets) != 0 {
865 t.Fatalf("seed with an unknown name: %v, wrote %v", err, p.sets)
866 }
867 // Values that would break a line, end the file early or change how
868 // systemd reads it.
869 for _, v := range []string{"a\nCARLOS_REGION=uk", "a\x00b", "a\rb", "$(id)", "a b", "\"quoted\""} {
870 p.vals["/carlos/flagship/ie/CARLOS_HIBERNATE_PREFIX"] = v
871 if _, err := Fetch(ctx, p, "flagship", "ie"); !errors.Is(err, ErrValue) || strings.Contains(err.Error(), v) {
872 t.Fatalf("%q: %v", v, err)
873 }
874 }
875}
876
877// withHostEnvRoot points write's fixed path at a directory of the test's
878// own, with an etc in it as a box has one.
879func withHostEnvRoot(t *testing.T) string {
880 t.Helper()
881 root := t.TempDir()
882 if err := os.Mkdir(filepath.Join(root, "etc"), 0o755); err != nil {
883 t.Fatal(err)
884 }
885 if err := os.Chmod(filepath.Join(root, "etc"), 0o755); err != nil {
886 t.Fatal(err)
887 }
888 was := hostEnvRoot
889 hostEnvRoot = root
890 t.Cleanup(func() { hostEnvRoot = was })
891 return root
892}
893````
894
895The checked write's own tests, Linux's and the refusal elsewhere:
896
897`internal/hostenv/write_linux_test.go`:
898
899````go
900//go:build linux
901
902package hostenv
903
904import (
905 "os"
906 "path/filepath"
907 "strings"
908 "testing"
909)
910
911// U3 (census condition 2 on runHostEnv): write's path is fixed, and the
912// file is written only through etc and etc/carlos held by descriptor,
913// each a real directory this user owns and nobody else can write.
914func TestWriteGoesOnlyThroughDirectoriesRootAloneCanWrite(t *testing.T) {
915 read := func(root string) string {
916 b, _ := os.ReadFile(filepath.Join(root, "etc", "carlos", "host.env"))
917 return string(b)
918 }
919 t.Run("lands whole, and replaces whole", func(t *testing.T) {
920 root := withHostEnvRoot(t)
921 if err := writeHostEnv(root, []byte("CARLOS_REGION=ie\n")); err != nil {
922 t.Fatal(err)
923 }
924 fi, err := os.Stat(filepath.Join(root, "etc", "carlos"))
925 if err != nil || fi.Mode().Perm()&0o022 != 0 || !fi.IsDir() {
926 t.Fatalf("created directory %v %v", fi, err)
927 }
928 first, err := os.Stat(filepath.Join(root, "etc", "carlos", "host.env"))
929 if err != nil {
930 t.Fatal(err)
931 }
932 if err := writeHostEnv(root, []byte("CARLOS_REGION=uk\n")); err != nil {
933 t.Fatal(err)
934 }
935 f, err := os.Stat(filepath.Join(root, "etc", "carlos", "host.env"))
936 if err != nil || f.Mode().Perm() != 0o600 || read(root) != "CARLOS_REGION=uk\n" {
937 t.Fatalf("host.env %v %v %q", f, err, read(root))
938 }
939 // Replaced by rename, not rewritten in place: a reader holding the
940 // old file never sees part of the new one.
941 if os.SameFile(first, f) {
942 t.Fatal("host.env was rewritten in place")
943 }
944 ents, _ := os.ReadDir(filepath.Join(root, "etc", "carlos"))
945 if len(ents) != 1 {
946 t.Fatalf("left behind: %v", ents)
947 }
948 })
949 for name, spoil := range map[string]func(t *testing.T, root string){
950 "a group-writable etc/carlos": func(t *testing.T, root string) {
951 dir := filepath.Join(root, "etc", "carlos")
952 if err := os.Mkdir(dir, 0o755); err != nil {
953 t.Fatal(err)
954 }
955 if err := os.Chmod(dir, 0o775); err != nil {
956 t.Fatal(err)
957 }
958 },
959 "a world-writable etc": func(t *testing.T, root string) {
960 if err := os.Chmod(filepath.Join(root, "etc"), 0o757); err != nil {
961 t.Fatal(err)
962 }
963 },
964 "an etc/carlos another user owns": func(t *testing.T, root string) {
965 if err := os.Mkdir(filepath.Join(root, "etc", "carlos"), 0o755); err != nil {
966 t.Fatal(err)
967 }
968 was := hostEnvOwner
969 hostEnvOwner = func() int { return was() + 1 }
970 t.Cleanup(func() { hostEnvOwner = was })
971 },
972 "a symlinked etc/carlos": func(t *testing.T, root string) {
973 elsewhere := safeDir(t)
974 if err := os.Symlink(elsewhere, filepath.Join(root, "etc", "carlos")); err != nil {
975 t.Fatal(err)
976 }
977 },
978 "a symlinked etc, an ancestor": func(t *testing.T, root string) {
979 elsewhere := safeDir(t)
980 if err := os.Mkdir(filepath.Join(elsewhere, "carlos"), 0o755); err != nil {
981 t.Fatal(err)
982 }
983 if err := os.Remove(filepath.Join(root, "etc")); err != nil {
984 t.Fatal(err)
985 }
986 if err := os.Symlink(elsewhere, filepath.Join(root, "etc")); err != nil {
987 t.Fatal(err)
988 }
989 },
990 } {
991 t.Run(name, func(t *testing.T) {
992 root := withHostEnvRoot(t)
993 spoil(t, root)
994 err := writeHostEnv(root, []byte("CARLOS_REGION=ie\n"))
995 if err == nil || !strings.Contains(err.Error(), "is not a directory only root can write") {
996 t.Fatalf("%v", err)
997 }
998 if got := read(root); got != "" {
999 t.Fatalf("wrote %q", got)
1000 }
1001 })
1002 }
1003}
1004
1005// safeDir is a directory that passes every check but the one under test:
1006// this user's, 0755 whatever the umask, so a symlink to it is refused only
1007// for being a symlink.
1008func safeDir(t *testing.T) string {
1009 t.Helper()
1010 dir := t.TempDir()
1011 if err := os.Chmod(dir, 0o755); err != nil {
1012 t.Fatal(err)
1013 }
1014 return dir
1015}
1016````
1017
1018`internal/hostenv/write_other_test.go`:
1019
1020````go
1021//go:build !linux
1022
1023package hostenv
1024
1025import "testing"
1026
1027// U3: off Linux there is no checked write, so write refuses and leaves
1028// nothing behind.
1029func TestWriteRefusesOffLinux(t *testing.T) {
1030 root := t.TempDir()
1031 err := writeHostEnv(root, []byte("CARLOS_REGION=ie\n"))
1032 if err == nil || err.Error() != "carlos system host-env write runs only on Linux" {
1033 t.Fatalf("%v", err)
1034 }
1035}
1036````
1037
1038- [ ] **Step 2: Run them to see them fail**
1039
1040Run: `nice -n 19 go test ./internal/hostenv/ -count=1`
1041Expected: FAIL, the package does not build (`undefined: Seed`, `undefined: Params`, ...).
1042
1043- [ ] **Step 3: Add the SSM SDK**
1044
1045The 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.
1046
1047`go.mod`:
1048
1049````diff
1050--- a/go.mod
1051+++ b/go.mod
1052@@ -15,6 +15,7 @@
1053 github.com/aws/aws-sdk-go-v2/service/route53 v1.64.1
1054 github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0
1055 github.com/aws/aws-sdk-go-v2/service/sesv2 v1.66.6
1056+ github.com/aws/aws-sdk-go-v2/service/ssm v1.74.0
1057 github.com/aws/aws-sdk-go-v2/service/sts v1.45.6
1058 github.com/aws/smithy-go v1.28.1
1059 github.com/benbjohnson/litestream v0.5.14
1060````
1061
1062`go.sum`:
1063
1064````diff
1065--- a/go.sum
1066+++ b/go.sum
1067@@ -68,6 +68,8 @@
1068 github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU=
1069 github.com/aws/aws-sdk-go-v2/service/sns v1.43.0 h1:VPYjwn0BoX34hb44OT8T+Ikgn4NzsN7fHetaHaevsDc=
1070 github.com/aws/aws-sdk-go-v2/service/sns v1.43.0/go.mod h1:I1vnLPvvi9KBqxddu8nJ4vktoPJvaIG05UmjBD9sqm8=
1071+github.com/aws/aws-sdk-go-v2/service/ssm v1.74.0 h1:11o/Z2uQatZTTWrkwN4MMPjhcVGnVQtLra5Md1tSSq0=
1072+github.com/aws/aws-sdk-go-v2/service/ssm v1.74.0/go.mod h1:uF4ifVWSlieuvF0ZSy9m6flJJojvWuEu80pJIO3UJ0U=
1073 github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 h1:aM/Q24rIlS3bRAhTyFurowU8A0SMyGDtEOY/l/s/1Uw=
1074 github.com/aws/aws-sdk-go-v2/service/sso v1.30.8/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg=
1075 github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE=
1076````
1077
1078- [ ] **Step 4: Write the package**
1079
1080`internal/hostenv/hostenv.go`:
1081
1082````go
1083// Package hostenv is a box's /etc/carlos/host.env as a parameter layout
1084// (region HA spec 2 §5 step 2, §4). Every value a hosting box reads from
1085// host.env is one parameter, named for its variable, under
1086// /carlos/<deployment>/<region>/, or under /carlos/<deployment>/shared/
1087// for the values every region of the deployment holds alike. A box's
1088// start-up assembles host.env from the two; an operator seeds them from a
1089// box's existing file.
1090//
1091// Where the parameters live is an adapter's business (awsssm.go, AWS SSM
1092// SecureStrings). This file names no provider (§25): host.env is a file,
1093// and Params is all the core asks of a parameter store.
1094package hostenv
1095
1096import (
1097 "bufio"
1098 "bytes"
1099 "context"
1100 "errors"
1101 "fmt"
1102 "regexp"
1103 "slices"
1104 "sort"
1105 "strings"
1106)
1107
1108// Path is the file a box's units read their environment from.
1109const Path = "/etc/carlos/host.env"
1110
1111// SharedScope is the scope of the values every region holds alike.
1112const SharedScope = "shared"
1113
1114// Required are the variables a hosting box cannot run without (§5 step 2):
1115// the buckets and their region, the release and secrets keys, the box's
1116// region and channel, the alert topic, and the claim and region HA
1117// switches. Assembling host.env refuses without any of them.
1118var Required = []string{
1119 "CARLOS_ALERT_SNS_TOPIC_ARN",
1120 "CARLOS_DEPLOYMENT_BUCKET",
1121 "CARLOS_HIBERNATE_BUCKET",
1122 "CARLOS_HIBERNATE_REGION",
1123 "CARLOS_HOST_CLAIM",
1124 "CARLOS_HOST_CLAIM_ORIGINS",
1125 "CARLOS_REGION",
1126 "CARLOS_REGION_HA",
1127 "CARLOS_RELEASE_PUBKEY",
1128 "CARLOS_SECRETS_KEY",
1129 "CARLOS_SYSTEM_CHANNEL",
1130}
1131
1132// Optional are the other variables a box its region's group launched may
1133// carry, from the agent's config surface (README's host.env list and the
1134// variables the agent reads). Together with Required they are the
1135// allowlist: any other name, under either scope, is refused by name,
1136// never by value, by write (Assemble) and by seed. The list leaves out
1137// the keys that select a box's authority (a fleet's or tunnel's identity,
1138// an address override, a pool, the data, opt and deployment directories,
1139// the relay's settings) and every token or API credential, except the
1140// deployment's own required secrets, CARLOS_SECRETS_KEY and
1141// CARLOS_RELEASE_PUBKEY. CARLOS_ALERT_WEBHOOK is left out on the same
1142// rule: a webhook URL can carry a credential. So are the status
1143// destination overrides, CARLOS_STATUS_BUCKET, CARLOS_STATUS_PREFIX and
1144// CARLOS_STATUS_REGION: a group box reports to the deployment bucket,
1145// unprefixed, where the console reads its guard-ready report, while it is
1146// joining and once it is hosting. An endpoint override such as
1147// CARLOS_S3_ENDPOINT stays, because it selects no box authority and only
1148// operators can write the parameter paths. CARLOS_REGION and the group
1149// marker, which do select, are cross-checked against the signed roster
1150// where the agent uses them (internal/host's mode selection). Adding a
1151// variable is a code change, reviewed.
1152var Optional = []string{
1153 "CARLOS_ACME_DELEGATION_ZONE",
1154 "CARLOS_ACME_DIRECTORY",
1155 "CARLOS_ALERT_CPU_PCT",
1156 "CARLOS_ALERT_DISK_PCT",
1157 "CARLOS_ALERT_MEM_PCT",
1158 "CARLOS_AWS_REGION",
1159 "CARLOS_BACKHAUL_MAX_INFLIGHT",
1160 "CARLOS_DNS_PROVIDER",
1161 "CARLOS_EDGE_GZIP",
1162 "CARLOS_HIBERNATE_IDLE",
1163 "CARLOS_HIBERNATE_PREFIX",
1164 "CARLOS_HOST_CLAIM_RETIRED_LOCATIONS",
1165 "CARLOS_HOST_METRICS",
1166 "CARLOS_LAYOUT_DEFAULT",
1167 "CARLOS_MOVES_PHASE1",
1168 "CARLOS_MOVE_READY_DELTA_BYTES",
1169 "CARLOS_REQUIRE_POINTER_DIGEST",
1170 "CARLOS_S3_ENDPOINT",
1171 "CARLOS_S3_QUIRKS",
1172 "CARLOS_STEERING_ZONE_ID",
1173 "CARLOS_SWAP",
1174 "CARLOS_UNIT_SETTLE",
1175 "CARLOS_VERSIONS_GRACE",
1176 "CARLOS_WILDCARD_DOMAINS",
1177 "STATUS_BOX_REGION",
1178}
1179
1180// Allowed reports whether name is a variable host.env may carry from the
1181// parameters: Required or Optional.
1182func Allowed(name string) bool {
1183 return slices.Contains(Required, name) || slices.Contains(Optional, name)
1184}
1185
1186// notAllowed lists, sorted, the names in env that are not Allowed.
1187func notAllowed(env map[string]string) []string {
1188 var out []string
1189 for k := range env {
1190 if k != BoxReference && !Allowed(k) {
1191 out = append(out, k)
1192 }
1193 }
1194 sort.Strings(out)
1195 return out
1196}
1197
1198// SharedKeys are the variables that are the same in every region of a
1199// deployment, so they are kept once, under the shared scope. Everything
1200// else is the region's own.
1201var SharedKeys = []string{
1202 "CARLOS_DEPLOYMENT_BUCKET",
1203 "CARLOS_HOST_CLAIM",
1204 "CARLOS_HOST_CLAIM_ORIGINS",
1205 "CARLOS_RELEASE_PUBKEY",
1206 "CARLOS_SECRETS_KEY",
1207}
1208
1209// BoxReference and GroupReference are the one line host.env carries that
1210// is not a parameter. write runs only in the start-up of a box its
1211// region's group launched, so a host.env it wrote marks such a box, and
1212// the box takes the reference its account and instance derive
1213// (hostactors.GroupBoxReference) instead of a status label. Every other
1214// box's host.env lacks it and keeps its label.
1215const (
1216 BoxReference = "CARLOS_BOX_REFERENCE"
1217 GroupReference = "group"
1218)
1219
1220// Params is a store of named, encrypted parameters.
1221type Params interface {
1222 // ByPath returns every parameter directly under prefix, keyed by its
1223 // name after the prefix, decrypted. A read that fails or stops part
1224 // way is an error, never a partial map.
1225 ByPath(ctx context.Context, prefix string) (map[string]string, error)
1226 // Set writes one parameter, encrypted, replacing any value it had.
1227 Set(ctx context.Context, name, value string) error
1228 // SetNew writes one parameter, encrypted, only if it does not exist:
1229 // ErrExists when it does, and the value is not changed.
1230 SetNew(ctx context.Context, name, value string) error
1231}
1232
1233var (
1234 // ErrMissing is a required variable with no value.
1235 ErrMissing = errors.New("required values are missing")
1236 // ErrConflict is a variable held both by the region and shared, or a
1237 // shared value seeding would change.
1238 ErrConflict = errors.New("values conflict")
1239 // ErrValue is a name or value host.env cannot hold safely.
1240 ErrValue = errors.New("a name or value host.env cannot hold")
1241 // ErrExists is SetNew's answer for a parameter that is already set:
1242 // another seed wrote it first.
1243 ErrExists = errors.New("already set by another seed")
1244)
1245
1246var (
1247 scopeName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`)
1248 keyName = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`)
1249 safeValue = regexp.MustCompile(`^[A-Za-z0-9_./:,@+=%-]*$`)
1250)
1251
1252// Prefix is the parameter path of one scope: a region's placement code or
1253// SharedScope. Both names are refused unless they are lower-case labels.
1254func Prefix(deployment, scope string) (string, error) {
1255 if !scopeName.MatchString(deployment) || !scopeName.MatchString(scope) {
1256 return "", fmt.Errorf("%w: %q and %q must be lower-case labels", ErrValue, deployment, scope)
1257 }
1258 return "/carlos/" + deployment + "/" + scope + "/", nil
1259}
1260
1261// ValidKey reports whether name is a variable host.env carries. Other
1262// parameters under a scope (the bootstrap object's sha256, for one) are
1263// not variables and are skipped.
1264func ValidKey(name string) bool { return keyName.MatchString(name) }
1265
1266// Assemble merges the shared and the region's values. A variable in both
1267// is a conflict, never resolved by precedence: one of the two is wrong.
1268// Every Required variable must have a value.
1269func Assemble(shared, regional map[string]string) (map[string]string, error) {
1270 env := map[string]string{}
1271 var both []string
1272 for _, src := range []map[string]string{shared, regional} {
1273 for k, v := range src {
1274 if !ValidKey(k) || k == BoxReference {
1275 continue
1276 }
1277 if _, dup := env[k]; dup {
1278 both = append(both, k)
1279 continue
1280 }
1281 env[k] = v
1282 }
1283 }
1284 if len(both) > 0 {
1285 sort.Strings(both)
1286 return nil, fmt.Errorf("%w: %s set both for the region and shared", ErrConflict, strings.Join(both, ", "))
1287 }
1288 if bad := notAllowed(env); len(bad) > 0 {
1289 return nil, fmt.Errorf("%w: %s", ErrValue, strings.Join(bad, ", "))
1290 }
1291 if missing := Missing(env); len(missing) > 0 {
1292 return nil, fmt.Errorf("%w: %s", ErrMissing, strings.Join(missing, ", "))
1293 }
1294 return env, nil
1295}
1296
1297// Missing lists the Required variables env has no value for, sorted.
1298func Missing(env map[string]string) []string {
1299 var out []string
1300 for _, k := range Required {
1301 if strings.TrimSpace(env[k]) == "" {
1302 out = append(out, k)
1303 }
1304 }
1305 return out
1306}
1307
1308// renderedHeader is the first line of a host.env Render writes.
1309const renderedHeader = "# Written by carlos system host-env write from the deployment's parameters. Do not edit by hand."
1310
1311// Render is host.env's bytes: one KEY=value line per variable, sorted.
1312// Values are written bare, so the file means the same to systemd's
1313// EnvironmentFile and to a shell that sources it. A value outside the safe
1314// set (letters, digits and _ . / : , @ + = % -) is refused rather than
1315// quoted; the error names the variables, never their values, which may be
1316// secrets.
1317func Render(env map[string]string) ([]byte, error) {
1318 keys := make([]string, 0, len(env))
1319 var bad []string
1320 for k, v := range env {
1321 if !ValidKey(k) || !safeValue.MatchString(v) {
1322 bad = append(bad, k)
1323 continue
1324 }
1325 keys = append(keys, k)
1326 }
1327 if len(bad) > 0 {
1328 sort.Strings(bad)
1329 return nil, fmt.Errorf("%w: %s", ErrValue, strings.Join(bad, ", "))
1330 }
1331 sort.Strings(keys)
1332 var b bytes.Buffer
1333 b.WriteString(renderedHeader + "\n")
1334 for _, k := range keys {
1335 b.WriteString(k + "=" + env[k] + "\n")
1336 }
1337 return b.Bytes(), nil
1338}
1339
1340// Parse reads an existing host.env: KEY=value lines, with blank lines,
1341// comments and a leading "export " ignored, and one layer of matching
1342// quotes removed from a value. A variable set twice, or a line that is
1343// not an assignment, is refused.
1344func Parse(body []byte) (map[string]string, error) {
1345 env := map[string]string{}
1346 sc := bufio.NewScanner(bytes.NewReader(body))
1347 for n := 1; sc.Scan(); n++ {
1348 line := strings.TrimSpace(sc.Text())
1349 if line == "" || strings.HasPrefix(line, "#") {
1350 continue
1351 }
1352 line = strings.TrimPrefix(line, "export ")
1353 k, v, ok := strings.Cut(line, "=")
1354 k = strings.TrimSpace(k)
1355 if !ok || !ValidKey(k) {
1356 return nil, fmt.Errorf("%w: line %d is not KEY=value", ErrValue, n)
1357 }
1358 v = strings.TrimSpace(v)
1359 if len(v) >= 2 && (v[0] == '"' || v[0] == '\'') && v[len(v)-1] == v[0] {
1360 v = v[1 : len(v)-1]
1361 }
1362 if _, dup := env[k]; dup {
1363 return nil, fmt.Errorf("%w: %s is set twice", ErrValue, k)
1364 }
1365 env[k] = v
1366 }
1367 if err := sc.Err(); err != nil {
1368 return nil, err
1369 }
1370 return env, nil
1371}
1372
1373// Split divides env into the shared variables and the region's own.
1374func Split(env map[string]string) (shared, regional map[string]string) {
1375 shared, regional = map[string]string{}, map[string]string{}
1376 for k, v := range env {
1377 if slices.Contains(SharedKeys, k) {
1378 shared[k] = v
1379 } else {
1380 regional[k] = v
1381 }
1382 }
1383 return shared, regional
1384}
1385
1386// Fetch assembles host.env for one region of a deployment from p, for a
1387// box the region's group launched: it adds BoxReference.
1388func Fetch(ctx context.Context, p Params, deployment, region string) ([]byte, error) {
1389 if region == SharedScope {
1390 return nil, fmt.Errorf("%w: %q is not a region", ErrValue, region)
1391 }
1392 sharedPrefix, err := Prefix(deployment, SharedScope)
1393 if err != nil {
1394 return nil, err
1395 }
1396 regionPrefix, err := Prefix(deployment, region)
1397 if err != nil {
1398 return nil, err
1399 }
1400 shared, err := p.ByPath(ctx, sharedPrefix)
1401 if err != nil {
1402 return nil, fmt.Errorf("parameters under %s: %w", sharedPrefix, err)
1403 }
1404 regional, err := p.ByPath(ctx, regionPrefix)
1405 if err != nil {
1406 return nil, fmt.Errorf("parameters under %s: %w", regionPrefix, err)
1407 }
1408 env, err := Assemble(shared, regional)
1409 if err != nil {
1410 return nil, err
1411 }
1412 env[BoxReference] = GroupReference
1413 return Render(env)
1414}
1415
1416// Seeded is what Seed wrote: the parameters under each prefix.
1417type Seeded struct {
1418 SharedPrefix, RegionPrefix string
1419 Shared, Regional int
1420}
1421
1422// Seed writes env, a box's existing host.env, into the layout for region.
1423// The region's variables are written over whatever is there. A shared
1424// variable is written only when it is not yet set, and create-only
1425// (SetNew): one region's file never changes a value every region reads.
1426// A shared value that differs is a conflict, and nothing at all is
1427// written. Two seeds racing for the same shared variable cannot both
1428// win: the one SetNew refuses reads the value back and goes on only if
1429// it is the value it would have written. env must hold every Required
1430// variable, in a form Render accepts. BoxReference is never seeded: it
1431// is write's.
1432func Seed(ctx context.Context, p Params, deployment, region string, env map[string]string) (Seeded, error) {
1433 if region == SharedScope {
1434 return Seeded{}, fmt.Errorf("%w: %q is not a region", ErrValue, region)
1435 }
1436 sharedPrefix, err := Prefix(deployment, SharedScope)
1437 if err != nil {
1438 return Seeded{}, err
1439 }
1440 regionPrefix, err := Prefix(deployment, region)
1441 if err != nil {
1442 return Seeded{}, err
1443 }
1444 if missing := Missing(env); len(missing) > 0 {
1445 return Seeded{}, fmt.Errorf("%w: %s", ErrMissing, strings.Join(missing, ", "))
1446 }
1447 if bad := notAllowed(env); len(bad) > 0 {
1448 return Seeded{}, fmt.Errorf("%w: %s", ErrValue, strings.Join(bad, ", "))
1449 }
1450 if _, err := Render(env); err != nil {
1451 return Seeded{}, err
1452 }
1453 shared, regional := Split(env)
1454 delete(regional, BoxReference)
1455 existing, err := p.ByPath(ctx, sharedPrefix)
1456 if err != nil {
1457 return Seeded{}, fmt.Errorf("parameters under %s: %w", sharedPrefix, err)
1458 }
1459 var differ []string
1460 for k, v := range shared {
1461 if have, ok := existing[k]; ok && have != v {
1462 differ = append(differ, k)
1463 }
1464 }
1465 if len(differ) > 0 {
1466 sort.Strings(differ)
1467 return Seeded{}, fmt.Errorf("%w: the shared %s already differ", ErrConflict, strings.Join(differ, ", "))
1468 }
1469 out := Seeded{SharedPrefix: sharedPrefix, RegionPrefix: regionPrefix}
1470 for _, k := range sortedKeys(shared) {
1471 if _, ok := existing[k]; ok {
1472 continue
1473 }
1474 err := p.SetNew(ctx, sharedPrefix+k, shared[k])
1475 if errors.Is(err, ErrExists) {
1476 // Another seed set it since the read above: go on only if it
1477 // set what this one would have.
1478 if err := sameShared(ctx, p, sharedPrefix, k, shared[k]); err != nil {
1479 return out, err
1480 }
1481 continue
1482 }
1483 if err != nil {
1484 return out, fmt.Errorf("%s%s: %w", sharedPrefix, k, err)
1485 }
1486 out.Shared++
1487 }
1488 for _, k := range sortedKeys(regional) {
1489 if err := p.Set(ctx, regionPrefix+k, regional[k]); err != nil {
1490 return out, fmt.Errorf("%s%s: %w", regionPrefix, k, err)
1491 }
1492 out.Regional++
1493 }
1494 return out, nil
1495}
1496
1497// sameShared reads the shared variable k back after SetNew found it set,
1498// and refuses unless it holds want. The error names the variable, never
1499// either value.
1500func sameShared(ctx context.Context, p Params, sharedPrefix, k, want string) error {
1501 now, err := p.ByPath(ctx, sharedPrefix)
1502 if err != nil {
1503 return fmt.Errorf("parameters under %s: %w", sharedPrefix, err)
1504 }
1505 have, ok := now[k]
1506 if !ok {
1507 return fmt.Errorf("%s%s: %w", sharedPrefix, k, ErrExists)
1508 }
1509 if have != want {
1510 return fmt.Errorf("%w: the shared %s already differ", ErrConflict, k)
1511 }
1512 return nil
1513}
1514
1515func sortedKeys(m map[string]string) []string {
1516 keys := make([]string, 0, len(m))
1517 for k := range m {
1518 keys = append(keys, k)
1519 }
1520 sort.Strings(keys)
1521 return keys
1522}
1523````
1524
1525`internal/hostenv/cli.go`:
1526
1527````go
1528package hostenv
1529
1530import (
1531 "context"
1532 "errors"
1533 "flag"
1534 "fmt"
1535 "io"
1536 "os"
1537 "path/filepath"
1538)
1539
1540// carlos system host-env write|seed (region HA spec 2 §5 step 2).
1541//
1542// - write is the start-up script's step: as root, it assembles host.env
1543// from the deployment's parameters and writes it 0600. A missing
1544// required value stops it, and the file is left as it was.
1545// - seed is the operator's: it writes a box's existing host.env into
1546// the parameter layout, once per region.
1547
1548const hostEnvUsage = `usage: carlos system host-env write --deployment <name> --region <code> [--aws-region <region>]
1549 carlos system host-env seed --deployment <name> --region <code> --kms-key <key> [--aws-region <region>] [--from <path>]
1550
1551 write build host.env from the deployment's parameters (root)
1552 seed copy a box's host.env into the deployment's parameters
1553`
1554
1555// hostEnvRoot is the root write's fixed path is under: "/" on a box.
1556// Tests point it at a directory of their own; there is no flag.
1557var hostEnvRoot = "/"
1558
1559// openParams is the parameter store: the AWS adapter's (awsssm.go).
1560// kmsKey is the key seed encrypts with; write passes "".
1561var openParams = openSSMParams
1562
1563// RunSystemHostEnv is `carlos system host-env`.
1564func RunSystemHostEnv(args []string) {
1565 if err := runHostEnv(context.Background(), args, os.Stdout, os.Geteuid(), openParams); err != nil {
1566 fmt.Fprintln(os.Stderr, err)
1567 os.Exit(1)
1568 }
1569}
1570
1571func runHostEnv(ctx context.Context, args []string, out io.Writer, euid int, open func(ctx context.Context, awsRegion, kmsKey string) (Params, error)) error {
1572 if len(args) == 0 || (args[0] != "write" && args[0] != "seed") {
1573 return usageError()
1574 }
1575 verb := args[0]
1576 fs := flag.NewFlagSet("system host-env "+verb, flag.ContinueOnError)
1577 fs.SetOutput(io.Discard)
1578 deployment := fs.String("deployment", "", "")
1579 region := fs.String("region", "", "")
1580 awsRegion := fs.String("aws-region", "", "")
1581 kmsKey := fs.String("kms-key", "", "")
1582 from := fs.String("from", Path, "")
1583 if err := fs.Parse(args[1:]); err != nil || fs.NArg() != 0 || *deployment == "" || *region == "" {
1584 return usageError()
1585 }
1586 switch verb {
1587 case "write":
1588 if euid != 0 {
1589 return errors.New("carlos system host-env write runs as root: it writes a root-owned file")
1590 }
1591 p, err := open(ctx, *awsRegion, "")
1592 if err != nil {
1593 return err
1594 }
1595 body, err := Fetch(ctx, p, *deployment, *region)
1596 if err != nil {
1597 return fmt.Errorf("host.env was not written: %w", err)
1598 }
1599 // The path is fixed, /etc/carlos/host.env, and the file is written
1600 // only through directories proved to be root's alone, then
1601 // replaced whole (writeHostEnv).
1602 if err := writeHostEnv(hostEnvRoot, body); err != nil {
1603 return fmt.Errorf("host.env was not written: %w", err)
1604 }
1605 fmt.Fprintln(out, wroteFileLine(filepath.Join(hostEnvRoot, Path), *region))
1606 return nil
1607 default:
1608 if *kmsKey == "" {
1609 return usageError()
1610 }
1611 body, err := os.ReadFile(*from)
1612 if err != nil {
1613 return err
1614 }
1615 env, err := Parse(body)
1616 if err != nil {
1617 return fmt.Errorf("%s: %w", *from, err)
1618 }
1619 p, err := open(ctx, *awsRegion, *kmsKey)
1620 if err != nil {
1621 return err
1622 }
1623 s, err := Seed(ctx, p, *deployment, *region, env)
1624 if err != nil {
1625 return fmt.Errorf("the parameters were not all written: %w", err)
1626 }
1627 fmt.Fprintln(out, seededLine(s))
1628 return nil
1629 }
1630}
1631
1632func usageError() error { return errors.New(hostEnvUsage) }
1633
1634// wroteFileLine is what write says once host.env is in place.
1635func wroteFileLine(path, region string) string {
1636 return fmt.Sprintf("wrote %s from the parameters for %s", path, region)
1637}
1638
1639// seededLine is what seed says once the parameters are written.
1640func seededLine(s Seeded) string {
1641 return fmt.Sprintf("wrote %d parameters under %s and %d under %s", s.Regional, s.RegionPrefix, s.Shared, s.SharedPrefix)
1642}
1643````
1644
1645`internal/hostenv/write_linux.go`:
1646
1647````go
1648//go:build linux
1649
1650package hostenv
1651
1652import (
1653 "crypto/rand"
1654 "errors"
1655 "fmt"
1656 "os"
1657 "path/filepath"
1658
1659 "golang.org/x/sys/unix"
1660)
1661
1662// writeHostEnv writes body as <root>/etc/carlos/host.env, 0600, owned by
1663// the process's user (root on a box). Every directory on the way is held
1664// by descriptor, never looked up again by name: etc and etc/carlos are
1665// opened with O_NOFOLLOW|O_DIRECTORY, so a symlink at either is refused,
1666// and each must be owned by this user and writable by no one else
1667// (checkedDir), so no other user can swap the file or the directory under
1668// it. etc/carlos is created 0755 if it is missing, then opened and
1669// checked like the rest. The new file is written beside the old one in
1670// that directory, synced, renamed over it, and the directory synced, so a
1671// reader sees the old file or the new one, never part of either.
1672func writeHostEnv(root string, body []byte) error {
1673 rootFD, err := unix.Open(root, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
1674 if err != nil {
1675 return err
1676 }
1677 defer unix.Close(rootFD)
1678 etc, err := checkedDir(rootFD, filepath.Join(root, "etc"), "etc", false)
1679 if err != nil {
1680 return err
1681 }
1682 defer unix.Close(etc)
1683 dir, err := checkedDir(etc, filepath.Join(root, "etc", "carlos"), "carlos", true)
1684 if err != nil {
1685 return err
1686 }
1687 defer unix.Close(dir)
1688 tmp := ".host.env." + rand.Text()
1689 fd, err := unix.Openat(dir, tmp, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0o600)
1690 if err != nil {
1691 return err
1692 }
1693 defer unix.Unlinkat(dir, tmp, 0) // gone once renamed
1694 f := os.NewFile(uintptr(fd), tmp)
1695 if err := f.Chmod(0o600); err != nil {
1696 f.Close()
1697 return err
1698 }
1699 if _, err := f.Write(body); err != nil {
1700 f.Close()
1701 return err
1702 }
1703 if err := f.Sync(); err != nil {
1704 f.Close()
1705 return err
1706 }
1707 if err := f.Close(); err != nil {
1708 return err
1709 }
1710 if err := unix.Renameat(dir, tmp, dir, "host.env"); err != nil {
1711 return err
1712 }
1713 return unix.Fsync(dir)
1714}
1715
1716// checkedDir opens name under the directory parent as a real directory,
1717// creating it 0755 first when create is set and it is missing, and refuses
1718// one that is a symlink, is not a directory, is not owned by this
1719// process's user (hostEnvOwner), or is writable by group or others. path
1720// is its name for the refusal.
1721func checkedDir(parent int, path, name string, create bool) (int, error) {
1722 const how = unix.O_RDONLY | unix.O_DIRECTORY | unix.O_NOFOLLOW | unix.O_CLOEXEC
1723 fd, err := unix.Openat(parent, name, how, 0)
1724 if errors.Is(err, unix.ENOENT) && create {
1725 if err := unix.Mkdirat(parent, name, 0o755); err != nil && !errors.Is(err, unix.EEXIST) {
1726 return -1, err
1727 }
1728 fd, err = unix.Openat(parent, name, how, 0)
1729 }
1730 if errors.Is(err, unix.ELOOP) || errors.Is(err, unix.ENOTDIR) {
1731 return -1, notRootsDir(path)
1732 }
1733 if err != nil {
1734 return -1, err
1735 }
1736 var st unix.Stat_t
1737 if err := unix.Fstat(fd, &st); err != nil {
1738 unix.Close(fd)
1739 return -1, err
1740 }
1741 if st.Mode&unix.S_IFMT != unix.S_IFDIR || int(st.Uid) != hostEnvOwner() || st.Mode&0o022 != 0 {
1742 unix.Close(fd)
1743 return -1, notRootsDir(path)
1744 }
1745 return fd, nil
1746}
1747
1748// hostEnvOwner is the user etc and etc/carlos must belong to: this
1749// process's, which runHostEnv has already required to be root. A var so a
1750// test can stand for a directory another user owns.
1751var hostEnvOwner = os.Geteuid
1752
1753func notRootsDir(path string) error {
1754 return fmt.Errorf("%s is not a directory only root can write", path)
1755}
1756````
1757
1758`internal/hostenv/write_other.go`:
1759
1760````go
1761//go:build !linux
1762
1763package hostenv
1764
1765import "errors"
1766
1767// writeHostEnv is Linux's: a box that writes host.env runs Linux, and only
1768// there are its directories opened and checked by descriptor.
1769func writeHostEnv(root string, body []byte) error {
1770 return errors.New("carlos system host-env write runs only on Linux")
1771}
1772````
1773
1774`internal/hostenv/awsssm.go`:
1775
1776````go
1777package hostenv
1778
1779import (
1780 "context"
1781 "errors"
1782 "strings"
1783
1784 "github.com/aws/aws-sdk-go-v2/aws"
1785 "github.com/aws/aws-sdk-go-v2/config"
1786 "github.com/aws/aws-sdk-go-v2/service/ssm"
1787 ssmtypes "github.com/aws/aws-sdk-go-v2/service/ssm/types"
1788)
1789
1790// The AWS adapter for Params (region HA spec 2 §25): SSM Parameter Store
1791// SecureStrings, encrypted with the deployment's configuration key (§4).
1792// A box reads its two paths with the role's ssm:GetParametersByPath and
1793// kms:Decrypt; an operator seeding them needs ssm:PutParameter and
1794// kms:Encrypt on that key.
1795
1796type ssmParams struct {
1797 client *ssm.Client
1798 kmsKey string
1799}
1800
1801// openSSMParams opens SSM in awsRegion ("" is the SDK's default region).
1802// kmsKey is the key Set encrypts with.
1803func openSSMParams(ctx context.Context, awsRegion, kmsKey string) (Params, error) {
1804 var opts []func(*config.LoadOptions) error
1805 if awsRegion != "" {
1806 opts = append(opts, config.WithRegion(awsRegion))
1807 }
1808 cfg, err := config.LoadDefaultConfig(ctx, opts...)
1809 if err != nil {
1810 return nil, err
1811 }
1812 return &ssmParams{client: ssm.NewFromConfig(cfg), kmsKey: kmsKey}, nil
1813}
1814
1815// ByPath reads every parameter one level under prefix, decrypted, page by
1816// page. Any failed page fails the whole read.
1817func (p *ssmParams) ByPath(ctx context.Context, prefix string) (map[string]string, error) {
1818 out := map[string]string{}
1819 pages := ssm.NewGetParametersByPathPaginator(p.client, &ssm.GetParametersByPathInput{
1820 Path: aws.String(strings.TrimSuffix(prefix, "/")),
1821 Recursive: aws.Bool(false),
1822 WithDecryption: aws.Bool(true),
1823 })
1824 for pages.HasMorePages() {
1825 page, err := pages.NextPage(ctx)
1826 if err != nil {
1827 return nil, err
1828 }
1829 for _, param := range page.Parameters {
1830 name, ok := strings.CutPrefix(aws.ToString(param.Name), prefix)
1831 if !ok || name == "" || strings.Contains(name, "/") {
1832 continue
1833 }
1834 out[name] = aws.ToString(param.Value)
1835 }
1836 }
1837 return out, nil
1838}
1839
1840// Set writes one SecureString with the deployment's key, replacing it:
1841// a region's own values.
1842func (p *ssmParams) Set(ctx context.Context, name, value string) error {
1843 in := &ssm.PutParameterInput{
1844 Name: aws.String(name),
1845 Value: aws.String(value),
1846 Type: ssmtypes.ParameterTypeSecureString,
1847 Overwrite: aws.Bool(true),
1848 }
1849 if p.kmsKey != "" {
1850 in.KeyId = aws.String(p.kmsKey)
1851 }
1852 _, err := p.client.PutParameter(ctx, in)
1853 return err
1854}
1855
1856// SetNew writes one SecureString with the deployment's key only if it does
1857// not exist (Overwrite false). SSM's ParameterAlreadyExists is ErrExists.
1858func (p *ssmParams) SetNew(ctx context.Context, name, value string) error {
1859 in := &ssm.PutParameterInput{
1860 Name: aws.String(name),
1861 Value: aws.String(value),
1862 Type: ssmtypes.ParameterTypeSecureString,
1863 Overwrite: aws.Bool(false),
1864 }
1865 if p.kmsKey != "" {
1866 in.KeyId = aws.String(p.kmsKey)
1867 }
1868 _, err := p.client.PutParameter(ctx, in)
1869 var exists *ssmtypes.ParameterAlreadyExists
1870 if errors.As(err, &exists) {
1871 return ErrExists
1872 }
1873 return err
1874}
1875````
1876
1877- [ ] **Step 5: Run the tests**
1878
1879Run: `nice -n 19 go test ./internal/hostenv/ -count=1`
1880Expected: PASS.
1881
1882- [ ] **Step 6: The provider boundary**
1883
1884List the core files, the adapter and the package:
1885
1886`region_ha_boundary_test.go`:
1887
1888````diff
1889--- a/region_ha_boundary_test.go
1890+++ b/region_ha_boundary_test.go
1891@@ -30,6 +30,10 @@
1892 "internal/console/nocapacity.go",
1893 "internal/hostops/chosen.go",
1894 "internal/console/regioncapacity.go",
1895+ "internal/hostenv/hostenv.go",
1896+ "internal/hostenv/cli.go",
1897+ "internal/hostenv/write_linux.go",
1898+ "internal/hostenv/write_other.go",
1899 }
1900
1901 // regionHAAdapterFiles are spec 2's AWS adapters. Each is named for AWS so
1902@@ -37,6 +41,7 @@
1903 var regionHAAdapterFiles = []string{
1904 "internal/host/awsaddr.go",
1905 "internal/console/awsregionmetric.go",
1906+ "internal/hostenv/awsssm.go",
1907 }
1908
1909 // regionHAPackages are the packages spec 2 touches. Every S3 client built
1910@@ -49,6 +54,7 @@
1911 "internal/edgesfile",
1912 "internal/host",
1913 "internal/hostactors",
1914+ "internal/hostenv",
1915 "internal/hostops",
1916 "internal/lbhealth",
1917 }
1918````
1919
1920Run: `nice -n 19 go test . -run 'TestRegionHA(Core|Adapters|S3)' -count=1`
1921Expected: PASS.
1922
1923- [ ] **Step 7: Mark the rows built**
1924
1925Add RHB-C1 to RHB-C18, RHB-C64, RHB-C67 and RHB-C68 to `regionHA2bCopyBuilt`, in the same commit as their text:
1926
1927`approved_region_ha_2b_copy_test.go`:
1928
1929````diff
1930--- a/approved_region_ha_2b_copy_test.go
1931+++ b/approved_region_ha_2b_copy_test.go
1932@@ -38,7 +38,7 @@
1933 // regionHA2bCopyBuilt is every id whose text the code or docs carry. The
1934 // task that writes a row's text adds its ids here in the same commit, once
1935 // Paul has approved the rows.
1936-var regionHA2bCopyBuilt = []string{}
1937+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"}
1938
1939 func mustParseRegionHA2bRecord(t *testing.T) []copyRow {
1940 t.Helper()
1941````
1942
1943Run: `nice -n 19 go test . -run 'RegionHA2b' -count=1`
1944Expected: PASS once the rows are approved. While any built row is pending, `TestRegionHA2bBuiltCopyIsApproved` fails naming each one, and nothing else fails.
1945
1946- [ ] **Step 8: Census**
1947
1948Create 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.
1949
1950`internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv`:
1951
1952````text
1953# 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.
1954# 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.
1955@coverage . be7c96bb2ff00b5b018b436f976f0d33bbcc8765c17e357baa6cd0290f59f42d
1956@coverage internal/hostenv a3f3274a86cdf8194afb871301c1ba3289dfbdc9b703dd547178273df151cdaa
1957internal/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
1958internal/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
1959internal/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
1960````
1961
1962`internal/provision/plan14_root_reads_test.go`:
1963
1964````diff
1965--- a/internal/provision/plan14_root_reads_test.go
1966+++ b/internal/provision/plan14_root_reads_test.go
1967@@ -806,6 +806,17 @@
1968 t.Fatal(err)
1969 }
1970 seal = "8198674d8809ef90ecebac55324bfd75e69fb3d73bc27b942f29ca2f43e3fe8d"
1971+ if errs := checkRootReadLegacy(inv, seal); len(errs) != 0 {
1972+ t.Fatal(errs)
1973+ }
1974+ // Census overlay region-ha-2b (region HA spec 2, plan 2b), loaded last.
1975+ // Its header says what each plan task added. The rows it adds are
1976+ // census ruling region-ha-2b's (platform-1e); its accepted-legacy rows
1977+ // move the seal.
1978+ if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv"), &inv, true); err != nil {
1979+ t.Fatal(err)
1980+ }
1981+ seal = "8198674d8809ef90ecebac55324bfd75e69fb3d73bc27b942f29ca2f43e3fe8d"
1982 errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...)
1983 errs = append(errs, checkRootReadDeclarations(got, inv)...)
1984 if len(errs) > 0 {
1985````
1986
1987Run: `nice -n 19 go test ./internal/provision/ -run 'TestPlan9B8RecordWriteCensus|TestPlan14RootReadCensus$' -count=1 && nice -n 19 go test ./internal/console/ -run TestClaimAuthoritySourceCensus -count=1`
1988Expected: PASS.
1989
1990- [ ] **Step 9: Commit**
1991
1992```bash
1993git 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
1994git commit -m "region HA 2b: host.env from the deployment's parameters"
1995```
1996
1997---
1998
1999### Task 3: `carlos system install-channel`
2000
2001**Files:**
2002- Create: `internal/host/installchannel.go`, `internal/host/installchannel_linux.go`, `internal/host/installchannel_other.go`, `internal/host/installchannel_test.go`
2003- Modify: `region_ha_boundary_test.go`, `approved_region_ha_2b_copy_test.go`, the overlay
2004
2005**Interfaces:**
2006- 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`.
2007- 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`.
2008
2009This 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.
2010
2011A 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).
2012
2013Install-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.
2014
2015- [ ] **Step 1: Write the failing tests**
2016
2017The fixture is the apply tests' `newApplyFixture` with its binary removed: a fresh box.
2018
2019`internal/host/installchannel_test.go`:
2020
2021````go
2022package host
2023
2024import (
2025 "bytes"
2026 "context"
2027 "crypto/ed25519"
2028 "crypto/sha256"
2029 "encoding/hex"
2030 "encoding/json"
2031 "errors"
2032 "os"
2033 "path/filepath"
2034 "reflect"
2035 "strings"
2036 "testing"
2037
2038 "github.com/carlosframework/platform/internal/hostfloor"
2039 "github.com/carlosframework/platform/internal/release"
2040)
2041
2042// channelFixture is a fresh box (applyFixture with no binary on it) whose
2043// stable channel names v1.4.0, whose identity is published, and whose
2044// deployment has a host floor.
2045type channelFixture struct {
2046 *applyFixture
2047 in channelInstall
2048 body []byte
2049 fetches int
2050 owned []string
2051}
2052
2053func newChannelFixture(t *testing.T, floor string) *channelFixture {
2054 t.Helper()
2055 f := &channelFixture{applyFixture: newApplyFixture(t, "stable", "bootstrap")}
2056 if err := os.Remove(systemBinaryPath(f.a.optDir)); err != nil {
2057 t.Fatal(err)
2058 }
2059 f.body = []byte("#!/bin/sh\necho carlos v1.4.0\n")
2060 f.publish(t, "v1.4.0", f.body)
2061 if floor != "" {
2062 if err := hostfloor.Publish(context.Background(), f.store, release.RootSigner(f.priv), "bucket", hostfloor.Floor{Min: 1, MinRelease: floor}); err != nil {
2063 t.Fatal(err)
2064 }
2065 }
2066 f.in = channelInstall{a: f.a, bucket: "bucket", fetchTo: func(ctx context.Context, s release.Store, key, dst, sha string) error {
2067 f.fetches++
2068 return release.FetchToFile(ctx, s, key, dst, systemArtifactLimit, sha)
2069 }, own: func(path string) error {
2070 f.owned = append(f.owned, path)
2071 return nil
2072 }}
2073 return f
2074}
2075
2076// publish promotes version on stable with body as its linux-arm64
2077// artifact and publishes its release identity.
2078func (f *channelFixture) publish(t *testing.T, version string, body []byte) {
2079 t.Helper()
2080 ctx := context.Background()
2081 sum := sha256.Sum256(body)
2082 rel := release.SystemRelease{Version: version, ReleaseTag: version, MovesProtocol: 1, Artifacts: map[string]release.SystemArtifact{
2083 "linux-arm64": {Key: release.SystemArtifactKey(version, "linux-arm64"), SHA256: hex.EncodeToString(sum[:])}}}
2084 if _, err := f.store.Put(ctx, rel.Artifacts["linux-arm64"].Key, body, ""); err != nil {
2085 t.Fatal(err)
2086 }
2087 if _, err := release.PromoteSystem(ctx, f.store, release.RootSigner(f.priv), "router", "stable", rel, release.SystemPromoteOptions{}); err != nil {
2088 t.Fatal(err)
2089 }
2090 wire, _, err := f.store.Get(ctx, release.SystemPointerKey("router", "stable"))
2091 if err != nil {
2092 t.Fatal(err)
2093 }
2094 if err := release.PublishSystemIdentity(ctx, f.store, f.pub, wire); err != nil {
2095 t.Fatal(err)
2096 }
2097}
2098
2099func (f *channelFixture) slots(t *testing.T) (cur, prev []byte) {
2100 t.Helper()
2101 cur, _ = os.ReadFile(systemBinaryPath(f.a.optDir))
2102 prev, _ = os.ReadFile(systemPrevPath(f.a.optDir))
2103 return cur, prev
2104}
2105
2106func TestInstallChannelPutsTheChannelReleaseInBothSlots(t *testing.T) {
2107 f := newChannelFixture(t, "v1.2.0")
2108 got, err := f.in.run(context.Background())
2109 if err != nil {
2110 t.Fatal(err)
2111 }
2112 if got.Version != "v1.4.0" || got.Tag != "v1.4.0" || got.Already {
2113 t.Fatalf("installed %+v", got)
2114 }
2115 cur, prev := f.slots(t)
2116 if !bytes.Equal(cur, f.body) || !bytes.Equal(prev, f.body) {
2117 t.Fatalf("slots hold %q and %q, not the channel's artifact", cur, prev)
2118 }
2119 // The identity guard-arm will be given is the channel release's.
2120 held, err := os.ReadFile(channelIdentityPath(f.a.optDir))
2121 if err != nil {
2122 t.Fatal(err)
2123 }
2124 p, err := release.VerifySystemIdentity(f.pub, held)
2125 if err != nil || p.Current.Version != "v1.4.0" {
2126 t.Fatalf("identity %v %v", p, err)
2127 }
2128 // An update later finds the binary on disk known good, and has seen
2129 // this promote.
2130 applied, err := readSystemApplied(f.a.optDir)
2131 if err != nil || applied == nil || applied.Version != "v1.4.0" || applied.Channel != "stable" {
2132 t.Fatalf("applied %+v %v", applied, err)
2133 }
2134 seen, err := readSystemSeen(f.a.optDir)
2135 if err != nil || seen["stable"].IsZero() {
2136 t.Fatalf("seen %v %v", seen, err)
2137 }
2138 // Nothing is left in staging or the agent's cache.
2139 if _, err := os.Stat(f.a.stagingDir()); !os.IsNotExist(err) {
2140 t.Fatalf("staging left: %v", err)
2141 }
2142 if _, err := os.Stat(filepath.Dir(systemCachePath(f.a.dataDir, "v1.4.0"))); !os.IsNotExist(err) {
2143 t.Fatalf("cache left: %v", err)
2144 }
2145 // The candidate was asked its version from staging before it was put
2146 // in place.
2147 if !f.called("run carlos version") {
2148 t.Fatalf("calls %v", f.calls)
2149 }
2150 // R2, S2: every directory on the cache's path is handed to the
2151 // service user, and nothing else, so the agent's own update can
2152 // download the next release beside them and ask for it.
2153 if want := cachePathDirs(f.a.dataDir, "v1.4.0"); !reflect.DeepEqual(f.owned, want) {
2154 t.Fatalf("handed to the service user: %v, want %v", f.owned, want)
2155 }
2156 // Run again: nothing to do, nothing fetched.
2157 again, err := f.in.run(context.Background())
2158 if err != nil || !again.Already || f.fetches != 1 {
2159 t.Fatalf("second run %+v %v, %d fetches", again, err, f.fetches)
2160 }
2161 if line := installedLine(again); line != "v1.4.0 (v1.4.0) from the stable channel is already installed" {
2162 t.Fatalf("line %q", line)
2163 }
2164 // The agent, its next release promoted, downloads it and asks for it
2165 // in the directories install-channel left.
2166 leg := &systemLeg{dataDir: f.a.dataDir, fetchTo: func(ctx context.Context, key, dst, sha string) error {
2167 return release.FetchToFile(ctx, f.store, key, dst, systemArtifactLimit, sha)
2168 }}
2169 next := []byte("#!/bin/sh\necho carlos v1.5.0\n")
2170 f.publish(t, "v1.5.0", next)
2171 sum := sha256.Sum256(next)
2172 pending := &systemPending{version: "v1.5.0", artifact: release.SystemArtifact{Key: release.SystemArtifactKey("v1.5.0", "linux-arm64"), SHA256: hex.EncodeToString(sum[:])}}
2173 if err := leg.fetch(context.Background(), pending); err != nil {
2174 t.Fatalf("the agent's next download: %v", err)
2175 }
2176 if err := leg.writeRequest(pending, f.now); err != nil {
2177 t.Fatalf("the agent's next request: %v", err)
2178 }
2179}
2180
2181// cachePathDirs are the directories install-channel hands the service
2182// user for version: system, system/router and the version's own.
2183func cachePathDirs(dataDir, version string) []string {
2184 return []string{filepath.Join(dataDir, "system"), filepath.Join(dataDir, "system", "router"), filepath.Join(dataDir, "system", "router", version)}
2185}
2186
2187// S2: a run whose ownership handoff fails installs nothing; the next run
2188// reconciles every directory on the path, including the ones the failed
2189// run created, not only the ones it creates itself.
2190func TestInstallChannelReconcilesOwnershipOnARetry(t *testing.T) {
2191 f := newChannelFixture(t, "v1.2.0")
2192 own := f.in.own
2193 f.in.own = func(string) error { return errors.New("chown: operation not permitted") }
2194 if _, err := f.in.run(context.Background()); err == nil {
2195 t.Fatal("a failed handoff installed")
2196 }
2197 if cur, prev := f.slots(t); cur != nil || prev != nil || f.fetches != 0 {
2198 t.Fatalf("a failed handoff wrote %q %q after %d fetches", cur, prev, f.fetches)
2199 }
2200 // The failed run created system, as root, and never handed it over.
2201 if _, err := os.Stat(filepath.Join(f.a.dataDir, "system")); err != nil {
2202 t.Fatalf("the failed run's parent: %v", err)
2203 }
2204 f.in.own = own
2205 if _, err := f.in.run(context.Background()); err != nil {
2206 t.Fatal(err)
2207 }
2208 if want := cachePathDirs(f.a.dataDir, "v1.4.0"); !reflect.DeepEqual(f.owned, want) {
2209 t.Fatalf("the retry handed %v, want %v", f.owned, want)
2210 }
2211}
2212
2213// S2: a symlinked parent is refused before anything is fetched, and never
2214// followed; the real handoff opens each directory without following one.
2215func TestInstallChannelRefusesASymlinkedParent(t *testing.T) {
2216 f := newChannelFixture(t, "v1.2.0")
2217 elsewhere := t.TempDir()
2218 if err := os.Symlink(elsewhere, filepath.Join(f.a.dataDir, "system")); err != nil {
2219 t.Fatal(err)
2220 }
2221 if _, err := f.in.run(context.Background()); err == nil || f.fetches != 0 || len(f.owned) != 0 {
2222 t.Fatalf("symlinked parent: %v, %d fetches, owned %v", err, f.fetches, f.owned)
2223 }
2224 if ents, _ := os.ReadDir(elsewhere); len(ents) != 0 {
2225 t.Fatalf("followed the symlink: %v", ents)
2226 }
2227 if err := ownDir(filepath.Join(f.a.dataDir, "system"), os.Getuid(), os.Getgid()); err == nil {
2228 t.Fatal("the handoff followed a symlink")
2229 }
2230 if err := ownDir(elsewhere, os.Getuid(), os.Getgid()); err != nil {
2231 t.Fatalf("the handoff of a real directory: %v", err)
2232 }
2233}
2234
2235func TestInstallChannelRefusesBelowTheFloorAndWithoutOne(t *testing.T) {
2236 for _, c := range []struct{ name, floor, want string }{
2237 {"below the floor", "v2.0.0", "below the host floor v2.0.0"},
2238 {"no floor", "", "no host floor is published"},
2239 } {
2240 t.Run(c.name, func(t *testing.T) {
2241 f := newChannelFixture(t, c.floor)
2242 _, err := f.in.run(context.Background())
2243 if err == nil || !strings.Contains(err.Error(), c.want) {
2244 t.Fatalf("err %v", err)
2245 }
2246 cur, prev := f.slots(t)
2247 if cur != nil || prev != nil || f.fetches != 0 {
2248 t.Fatalf("refused install wrote %q %q after %d fetches", cur, prev, f.fetches)
2249 }
2250 })
2251 }
2252}
2253
2254func TestInstallChannelRefusesAnUnreadableStoreAndABadArtifact(t *testing.T) {
2255 f := newChannelFixture(t, "v1.2.0")
2256 f.a.openStore = func(context.Context) (release.Store, ed25519.PublicKey, error) {
2257 return nil, nil, errors.New("no route to bucket")
2258 }
2259 if _, err := f.in.run(context.Background()); err == nil || !strings.Contains(err.Error(), "could not be read") {
2260 t.Fatalf("unreachable store: %v", err)
2261 }
2262 g := newChannelFixture(t, "v1.2.0")
2263 // The object no longer matches the signed sha256.
2264 _, etag, _ := g.store.Get(context.Background(), release.SystemArtifactKey("v1.4.0", "linux-arm64"))
2265 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 {
2266 t.Fatal(err)
2267 }
2268 if _, err := g.in.run(context.Background()); err == nil || !strings.Contains(err.Error(), "could not be fetched") {
2269 t.Fatalf("swapped artifact: %v", err)
2270 }
2271 if cur, prev := g.slots(t); cur != nil || prev != nil {
2272 t.Fatalf("swapped artifact installed %q %q", cur, prev)
2273 }
2274}
2275
2276func TestInstallChannelLeavesAnInstalledBoxToUpdates(t *testing.T) {
2277 // An armed guard.
2278 f := newChannelFixture(t, "v1.2.0")
2279 ctx := context.Background()
2280 raw, _, _ := f.store.Get(ctx, hostfloor.Key)
2281 auth, _, _ := f.store.Get(ctx, hostfloor.AuthKey)
2282 policy, _ := json.Marshal(hostfloor.Policy{Schema: 1, DeploymentBucket: "bucket", Mode: "armed", Floor: raw, Authorization: auth})
2283 if err := os.MkdirAll(systemGuardDir(f.a.optDir), 0o755); err != nil {
2284 t.Fatal(err)
2285 }
2286 if err := os.WriteFile(filepath.Join(systemGuardDir(f.a.optDir), "policy.json"), policy, 0o644); err != nil {
2287 t.Fatal(err)
2288 }
2289 if _, err := f.in.run(ctx); err == nil || !strings.Contains(err.Error(), "guard is armed") {
2290 t.Fatalf("armed guard: %v", err)
2291 }
2292 // A box an earlier install or update put another release on.
2293 g := newChannelFixture(t, "v1.2.0")
2294 if _, err := g.in.run(ctx); err != nil {
2295 t.Fatal(err)
2296 }
2297 g.publish(t, "v1.5.0", []byte("#!/bin/sh\necho carlos v1.5.0\n"))
2298 if _, err := g.in.run(ctx); err == nil || !strings.Contains(err.Error(), "already runs v1.4.0") {
2299 t.Fatalf("installed box: %v", err)
2300 }
2301 if cur, _ := g.slots(t); !bytes.Equal(cur, g.body) {
2302 t.Fatalf("installed box changed to %q", cur)
2303 }
2304}
2305````
2306
2307- [ ] **Step 2: Run them to see them fail**
2308
2309Run: `nice -n 19 go test ./internal/host/ -run TestInstallChannel -count=1`
2310Expected: FAIL, the package does not build (`undefined: channelInstall`).
2311
2312- [ ] **Step 3: Write the command**
2313
2314`internal/host/installchannel.go`:
2315
2316````go
2317package host
2318
2319import (
2320 "context"
2321 "crypto/ed25519"
2322 "errors"
2323 "flag"
2324 "fmt"
2325 "os"
2326 "path/filepath"
2327 "runtime"
2328
2329 "github.com/carlosframework/platform/internal/cliflag"
2330 "github.com/carlosframework/platform/internal/hostfloor"
2331 "github.com/carlosframework/platform/internal/release"
2332 "github.com/carlosframework/platform/internal/version"
2333)
2334
2335// carlos system install-channel (region HA spec 2 §5 step 3, A1-6, A2-2).
2336//
2337// A group box boots from a bootstrap object, which is whatever build the
2338// deployment last uploaded there, not its neighbours' release. This puts
2339// the release the box's signed channel pointer names in place instead,
2340// through the same path `carlos system update` takes: the pointer verified
2341// with CARLOS_RELEASE_PUBKEY (verifyPointer), the artifact fetched against
2342// the signed sha256 into the agent's cache, then copied into root's
2343// staging, checked again and asked its version (stageCandidate).
2344//
2345// Both protected slots, carlos and carlos.prev, become that artifact
2346// (A2-2): guard-arm checks both against the release identity and refuses
2347// a box with any other build in either. The release identity is kept
2348// beside them (channelIdentityPath) for the join step's guard-arm, and the
2349// release must pass the published host floor (hostfloor.CheckImage, the
2350// check guard-arm makes) before anything is fetched.
2351//
2352// It is for a fresh box. A box whose guard is armed, or that already runs
2353// another release this way or through an update, is refused: its releases
2354// come through `carlos system update`. Run again on a box it has already
2355// installed, it changes nothing.
2356
2357const installChannelUsage = "usage: carlos system install-channel\n\n" +
2358 "Install the release this box's channel names, as root, on a box that has none yet.\n"
2359
2360const installChannelNotRoot = "carlos system install-channel runs as root: it installs the platform binary"
2361
2362// channelIdentityPath is where the installed release's signed identity is
2363// kept for guard-arm (§5 step 5.3).
2364func channelIdentityPath(optDir string) string {
2365 return filepath.Join(systemRootDir(optDir), "channel-identity.json")
2366}
2367
2368// channelInstall is one run. fetchTo is release.FetchToFile over the
2369// pointer's store, and own hands a directory tree to the service user
2370// (ownByService); both are seams for tests.
2371type channelInstall struct {
2372 a *systemApplier
2373 bucket string
2374 fetchTo func(ctx context.Context, s release.Store, key, dst, sha string) error
2375 own func(path string) error
2376}
2377
2378// channelInstalled is what a run did.
2379type channelInstalled struct {
2380 Version, Tag, Channel string
2381 Already bool
2382}
2383
2384// RunSystemInstallChannel is `carlos system install-channel`.
2385func RunSystemInstallChannel(args []string) {
2386 fs := flag.NewFlagSet("system install-channel", flag.ExitOnError)
2387 fs.Usage = cliflag.Usage(fs, installChannelUsage)
2388 _ = fs.Parse(args)
2389 if fs.NArg() != 0 {
2390 fs.Usage()
2391 os.Exit(2)
2392 }
2393 if os.Geteuid() != 0 {
2394 fmt.Fprintln(os.Stderr, installChannelNotRoot)
2395 os.Exit(1)
2396 }
2397 channel, invalid := systemChannel(os.Getenv)
2398 if invalid != "" {
2399 fmt.Fprintln(os.Stderr, fmt.Sprintf("%s=%q is not a channel, so nothing was installed", systemChannelEnv, invalid))
2400 os.Exit(1)
2401 }
2402 in := channelInstall{
2403 a: &systemApplier{ops: productionSysOps(), openStore: openSystemStore, channel: channel, optDir: release.OptDir(),
2404 dataDir: dataDir(), etcDir: "/etc", build: version.Build, platform: runtime.GOOS + "-" + runtime.GOARCH},
2405 bucket: os.Getenv("CARLOS_DEPLOYMENT_BUCKET"),
2406 fetchTo: func(ctx context.Context, s release.Store, key, dst, sha string) error {
2407 return release.FetchToFile(ctx, s, key, dst, systemArtifactLimit, sha)
2408 },
2409 own: ownByService,
2410 }
2411 got, err := in.run(context.Background())
2412 if err != nil {
2413 fmt.Fprintln(os.Stderr, err)
2414 os.Exit(1)
2415 }
2416 fmt.Println(installedLine(got))
2417}
2418
2419func installedLine(got channelInstalled) string {
2420 if got.Already {
2421 return fmt.Sprintf("%s (%s) from the %s channel is already installed", got.Version, got.Tag, got.Channel)
2422 }
2423 return fmt.Sprintf("installed %s (%s) from the %s channel as carlos and carlos.prev", got.Version, got.Tag, got.Channel)
2424}
2425
2426// run takes the apply lock, so no update runs beside it, and installs.
2427func (in channelInstall) run(ctx context.Context) (channelInstalled, error) {
2428 a := in.a
2429 if err := os.MkdirAll(a.rootDir(), 0o755); err != nil {
2430 return channelInstalled{}, err
2431 }
2432 lock, err := os.OpenFile(a.lockPath(), os.O_CREATE|os.O_RDWR, 0o600)
2433 if err != nil {
2434 return channelInstalled{}, err
2435 }
2436 defer lock.Close()
2437 if err := flockExclusiveNonblock(lock); err != nil {
2438 return channelInstalled{}, fmt.Errorf("an update is running on this box, so nothing was installed: %w", err)
2439 }
2440 return in.install(ctx)
2441}
2442
2443func (in channelInstall) install(ctx context.Context) (channelInstalled, error) {
2444 a := in.a
2445 var pub ed25519.PublicKey
2446 open := a.openStore
2447 a.openStore = func(ctx context.Context) (release.Store, ed25519.PublicKey, error) {
2448 s, k, err := open(ctx)
2449 pub = k
2450 return s, k, err
2451 }
2452 p, err := a.verifyPointer(ctx)
2453 if err != nil {
2454 return channelInstalled{}, fmt.Errorf("the %s channel could not be read, so nothing was installed: %w", a.channel, err)
2455 }
2456 got := channelInstalled{Version: p.Current.Version, Tag: p.Current.ReleaseTag, Channel: a.channel}
2457 art, ok := p.Current.Artifacts[a.platform]
2458 if !ok {
2459 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)
2460 }
2461 // A box whose guard is armed takes releases only through an update.
2462 guard := hostfloor.Disk{Dir: systemGuardDir(a.optDir), Bucket: in.bucket, PublicKey: pub}
2463 if policy, _, err := guard.Load(); err == nil && policy.Mode != "unpublished" {
2464 return got, errors.New("this box's host lifecycle guard is armed, so carlos system update installs its releases")
2465 } else if err != nil && !errors.Is(err, os.ErrNotExist) {
2466 return got, fmt.Errorf("this box's host lifecycle guard could not be read, so nothing was installed: %w", err)
2467 }
2468 applied, err := readSystemApplied(a.optDir)
2469 if err != nil {
2470 return got, fmt.Errorf("this box's install record could not be read, so nothing was installed: %w", err)
2471 }
2472 if applied != nil && applied.Version != p.Current.Version {
2473 return got, fmt.Errorf("this box already runs %s, so carlos system update installs its releases", applied.Version)
2474 }
2475 wire, err := in.checkFloor(ctx, p, art, pub)
2476 if err != nil {
2477 return got, err
2478 }
2479 if applied != nil && systemCacheHolds(systemBinaryPath(a.optDir), art.SHA256) && systemCacheHolds(systemPrevPath(a.optDir), art.SHA256) &&
2480 systemCacheHolds(channelIdentityPath(a.optDir), hostfloor.Digest(wire)) {
2481 got.Already = true
2482 return got, nil
2483 }
2484 cache := systemCachePath(a.dataDir, p.Current.Version)
2485 defer os.RemoveAll(filepath.Dir(cache))
2486 defer os.RemoveAll(a.stagingDir())
2487 if err := in.cacheDir(p.Current.Version); err != nil {
2488 return got, err
2489 }
2490 if err := in.fetchTo(ctx, a.store, art.Key, cache, art.SHA256); err != nil {
2491 return got, fmt.Errorf("%s could not be fetched, so nothing was installed: %w", p.Current.Version, err)
2492 }
2493 staged, err := a.stageCandidate(ctx, p, systemRequest{Version: p.Current.Version, PromotedAt: p.PromotedAt})
2494 if err != nil {
2495 return got, fmt.Errorf("%s did not verify, so nothing was installed: %w", p.Current.Version, err)
2496 }
2497 // carlos.prev first: a run cut off between the two copies leaves the
2498 // new build in .prev and none in the current slot, which the next run
2499 // replaces, never the other way round.
2500 for _, slot := range []string{systemPrevPath(a.optDir), systemBinaryPath(a.optDir)} {
2501 if err := copyFileDurable(staged, slot, 0o755); err != nil {
2502 return got, err
2503 }
2504 if !systemCacheHolds(slot, art.SHA256) {
2505 return got, fmt.Errorf("%s does not hold %s after the copy", slot, p.Current.Version)
2506 }
2507 }
2508 if err := hostfloor.Durable(channelIdentityPath(a.optDir), wire, 0o644); err != nil {
2509 return got, err
2510 }
2511 seen, err := readSystemSeen(a.optDir)
2512 if err != nil {
2513 return got, err
2514 }
2515 seen[a.channel] = p.PromotedAt
2516 if err := writeJSONDurable(a.file("seen.json"), seen, 0o644); err != nil {
2517 return got, err
2518 }
2519 if err := writeJSONDurable(a.file("applied.json"), systemApplied{Version: p.Current.Version, Channel: a.channel, At: a.ops.now().UTC()}, 0o644); err != nil {
2520 return got, err
2521 }
2522 return got, nil
2523}
2524
2525// checkFloor reads the published host floor and the release's signed
2526// identity, and returns the identity once the release passes the floor
2527// exactly as guard-arm will check it. No floor, or one that cannot be read,
2528// refuses: a joining box could not arm its guard without one, and a read
2529// that failed says nothing about where the floor is.
2530func (in channelInstall) checkFloor(ctx context.Context, p *release.SystemPointer, art release.SystemArtifact, pub ed25519.PublicKey) ([]byte, error) {
2531 s := in.a.store
2532 raw, _, err := s.Get(ctx, hostfloor.Key)
2533 if errors.Is(err, os.ErrNotExist) {
2534 return nil, errors.New("no host floor is published, so this box could not arm its guard and nothing was installed")
2535 }
2536 if err != nil {
2537 return nil, fmt.Errorf("the host floor could not be read, so nothing was installed: %w", err)
2538 }
2539 auth, _, err := s.Get(ctx, hostfloor.AuthKey)
2540 if err != nil {
2541 return nil, fmt.Errorf("the host floor could not be read, so nothing was installed: %w", err)
2542 }
2543 floor, err := hostfloor.Verify(pub, in.bucket, raw, auth)
2544 if err != nil {
2545 return nil, fmt.Errorf("the host floor does not verify, so nothing was installed: %w", err)
2546 }
2547 key, err := release.IdentityKey(p.Current.ReleaseTag)
2548 if err != nil {
2549 return nil, fmt.Errorf("the %s channel names %s with no release tag, so nothing was installed", in.a.channel, p.Current.Version)
2550 }
2551 wire, _, err := s.Get(ctx, key)
2552 if err != nil {
2553 return nil, fmt.Errorf("the release identity for %s could not be read, so nothing was installed: %w", p.Current.ReleaseTag, err)
2554 }
2555 if err := hostfloor.CheckImage(pub, floor, wire, p.Current.Version, in.a.platform, art.SHA256); err != nil {
2556 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)
2557 }
2558 return wire, nil
2559}
2560
2561// cacheDir makes the agent's cache entry for version and reconciles the
2562// ownership of every directory on its path below the data dir, on every
2563// run: system, system/router and system/router/<version> are created as
2564// bootstrap creates directories (bootstrapMakeDir: each component opened
2565// with no symlink followed, so a symlinked parent is refused) and each is
2566// handed to the service user, as bootstrap's chown of /data does
2567// (privilegedPlan), whether this run created it or an earlier run that
2568// failed before its handoff did. Nothing else is chowned. The agent
2569// (User=carlos) later downloads releases beside the entry and writes its
2570// update requests there. Only the entry itself is removed after the
2571// install; its parents stay, owned by the agent.
2572func (in channelInstall) cacheDir(version string) error {
2573 parts := systemCacheParts(version)
2574 dir := in.a.dataDir
2575 for _, part := range parts[:len(parts)-1] {
2576 dir = filepath.Join(dir, part)
2577 if err := bootstrapMakeDir(dir, 0o755); err != nil {
2578 return err
2579 }
2580 if err := in.own(dir); err != nil {
2581 return err
2582 }
2583 }
2584 return nil
2585}
2586````
2587
2588`internal/host/installchannel_linux.go`:
2589
2590````go
2591//go:build linux
2592
2593package host
2594
2595import "golang.org/x/sys/unix"
2596
2597// ownByService hands one directory, and nothing beneath it, to the service
2598// user and its group, as bootstrap's ownership step does
2599// (bootstrapOwner). It opens the directory as bootstrap opens one
2600// (bootstrapOpenDir: no symlink anywhere on the path, not a symlink
2601// itself) and changes the open descriptor, so a path swapped for a
2602// symlink is refused, never followed.
2603func ownByService(path string) error {
2604 uid, gid, err := bootstrapOwner(serviceUser, serviceUser)
2605 if err != nil {
2606 return err
2607 }
2608 return ownDir(path, uid, gid)
2609}
2610
2611// ownDir is ownByService for a given owner.
2612func ownDir(path string, uid, gid int) error {
2613 fd, err := bootstrapOpenDir(path)
2614 if err != nil {
2615 return err
2616 }
2617 defer unix.Close(fd)
2618 return unix.Fchown(fd, uid, gid)
2619}
2620````
2621
2622`internal/host/installchannel_other.go`:
2623
2624````go
2625//go:build !linux
2626
2627package host
2628
2629import "errors"
2630
2631// errOwnNotLinux is every ownership handoff off Linux: carlos system
2632// install-channel runs only on a Linux box.
2633var errOwnNotLinux = errors.New("install-channel's ownership handoff requires Linux")
2634
2635func ownByService(path string) error { return errOwnNotLinux }
2636
2637func ownDir(path string, uid, gid int) error { return errOwnNotLinux }
2638````
2639
2640- [ ] **Step 4: Run the tests**
2641
2642Run: `nice -n 19 go test ./internal/host/ -run TestInstallChannel -count=1`
2643Expected: PASS (on linux: staging opens the cache with `openat`, which only the linux build has).
2644
2645- [ ] **Step 5: The provider boundary**
2646
2647`region_ha_boundary_test.go`:
2648
2649````diff
2650--- a/region_ha_boundary_test.go
2651+++ b/region_ha_boundary_test.go
2652@@ -34,6 +34,7 @@
2653 "internal/hostenv/cli.go",
2654 "internal/hostenv/write_linux.go",
2655 "internal/hostenv/write_other.go",
2656+ "internal/host/installchannel.go",
2657 }
2658
2659 // regionHAAdapterFiles are spec 2's AWS adapters. Each is named for AWS so
2660````
2661
2662Run: `nice -n 19 go test . -run 'TestRegionHA(Core|Adapters|S3)' -count=1`
2663Expected: PASS.
2664
2665- [ ] **Step 6: Mark the rows built**
2666
2667Add RHB-C19 to RHB-C39 to `regionHA2bCopyBuilt`, in the same commit as their text:
2668
2669`approved_region_ha_2b_copy_test.go`:
2670
2671````diff
2672--- a/approved_region_ha_2b_copy_test.go
2673+++ b/approved_region_ha_2b_copy_test.go
2674@@ -38,7 +38,7 @@
2675 // regionHA2bCopyBuilt is every id whose text the code or docs carry. The
2676 // task that writes a row's text adds its ids here in the same commit, once
2677 // Paul has approved the rows.
2678-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"}
2679+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"}
2680
2681 func mustParseRegionHA2bRecord(t *testing.T) []copyRow {
2682 t.Helper()
2683````
2684
2685Run: `nice -n 19 go test . -run 'RegionHA2b' -count=1`
2686Expected: PASS once the rows are approved. While any built row is pending, `TestRegionHA2bBuiltCopyIsApproved` fails naming each one, and nothing else fails.
2687
2688- [ ] **Step 7: Census**
2689
2690`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.
2691
2692`internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv`:
2693
2694````diff
2695--- a/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv
2696+++ b/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv
2697@@ -1,7 +1,10 @@
2698 # 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.
2699 # 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.
2700+# 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.
2701 @coverage . be7c96bb2ff00b5b018b436f976f0d33bbcc8765c17e357baa6cd0290f59f42d
2702 @coverage internal/hostenv a3f3274a86cdf8194afb871301c1ba3289dfbdc9b703dd547178273df151cdaa
2703 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
2704 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
2705 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
2706+@coverage internal/host 64ee4f561f2a28d584a6f7c82e068e57db4822f6cbb130116284bca583d2d390
2707+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
2708````
2709
2710Run: `nice -n 19 go test ./internal/provision/ -run 'TestPlan9B8RecordWriteCensus|TestPlan14RootReadCensus$' -count=1 && nice -n 19 go test ./internal/console/ -run TestClaimAuthoritySourceCensus -count=1`
2711Expected: PASS.
2712
2713- [ ] **Step 8: Commit**
2714
2715```bash
2716git 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
2717git commit -m "region HA 2b: carlos system install-channel"
2718```
2719
2720---
2721
2722### Task 4: Group membership, read by the console
2723
2724**Files:**
2725- Create: `internal/hostactors/membershipread.go`, `internal/hostactors/groupref.go`, `internal/hostactors/membershipread_test.go`, `internal/hostactors/export_test.go`, `internal/hostactors/boundsdk_test.go`
2726- 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)
2727- Modify: `internal/hostactors/attempt_regression_test.go`, `internal/hostactors/roles_test.go` (main's tests, onto a fake reader)
2728- Create: `internal/hostactors/awsmembership/awsmembership.go`, `awsmembership_test.go`
2729- Modify: `go.mod`, `go.sum` (`github.com/aws/aws-sdk-go-v2/service/autoscaling v1.74.0`)
2730- Modify: `region_ha_boundary_test.go`, `internal/provision/plan9_b8_census_test.go` (`deadlineStore.Put`), the overlay
2731
2732**Interfaces:**
2733- Consumes: `validateScope`, `segment`, `token`, `EnrollmentAttempt`, `Directory.enroll`, `Replace` and `checkMembership` (all main's, unchanged, run on the bound store), `release.Store`.
2734- 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.
2735- Removes (`hostactors`): `ConfirmationKey`, `MembershipConfirmation`, `TerminatedEnrollment`, `freshConfirmation`. Nothing on main but `EnrollAutomatic` and its two tests used them.
2736- Produces (`hostactors/awsmembership`): `New(ctx) (*Reader, error)` over the console's credentials; `(*Reader).Member` (`DescribeAutoScalingInstances`, then `DescribeInstances` by the `instance-id` filter).
2737
2738H28: 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.
2739
2740Every 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.
2741
2742The 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.
2743
2744**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:
2745- 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;
2746- 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;
2747- 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;
2748- an unknown write keeps the attempt and its wait, and the next pass reads the roster as every pass does (Task 5, U1);
2749- 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.
2750
2751The 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.
2752
2753**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).
2754
2755- [ ] **Step 1: Write the failing tests**
2756
2757`internal/hostactors/membershipread_test.go`:
2758
2759````go
2760package hostactors
2761
2762import (
2763 "context"
2764 "encoding/json"
2765 "errors"
2766 "fmt"
2767 "reflect"
2768 "strings"
2769 "testing"
2770 "time"
2771
2772 "github.com/carlosframework/platform/internal/boxid"
2773 "github.com/carlosframework/platform/internal/boxid/boxidtest"
2774 "github.com/carlosframework/platform/internal/release"
2775 "github.com/carlosframework/platform/internal/status"
2776)
2777
2778// fakeMembership is the cloud, as the console's reader sees it: each
2779// instance's membership. err fails every call; during runs inside each
2780// call (a slow describe, say).
2781type fakeMembership struct {
2782 members map[string]Membership
2783 err error
2784 during func()
2785 calls []string
2786}
2787
2788func (f *fakeMembership) Member(_ context.Context, awsRegion, instance string) (Membership, error) {
2789 f.calls = append(f.calls, awsRegion+" "+instance)
2790 if f.during != nil {
2791 f.during()
2792 }
2793 if f.err != nil {
2794 return Membership{}, f.err
2795 }
2796 return f.members[instance], nil
2797}
2798
2799func member(group string) Membership {
2800 return Membership{GroupName: group, AvailabilityZoneRegion: "eu-west-1", LifecycleState: "InService", InstanceState: "running", Owner: "123", RequestIDs: []string{"asg-req", "ec2-req"}}
2801}
2802
2803// memberWorld is fixture()'s roster with ie's group "routers-ie", and a
2804// box (instance i-new, reference newRef) the group launched, invited, with an open
2805// attempt.
2806type memberWorld struct {
2807 d Directory
2808 sg release.Signer
2809 o Observed
2810 v boxid.Verifier
2811 raw []byte
2812 in EnrollmentRequest
2813 cloud *fakeMembership
2814 clock time.Time
2815}
2816
2817func newMemberWorld(t *testing.T, m Membership) *memberWorld {
2818 t.Helper()
2819 ctx := context.Background()
2820 d, sg, o := fixture(t)
2821 aws := boxidtest.NewSigner(t)
2822 _, raw := aws.Record(t, newRef, "123", "eu-west-1", "i-new")
2823 next := Clone(o.Roster)
2824 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"}}}
2825 o, err := d.Replace(ctx, sg, o, next)
2826 if err != nil {
2827 t.Fatal(err)
2828 }
2829 w := &memberWorld{d: d, sg: sg, v: boxid.Verifier{Account: "123", Certs: aws.Certs()}, raw: raw, clock: time.Now().UTC(),
2830 cloud: &fakeMembership{members: map[string]Membership{"i-new": m}},
2831 in: EnrollmentRequest{Reference: newRef, Namespace: "platform", Label: newRef, Region: "ie", Pool: "default", AWSRegion: "eu-west-1"}}
2832 if _, err := d.Invite(ctx, sg, newRef, raw); err != nil {
2833 t.Fatal(err)
2834 }
2835 o, a, err := d.BeginEnrollmentAttempt(ctx, sg, o, w.in, raw, "i-new")
2836 if err != nil {
2837 t.Fatal(err)
2838 }
2839 w.o, w.in.AttemptID, w.in.ConfirmationID = o, a.AttemptID, a.ConfirmationID
2840 return w
2841}
2842
2843func (w *memberWorld) now() time.Time { return w.clock }
2844
2845func (w *memberWorld) enroll() (Observed, MembershipEvidence, error) {
2846 return w.d.EnrollAutomatic(context.Background(), w.sg, w.o, w.v, w.in, w.raw, "i-new", w.cloud, w.now)
2847}
2848
2849// The console reads membership itself, once, in the attempt's AWS region,
2850// and enrolls on it; the read's request ids come back for its log.
2851func TestEnrollAutomaticReadsMembershipItself(t *testing.T) {
2852 w := newMemberWorld(t, member("routers-ie"))
2853 next, ev, err := w.enroll()
2854 if err != nil {
2855 t.Fatal(err)
2856 }
2857 if b := next.Roster.Boxes[newRef]; b.State != "active" || b.Group != "routers-ie" || b.Cloud.InstanceID != "i-new" {
2858 t.Fatalf("roster entry %+v", b)
2859 }
2860 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) {
2861 t.Fatalf("calls %v, evidence %+v", w.cloud.calls, ev)
2862 }
2863 // A replay of the consumed attempt is refused before the cloud is
2864 // asked again.
2865 w.o = next
2866 if _, _, err := w.enroll(); !errors.Is(err, ErrRoster) || len(w.cloud.calls) != 1 {
2867 t.Fatalf("replay: %v, calls %v", err, w.cloud.calls)
2868 }
2869 if _, _, err := w.d.checkConfirmed(context.Background(), next, w.v, w.in, w.raw, "i-new", w.cloud, w.now); !errors.Is(err, ErrRoster) {
2870 t.Fatalf("replayed check: %v", err)
2871 }
2872}
2873
2874// Every positive answer that the instance is not a current member of the
2875// region's group refuses, and writes nothing.
2876func TestEnrollAutomaticRefusesANonMember(t *testing.T) {
2877 for name, m := range map[string]Membership{
2878 "another group": member("routers-uk"),
2879 "no group": {Owner: "123", InstanceState: "running", AvailabilityZoneRegion: "eu-west-1", RequestIDs: []string{"a", "b"}},
2880 "leaving the group": func() Membership { m := member("routers-ie"); m.LifecycleState = "Terminating"; return m }(),
2881 "stopped": func() Membership { m := member("routers-ie"); m.InstanceState = "stopped"; return m }(),
2882 "terminated": func() Membership { m := member("routers-ie"); m.InstanceState = "terminated"; return m }(),
2883 "another account": func() Membership { m := member("routers-ie"); m.Owner = "999"; return m }(),
2884 "another zone": func() Membership { m := member("routers-ie"); m.AvailabilityZoneRegion = "eu-west-2"; return m }(),
2885 } {
2886 t.Run(name, func(t *testing.T) {
2887 w := newMemberWorld(t, m)
2888 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) {
2889 t.Fatalf("check: %v", err)
2890 }
2891 if _, _, err := w.enroll(); !errors.Is(err, ErrNotMember) {
2892 t.Fatalf("enroll: %v", err)
2893 }
2894 o, err := w.d.Read(context.Background())
2895 if err != nil || o.Roster.Revision != w.o.Roster.Revision {
2896 t.Fatalf("a refused enrollment wrote the roster: %v", err)
2897 }
2898 })
2899 }
2900}
2901
2902// A read that fails, or is not an answer, is unknown: never a member and
2903// never a non-member.
2904func TestAMembershipReadThatIsNotAnAnswerIsUnknown(t *testing.T) {
2905 for name, edit := range map[string]func(*fakeMembership){
2906 "the call fails": func(f *fakeMembership) { f.err = errors.New("Throttling") },
2907 "EC2 does not list it": func(f *fakeMembership) {
2908 m := f.members["i-new"]
2909 m.InstanceState, m.Owner = "", ""
2910 f.members["i-new"] = m
2911 },
2912 "one request id": func(f *fakeMembership) { m := f.members["i-new"]; m.RequestIDs = []string{"a"}; f.members["i-new"] = m },
2913 "a blank request id": func(f *fakeMembership) {
2914 m := f.members["i-new"]
2915 m.RequestIDs = []string{"a", ""}
2916 f.members["i-new"] = m
2917 },
2918 } {
2919 t.Run(name, func(t *testing.T) {
2920 w := newMemberWorld(t, member("routers-ie"))
2921 edit(w.cloud)
2922 _, _, err := w.enroll()
2923 if !errors.Is(err, ErrMembershipUnknown) || errors.Is(err, ErrNotMember) {
2924 t.Fatalf("%v", err)
2925 }
2926 })
2927 }
2928}
2929
2930// C2: the write deadline is 60 seconds from the read's anchor, taken
2931// before the describe and so never later than its completion, and a read
2932// sixty seconds old or more is refused. A describe that took exactly a
2933// minute leaves the roster as it was.
2934func TestEnrollAutomaticRefusesAReadOlderThanAMinuteAtTheWrite(t *testing.T) {
2935 w := newMemberWorld(t, member("routers-ie"))
2936 w.cloud.during = func() { w.clock = w.clock.Add(60 * time.Second) }
2937 if _, _, err := w.enroll(); !errors.Is(err, ErrMembershipStale) {
2938 t.Fatalf("slow read: %v", err)
2939 }
2940 o, err := w.d.Read(context.Background())
2941 if err != nil || o.Roster.Revision != w.o.Roster.Revision || o.Roster.EnrollmentAttempts[newRef].Consumed {
2942 t.Fatalf("a stale read wrote the roster: %v", err)
2943 }
2944 // A read that was fresh when it was checked but is over a minute old
2945 // by the write: here every look at the clock takes 31 seconds, so the
2946 // read is 31 seconds old when checked and 62 when the roster write is
2947 // about to be made. The write bound refuses it there, as unknown.
2948 u := newMemberWorld(t, member("routers-ie"))
2949 ticking := func() time.Time { u.clock = u.clock.Add(31 * time.Second); return u.clock }
2950 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) {
2951 t.Fatalf("a read gone stale before the write: %v", err)
2952 }
2953 if o, err := u.d.Read(context.Background()); err != nil || o.Roster.Revision != u.o.Roster.Revision {
2954 t.Fatalf("a read gone stale before the write wrote the roster: %v", err)
2955 }
2956 // Fifty-nine seconds is inside the minute.
2957 v := newMemberWorld(t, member("routers-ie"))
2958 v.cloud.during = func() { v.clock = v.clock.Add(59 * time.Second) }
2959 if _, _, err := v.enroll(); err != nil {
2960 t.Fatalf("a read inside the minute: %v", err)
2961 }
2962}
2963
2964// No exported identifier of this package carries a membership
2965// confirmation: the only one is built inside it, from a reader's answer.
2966func TestNoExportedPathTakesAConfirmation(t *testing.T) {
2967 // Every exported method of Directory, EnrollAutomatic among them: the
2968 // admission check itself (checkConfirmed) is not exported.
2969 if _, ok := reflect.TypeOf(Directory{}).MethodByName("EnrollAutomatic"); !ok {
2970 t.Fatal("no EnrollAutomatic")
2971 }
2972 if _, ok := reflect.TypeOf(Directory{}).MethodByName("CheckConfirmed"); ok {
2973 t.Fatal("the admission check is exported again")
2974 }
2975 for j := 0; j < reflect.TypeOf(Directory{}).NumMethod(); j++ {
2976 m := reflect.TypeOf(Directory{}).Method(j)
2977 name := m.Name
2978 for i := 0; i < m.Type.NumIn(); i++ {
2979 in := m.Type.In(i)
2980 if strings.Contains(strings.ToLower(in.Name()), "confirmation") || in == reflect.TypeOf(MembershipEvidence{}) || in == reflect.TypeOf(Membership{}) {
2981 t.Fatalf("%s takes a %s", name, in)
2982 }
2983 }
2984 }
2985}
2986
2987// newRef is the joining box's reference: the group reference of
2988// instance i-new in account 123.
2989var newRef = GroupBoxReference("123", "i-new")
2990
2991// The ruling's first test: two group boxes never share a reference. The
2992// reference is a pseudonym of account and instance, stable, and outside
2993// the label space.
2994func TestGroupBoxReferencesDoNotCollide(t *testing.T) {
2995 if GroupBoxReference("123", "i-0a61f580df1acfa65") != GroupBoxReference("123", "i-0a61f580df1acfa65") {
2996 t.Fatal("not stable")
2997 }
2998 if GroupBoxReference("123", "i-a") == GroupBoxReference("124", "i-a") {
2999 t.Fatal("the account is not in the reference")
3000 }
3001 seen := map[string]string{}
3002 for i := 0; i < 200000; i++ {
3003 inst := fmt.Sprintf("i-%017x", i)
3004 ref := GroupBoxReference("123456789012", inst)
3005 if len(ref) != 22 || !strings.HasPrefix(ref, "g-") || !segment(ref) {
3006 t.Fatalf("%s: %q", inst, ref)
3007 }
3008 if other, ok := seen[ref]; ok {
3009 t.Fatalf("%s and %s share %s", inst, other, ref)
3010 }
3011 seen[ref] = inst
3012 }
3013 if strings.Contains(GroupBoxReference("123", "i-0a61f580df1acfa65"), "0a61f580") {
3014 t.Fatal("the reference shows the instance id")
3015 }
3016}
3017
3018// The console reads a group box's report under the reference it derives
3019// from the roster's pinned instance, never the label in the entry; every
3020// other box under its reference, exactly as before.
3021func TestPrivateBoxReportDerivesAGroupBoxsKey(t *testing.T) {
3022 d, _, _ := fixture(t)
3023 ctx := context.Background()
3024 put := func(key, instance string) {
3025 raw, _ := json.Marshal(status.Box{GeneratedAt: time.Now(), Metrics: &status.BoxMetrics{InstanceID: instance}})
3026 if _, err := d.Store.Put(ctx, key, raw, ""); err != nil {
3027 t.Fatal(err)
3028 }
3029 }
3030 derived := GroupBoxReference("123", "i-g")
3031 put("status/boxes/"+derived+".json", "i-g")
3032 put("status/boxes/chosen.json", "i-chosen")
3033 put("status/boxes/annie-welding.json", "i-old")
3034 r, err := d.PrivateBoxReport(ctx, "chosen", Box{Label: "chosen", Group: "routers-ie", Cloud: Cloud{Account: "123", InstanceID: "i-g"}})
3035 if err != nil || r.InstanceID != "i-g" {
3036 t.Fatalf("group box: %+v %v", r, err)
3037 }
3038 r, err = d.PrivateBoxReport(ctx, "annie-welding", Box{Label: "annie-welding", Cloud: Cloud{Account: "123", InstanceID: "i-old"}})
3039 if err != nil || r.InstanceID != "i-old" {
3040 t.Fatalf("a box an operator enrolled: %+v %v", r, err)
3041 }
3042 if _, err := d.PrivateBoxReport(ctx, derived, Box{Group: "routers-ie"}); !errors.Is(err, ErrRoster) {
3043 t.Fatalf("a group box with no pinned instance: %v", err)
3044 }
3045}
3046
3047// A group box is admitted only under the reference its instance derives:
3048// an attempt under a reference the box chose is refused before the cloud
3049// is asked, even for a member.
3050func TestEnrollAutomaticRefusesAReferenceTheBoxChose(t *testing.T) {
3051 w := newMemberWorld(t, member("routers-ie"))
3052 in := w.in
3053 in.Reference, in.Label = "chosen", "chosen"
3054 o, a, err := w.d.BeginEnrollmentAttempt(context.Background(), w.sg, w.o, in, w.raw, "i-new")
3055 if err != nil {
3056 t.Fatal(err)
3057 }
3058 in.AttemptID, in.ConfirmationID = a.AttemptID, a.ConfirmationID
3059 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 {
3060 t.Fatalf("a chosen reference: %v, calls %v", err, w.cloud.calls)
3061 }
3062}
3063````
3064
3065The bound through the console's production stores, down to the SDK request (V2):
3066
3067`internal/hostactors/export_test.go`:
3068
3069````go
3070package hostactors
3071
3072import (
3073 "context"
3074 "time"
3075)
3076
3077// BindForTest is writeBound for a membership read anchored at observedAt,
3078// for the external test that drives the console's production store.
3079func BindForTest(ctx context.Context, d Directory, observedAt time.Time, now func() time.Time) (Directory, context.Context, context.CancelFunc) {
3080 return writeBound(ctx, d, membershipConfirmation{observedAt: observedAt}, now)
3081}
3082````
3083
3084`internal/hostactors/boundsdk_test.go`:
3085
3086````go
3087package hostactors_test
3088
3089import (
3090 "bytes"
3091 "context"
3092 "crypto/ed25519"
3093 "errors"
3094 "fmt"
3095 "io"
3096 "net/http"
3097 "strings"
3098 "sync"
3099 "testing"
3100 "time"
3101
3102 awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
3103
3104 "github.com/carlosframework/platform/internal/hostactors"
3105 "github.com/carlosframework/platform/internal/hostops"
3106 "github.com/carlosframework/platform/internal/release"
3107)
3108
3109// sdkRequest is one HTTP request the AWS SDK sent: its method, its path,
3110// its If-Match condition, and how long its context had left when it was
3111// sent (zero: no deadline).
3112type sdkRequest struct {
3113 method, path, ifMatch string
3114 left time.Duration
3115}
3116
3117// recordingHTTP stands in for the network under the SDK: a bucket that
3118// honours S3's conditional headers. It records each request's context
3119// deadline. A GET answers the object and its ETag, or NoSuchKey; a PUT
3120// with If-Match succeeds only on the current ETag, one with
3121// If-None-Match: * only when the key is absent, and gets a new ETag.
3122type recordingHTTP struct {
3123 mu sync.Mutex
3124 seen []sdkRequest
3125 objects map[string]bucketObject
3126 next int
3127}
3128
3129type bucketObject struct {
3130 body []byte
3131 etag string
3132}
3133
3134func (h *recordingHTTP) Do(r *http.Request) (*http.Response, error) {
3135 var left time.Duration
3136 if dl, ok := r.Context().Deadline(); ok {
3137 left = time.Until(dl)
3138 }
3139 var body []byte
3140 if r.Body != nil {
3141 body, _ = io.ReadAll(r.Body)
3142 }
3143 h.mu.Lock()
3144 defer h.mu.Unlock()
3145 h.seen = append(h.seen, sdkRequest{r.Method, r.URL.Path, r.Header.Get("If-Match"), left})
3146 resp := &http.Response{Header: http.Header{}, Request: r, Proto: "HTTP/1.1", ProtoMajor: 1, ProtoMinor: 1, Body: io.NopCloser(strings.NewReader(""))}
3147 reply := func(code int, xml string) (*http.Response, error) {
3148 resp.StatusCode = code
3149 resp.Header.Set("Content-Type", "application/xml")
3150 resp.Body = io.NopCloser(strings.NewReader(`<?xml version="1.0" encoding="UTF-8"?><Error><Code>` + xml + `</Code><Message>fake</Message></Error>`))
3151 return resp, nil
3152 }
3153 key := r.URL.Path
3154 cur, exists := h.objects[key]
3155 switch r.Method {
3156 case http.MethodPut:
3157 if m := r.Header.Get("If-Match"); m != "" && (!exists || strings.Trim(m, `"`) != strings.Trim(cur.etag, `"`)) {
3158 return reply(http.StatusPreconditionFailed, "PreconditionFailed")
3159 }
3160 if r.Header.Get("If-None-Match") == "*" && exists {
3161 return reply(http.StatusPreconditionFailed, "PreconditionFailed")
3162 }
3163 h.next++
3164 obj := bucketObject{body, fmt.Sprintf(`"e%d"`, h.next)}
3165 h.objects[key] = obj
3166 resp.StatusCode = http.StatusOK
3167 resp.Header.Set("ETag", obj.etag)
3168 return resp, nil
3169 case http.MethodGet:
3170 if !exists {
3171 return reply(http.StatusNotFound, "NoSuchKey")
3172 }
3173 resp.StatusCode = http.StatusOK
3174 resp.Header.Set("ETag", cur.etag)
3175 resp.Header.Set("Content-Length", fmt.Sprint(len(cur.body)))
3176 resp.Body = io.NopCloser(bytes.NewReader(cur.body))
3177 resp.ContentLength = int64(len(cur.body))
3178 return resp, nil
3179 }
3180 return reply(http.StatusNotFound, "NoSuchKey")
3181}
3182
3183func (h *recordingHTTP) take() []sdkRequest {
3184 h.mu.Lock()
3185 defer h.mu.Unlock()
3186 out := h.seen
3187 h.seen = nil
3188 return out
3189}
3190
3191// productionStores builds the console's stores as claim_runtime.go does:
3192// the release S3 store (its own construction, s3compat and all, with only
3193// the network replaced), the conditional record store over it, and the
3194// observed mutation boundary with the production mutation check over
3195// that: the store the enrolment pass's Directory carries.
3196func productionStores(t *testing.T) (raw *release.S3Store, data release.Store, h *recordingHTTP, root release.Signer) {
3197 t.Helper()
3198 t.Setenv("AWS_ACCESS_KEY_ID", "k")
3199 t.Setenv("AWS_SECRET_ACCESS_KEY", "s")
3200 t.Setenv("AWS_SESSION_TOKEN", "")
3201 t.Setenv("AWS_REGION", "eu-west-1")
3202 t.Setenv("AWS_EC2_METADATA_DISABLED", "true")
3203 t.Setenv("AWS_CONFIG_FILE", "/nonexistent")
3204 t.Setenv("AWS_SHARED_CREDENTIALS_FILE", "/nonexistent")
3205 t.Setenv("CARLOS_S3_ENDPOINT", "http://127.0.0.1:9")
3206 raw, err := release.NewS3Store(context.Background(), "deployment")
3207 if err != nil {
3208 t.Fatal(err)
3209 }
3210 h = &recordingHTTP{objects: map[string]bucketObject{}}
3211 raw.Client = awss3.New(raw.Client.Options(), func(o *awss3.Options) { o.HTTPClient = h; o.RetryMaxAttempts = 1 })
3212 pub, priv, err := ed25519.GenerateKey(nil)
3213 if err != nil {
3214 t.Fatal(err)
3215 }
3216 control, err := hostops.NewControlWriter(raw, pub, time.Now)
3217 if err != nil {
3218 t.Fatal(err)
3219 }
3220 check, err := hostops.NewProductionMutationCheck(raw, control)
3221 if err != nil {
3222 t.Fatal(err)
3223 }
3224 records, err := hostops.NewConditionalRecordStore(raw, pub)
3225 if err != nil {
3226 t.Fatal(err)
3227 }
3228 data, err = hostops.NewObservedMutationBoundary(true, records, check, nil)
3229 if err != nil {
3230 t.Fatal(err)
3231 }
3232 return raw, data, h, release.RootSigner(priv)
3233}
3234
3235// V2 and W1 (platform-1e): the per-call context of a bound write reaches
3236// the AWS SDK's HTTP request through the console's production stores, so
3237// the 15-second limit is enforced where the request is sent. The roster
3238// write is a successful conditional PUT through the whole stack: the
3239// bound's deadline store, the observed mutation boundary (its pre-read,
3240// the production mutation check, which admits a root-signed roster, and
3241// its forwarding), the conditional record store's forwarding and the S3
3242// store. Every request it sends, the PUT included, carries a deadline of
3243// 15 seconds from its call, or the write deadline when that comes first.
3244// A wrapper anywhere on the path that drops the context, or a bound that
3245// does not give each call its own, fails here.
3246func TestTheBoundReachesTheSDKRequest(t *testing.T) {
3247 _, data, h, root := productionStores(t)
3248 for name, c := range map[string]struct {
3249 readAge time.Duration
3250 low, high time.Duration
3251 }{
3252 "the per-call limit": {0, 14 * time.Second, 15 * time.Second},
3253 "the earlier write deadline": {50 * time.Second, 9 * time.Second, 10 * time.Second},
3254 } {
3255 t.Run(name, func(t *testing.T) {
3256 check := func(what string, got []sdkRequest, methods ...string) []sdkRequest {
3257 t.Helper()
3258 var sent []string
3259 for _, r := range got {
3260 sent = append(sent, r.method)
3261 if r.left <= c.low || r.left > c.high {
3262 t.Errorf("%s: %s %s had %s left, want (%s, %s]", what, r.method, r.path, r.left, c.low, c.high)
3263 }
3264 }
3265 if strings.Join(sent, " ") != strings.Join(methods, " ") {
3266 t.Fatalf("%s: the SDK sent %v, want %v", what, sent, methods)
3267 }
3268 return got
3269 }
3270 // The bucket holds a roster the pass has read.
3271 h.mu.Lock()
3272 h.objects = map[string]bucketObject{}
3273 h.mu.Unlock()
3274 before, err := release.SignRootEnvelope(root, map[string]int{"revision": 1})
3275 if err != nil {
3276 t.Fatal(err)
3277 }
3278 d := hostactors.Directory{Store: data, Bucket: "deployment"}
3279 etag, err := data.Put(context.Background(), hostactors.Key, before, "")
3280 if err != nil {
3281 t.Fatal(err)
3282 }
3283 h.take()
3284 bd, bctx, cancel := hostactors.BindForTest(context.Background(), d, time.Now().Add(-c.readAge), time.Now)
3285 defer cancel()
3286 if _, got, err := bd.Store.Get(bctx, hostactors.Key); err != nil || got != etag {
3287 t.Fatalf("roster read: %q %v", got, err)
3288 }
3289 check("the roster read", h.take(), http.MethodGet)
3290 // The enrolling write: conditional on that ETag, and admitted.
3291 next, err := release.SignRootEnvelope(root, map[string]int{"revision": 2})
3292 if err != nil {
3293 t.Fatal(err)
3294 }
3295 after, err := bd.Store.Put(bctx, hostactors.Key, next, etag)
3296 if err != nil || after == etag {
3297 t.Fatalf("the conditional roster write: %q %v", after, err)
3298 }
3299 sent := check("the roster write", h.take(), http.MethodGet, http.MethodPut)
3300 if strings.Trim(sent[1].ifMatch, `"`) != strings.Trim(etag, `"`) {
3301 t.Fatalf("the roster PUT was sent with If-Match %q, want the ETag read, %q", sent[1].ifMatch, etag)
3302 }
3303 // And it stays conditional: the old ETag now loses.
3304 if _, err := bd.Store.Put(bctx, hostactors.Key, next, etag); !errors.Is(err, release.ErrCASConflict) {
3305 t.Fatalf("a stale ETag was not refused: %v", err)
3306 }
3307 h.take()
3308 })
3309 }
3310}
3311````
3312
3313Main's two tests that wrote a confirmation object read through a fake reader instead:
3314
3315`internal/hostactors/attempt_regression_test.go`:
3316
3317````diff
3318--- a/internal/hostactors/attempt_regression_test.go
3319+++ b/internal/hostactors/attempt_regression_test.go
3320@@ -10,12 +10,11 @@
3321 "encoding/base64"
3322 "encoding/json"
3323 "github.com/carlosframework/platform/internal/boxid"
3324- "github.com/carlosframework/platform/internal/release"
3325 "testing"
3326 "time"
3327 )
3328
3329-func TestEnrollmentRefreshRejectsExpiredSupersededAndConsumedEvidence(t *testing.T) {
3330+func TestEnrollmentRejectsSupersededAndConsumedAttempts(t *testing.T) {
3331 ctx := context.Background()
3332 d, sg, o := fixture(t)
3333 key, err := rsa.GenerateKey(rand.Reader, 2048)
3334@@ -28,10 +27,10 @@
3335 if err != nil {
3336 t.Fatal(err)
3337 }
3338- rec := boxid.Record{V: 1, Box: "new", IdentityDocument: base64.StdEncoding.EncodeToString(doc), IdentitySignature: base64.StdEncoding.EncodeToString(sig)}
3339+ rec := boxid.Record{V: 1, Box: newRef, IdentityDocument: base64.StdEncoding.EncodeToString(doc), IdentitySignature: base64.StdEncoding.EncodeToString(sig)}
3340 raw, _ := json.Marshal(rec)
3341 v := boxid.Verifier{Account: "123", Certs: map[string]*x509.Certificate{"eu-west-1": {PublicKey: &key.PublicKey, PublicKeyAlgorithm: x509.RSA}}}
3342- in := EnrollmentRequest{Reference: "new", Namespace: "platform", Label: "new", Region: "ie", Pool: "default", AWSRegion: "eu-west-1"}
3343+ in := EnrollmentRequest{Reference: newRef, Namespace: "platform", Label: newRef, Region: "ie", Pool: "default", AWSRegion: "eu-west-1"}
3344 next := Clone(o.Roster)
3345 next.Regions = map[string]Region{"ie": {Account: "123", AWSRegion: "eu-west-1", GroupName: "routers", GroupARN: "group-arn", Namespaces: []string{"platform"}, Pools: []string{"default"}}}
3346 o, err = d.Replace(ctx, sg, o, next)
3347@@ -45,27 +44,9 @@
3348 if err != nil {
3349 t.Fatal(err)
3350 }
3351- now := time.Now().UTC()
3352- confirmation := func(a EnrollmentAttempt, at time.Time) MembershipConfirmation {
3353- 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"}}
3354- c.Confirmer.Box = "box"
3355- c.Confirmer.Enrollment = o.Roster.Boxes["box"].Enrollment
3356- c.Confirmer.Region = "ie"
3357- return c
3358- }
3359- save := func(a EnrollmentAttempt, c MembershipConfirmation) {
3360- t.Helper()
3361- wire, _ := json.Marshal(c)
3362- if err := release.CreateExact(ctx, d.Store, ConfirmationKey(in.Reference, a.AttemptID, a.ConfirmationID), wire); err != nil {
3363- t.Fatal(err)
3364- }
3365- }
3366- save(first, confirmation(first, now.Add(-2*time.Minute)))
3367- in.AttemptID = first.AttemptID
3368- in.ConfirmationID = first.ConfirmationID
3369- if _, err = d.EnrollAutomatic(ctx, sg, o, v, in, raw, "i-new", now); err == nil {
3370- t.Fatal("expired evidence admitted")
3371- }
3372+ // The console's own read, which says the instance is a member.
3373+ cloud := &fakeMembership{members: map[string]Membership{"i-new": member("routers")}}
3374+ now := func() time.Time { return time.Now().UTC() }
3375 staleRoster := o
3376 o, second, err := d.BeginEnrollmentAttempt(ctx, sg, o, in, raw, "i-new")
3377 if err != nil {
3378@@ -74,30 +55,24 @@
3379 if first.AttemptID == second.AttemptID || first.ConfirmationID == second.ConfirmationID {
3380 t.Fatal("immutable identity reused")
3381 }
3382- save(second, confirmation(second, now))
3383- if _, err = d.EnrollAutomatic(ctx, sg, o, v, in, raw, "i-new", now); err == nil {
3384+ in.AttemptID, in.ConfirmationID = first.AttemptID, first.ConfirmationID
3385+ if _, _, err = d.EnrollAutomatic(ctx, sg, o, v, in, raw, "i-new", cloud, now); err == nil {
3386 t.Fatal("superseded request admitted")
3387 }
3388- if _, err = d.EnrollAutomatic(ctx, sg, o, v, in, raw, "i-new", now.Add(-2*time.Minute)); err == nil {
3389- t.Fatal("superseded IDs admitted while old evidence was still fresh")
3390- }
3391- // Even a still-fresh old confirmation cannot win the stale roster CAS.
3392- if _, err = d.EnrollAutomatic(ctx, sg, staleRoster, v, in, raw, "i-new", now.Add(-2*time.Minute)); err == nil {
3393+ // The superseded roster snapshot loses its CAS even with its own
3394+ // attempt's ids.
3395+ if _, _, err = d.EnrollAutomatic(ctx, sg, staleRoster, v, in, raw, "i-new", cloud, now); err == nil {
3396 t.Fatal("superseded snapshot admitted")
3397 }
3398- in.AttemptID = second.AttemptID
3399- in.ConfirmationID = second.ConfirmationID
3400- admitted, err := d.EnrollAutomatic(ctx, sg, o, v, in, raw, "i-new", now)
3401+ in.AttemptID, in.ConfirmationID = second.AttemptID, second.ConfirmationID
3402+ admitted, _, err := d.EnrollAutomatic(ctx, sg, o, v, in, raw, "i-new", cloud, now)
3403 if err != nil {
3404 t.Fatal(err)
3405 }
3406 if !admitted.Roster.EnrollmentAttempts[in.Reference].Consumed {
3407 t.Fatal("attempt not consumed with roster admission")
3408 }
3409- if _, err = d.EnrollAutomatic(ctx, sg, admitted, v, in, raw, "i-new", now); err == nil {
3410- t.Fatal("consumed confirmation replayed")
3411- }
3412- if _, _, err = d.Store.Get(ctx, ConfirmationKey(in.Reference, first.AttemptID, first.ConfirmationID)); err != nil {
3413- t.Fatal("old immutable evidence removed", err)
3414+ if _, _, err = d.EnrollAutomatic(ctx, sg, admitted, v, in, raw, "i-new", cloud, now); err == nil {
3415+ t.Fatal("consumed attempt replayed")
3416 }
3417 }
3418````
3419
3420`internal/hostactors/roles_test.go`:
3421
3422````diff
3423--- a/internal/hostactors/roles_test.go
3424+++ b/internal/hostactors/roles_test.go
3425@@ -189,9 +189,9 @@
3426 if err != nil {
3427 t.Fatal(err)
3428 }
3429- raw, _ := json.Marshal(boxid.Record{V: 1, Box: "new", IdentityDocument: base64.StdEncoding.EncodeToString(doc), IdentitySignature: base64.StdEncoding.EncodeToString(sig)})
3430+ raw, _ := json.Marshal(boxid.Record{V: 1, Box: newRef, IdentityDocument: base64.StdEncoding.EncodeToString(doc), IdentitySignature: base64.StdEncoding.EncodeToString(sig)})
3431 v := boxid.Verifier{Account: "123", Certs: map[string]*x509.Certificate{"eu-west-1": {PublicKey: &key.PublicKey, PublicKeyAlgorithm: x509.RSA}}}
3432- in := EnrollmentRequest{Reference: "new", Namespace: "platform", Label: "new", Region: "ie", Pool: "default", AWSRegion: "eu-west-1"}
3433+ in := EnrollmentRequest{Reference: newRef, Namespace: "platform", Label: newRef, Region: "ie", Pool: "default", AWSRegion: "eu-west-1"}
3434 next := Clone(o.Roster)
3435 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}}
3436 if o, err = d.Replace(ctx, sg, o, next); err != nil {
3437@@ -204,19 +204,14 @@
3438 if err != nil {
3439 t.Fatal(err)
3440 }
3441- now := time.Now().UTC()
3442- 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"}}
3443- c.Confirmer.Box, c.Confirmer.Enrollment, c.Confirmer.Region = "box", o.Roster.Boxes["box"].Enrollment, "ie"
3444- wire, _ := json.Marshal(c)
3445- if err := release.CreateExact(ctx, d.Store, ConfirmationKey(in.Reference, a.AttemptID, a.ConfirmationID), wire); err != nil {
3446- t.Fatal(err)
3447- }
3448+ // The console's own read: a running member of the group.
3449+ cloud := &fakeMembership{members: map[string]Membership{"i-new": member("routers")}}
3450 in.AttemptID, in.ConfirmationID = a.AttemptID, a.ConfirmationID
3451- admitted, err := d.EnrollAutomatic(ctx, sg, o, v, in, raw, "i-new", now)
3452- if err != nil {
3453- t.Fatal(err)
3454- }
3455- b := admitted.Roster.Boxes["new"]
3456+ admitted, _, err := d.EnrollAutomatic(ctx, sg, o, v, in, raw, "i-new", cloud, time.Now)
3457+ if err != nil {
3458+ t.Fatal(err)
3459+ }
3460+ b := admitted.Roster.Boxes[newRef]
3461 if b.Group != "routers" || b.Role != role {
3462 t.Fatalf("admitted box group %q role %q, want routers %q", b.Group, b.Role, role)
3463 }
3464````
3465
3466`internal/hostactors/awsmembership/awsmembership_test.go`:
3467
3468````go
3469package awsmembership
3470
3471import (
3472 "reflect"
3473 "testing"
3474
3475 "github.com/aws/aws-sdk-go-v2/aws"
3476 awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware"
3477 "github.com/aws/aws-sdk-go-v2/service/autoscaling"
3478 asgtypes "github.com/aws/aws-sdk-go-v2/service/autoscaling/types"
3479 "github.com/aws/aws-sdk-go-v2/service/ec2"
3480 ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types"
3481 "github.com/aws/smithy-go/middleware"
3482
3483 "github.com/carlosframework/platform/internal/hostactors"
3484)
3485
3486func meta(id string) middleware.Metadata {
3487 var md middleware.Metadata
3488 awsmiddleware.SetRequestIDMetadata(&md, id)
3489 return md
3490}
3491
3492func asgReply(instance, group, zone, state string) *autoscaling.DescribeAutoScalingInstancesOutput {
3493 return &autoscaling.DescribeAutoScalingInstancesOutput{ResultMetadata: meta("asg-req"), AutoScalingInstances: []asgtypes.AutoScalingInstanceDetails{{
3494 InstanceId: aws.String(instance), AutoScalingGroupName: aws.String(group), AvailabilityZone: aws.String(zone), LifecycleState: aws.String(state)}}}
3495}
3496
3497func ec2Reply(owner string, instances map[string]ec2types.InstanceStateName) *ec2.DescribeInstancesOutput {
3498 res := ec2types.Reservation{OwnerId: aws.String(owner)}
3499 for id, st := range instances {
3500 res.Instances = append(res.Instances, ec2types.Instance{InstanceId: aws.String(id), State: &ec2types.InstanceState{Name: st}})
3501 }
3502 return &ec2.DescribeInstancesOutput{ResultMetadata: meta("ec2-req"), Reservations: []ec2types.Reservation{res}}
3503}
3504
3505func TestMembershipFromTheTwoReplies(t *testing.T) {
3506 got := membershipFrom("i-new", asgReply("i-new", "routers-ie", "eu-west-1b", "InService"), ec2Reply("123", map[string]ec2types.InstanceStateName{"i-new": ec2types.InstanceStateNameRunning}))
3507 want := hostactors.Membership{GroupName: "routers-ie", AvailabilityZoneRegion: "eu-west-1", LifecycleState: "InService", InstanceState: "running", Owner: "123", RequestIDs: []string{"asg-req", "ec2-req"}}
3508 if !reflect.DeepEqual(got, want) {
3509 t.Fatalf("got %+v\nwant %+v", got, want)
3510 }
3511 // Not a member: Auto Scaling knows nothing of it, or answers for
3512 // another instance. EC2 knowing it changes nothing.
3513 for _, ao := range []*autoscaling.DescribeAutoScalingInstancesOutput{{ResultMetadata: meta("asg-req")}, asgReply("i-other", "routers-ie", "eu-west-1b", "InService")} {
3514 m := membershipFrom("i-new", ao, ec2Reply("123", map[string]ec2types.InstanceStateName{"i-new": ec2types.InstanceStateNameRunning}))
3515 if m.GroupName != "" || m.LifecycleState != "" {
3516 t.Fatalf("non-member read as %+v", m)
3517 }
3518 }
3519 // EC2 no longer lists it: no state and no owner.
3520 m := membershipFrom("i-new", asgReply("i-new", "routers-ie", "eu-west-1b", "Terminating"), &ec2.DescribeInstancesOutput{ResultMetadata: meta("ec2-req")})
3521 if m.InstanceState != "" || m.Owner != "" {
3522 t.Fatalf("forgotten instance read as %+v", m)
3523 }
3524}
3525
3526func TestZoneRegion(t *testing.T) {
3527 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": "", "": ""} {
3528 if got := zoneRegion(az); got != want {
3529 t.Fatalf("zoneRegion(%q) = %q, want %q", az, got, want)
3530 }
3531 }
3532}
3533````
3534
3535- [ ] **Step 2: Run them to see them fail**
3536
3537Run: `nice -n 19 go test ./internal/hostactors/... -count=1`
3538Expected: FAIL, neither package builds (`undefined: Membership`, `undefined: membershipFrom`).
3539
3540- [ ] **Step 3: Add the Auto Scaling SDK**
3541
3542v1.74.0 is the release built against the module's AWS core v1.44.0. Its sums match sum.golang.org.
3543
3544`go.mod`:
3545
3546````diff
3547--- a/go.mod
3548+++ b/go.mod
3549@@ -7,6 +7,7 @@
3550 github.com/aws/aws-sdk-go-v2/config v1.32.6
3551 github.com/aws/aws-sdk-go-v2/credentials v1.19.6
3552 github.com/aws/aws-sdk-go-v2/service/acm v1.45.0
3553+ github.com/aws/aws-sdk-go-v2/service/autoscaling v1.74.0
3554 github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.68.0
3555 github.com/aws/aws-sdk-go-v2/service/costexplorer v1.67.5
3556 github.com/aws/aws-sdk-go-v2/service/ec2 v1.324.0
3557````
3558
3559`go.sum`:
3560
3561````diff
3562--- a/go.sum
3563+++ b/go.sum
3564@@ -40,6 +40,8 @@
3565 github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.38/go.mod h1:1PDUYG9Z+JrbbsobsAZHjWOm9QBT/djiK3QbykTL5Z4=
3566 github.com/aws/aws-sdk-go-v2/service/acm v1.45.0 h1:C/LUyd2F3iFtityAYQbAYb56uP0x32HaHSVXfRSImAM=
3567 github.com/aws/aws-sdk-go-v2/service/acm v1.45.0/go.mod h1:8TFQHTFTgR7OoBHlqJr8gsHv+ePC8z4S1lcrZVPgqbM=
3568+github.com/aws/aws-sdk-go-v2/service/autoscaling v1.74.0 h1:hm0br6zbFj1tLd5l8F6owm2/4pFK+z6NA+wIkvdMFxg=
3569+github.com/aws/aws-sdk-go-v2/service/autoscaling v1.74.0/go.mod h1:71sdt+AKd1aCexOigHqdAO5Er2oY2dEhI63220v7A6o=
3570 github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.68.0 h1:RdWMeCVaJfu4hCPyRNVQMKMP/Y5J04zB5LF45DmHhRg=
3571 github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.68.0/go.mod h1:HfsU8PQ6mHewSJNS77IPAFMZZgFaYSHjAhSOAFXdu9M=
3572 github.com/aws/aws-sdk-go-v2/service/costexplorer v1.67.5 h1:hiJJTfio1+i/xnfLwu6RsDjDzkGSiLux54LTzDK4MyA=
3573````
3574
3575- [ ] **Step 4: Write the read, the admission check and the adapter**
3576
3577`internal/hostactors/membershipread.go`:
3578
3579````go
3580package hostactors
3581
3582import (
3583 "context"
3584 "errors"
3585 "fmt"
3586 "time"
3587
3588 "github.com/carlosframework/platform/internal/release"
3589)
3590
3591// Group membership, confirmed by the console (region HA spec 2 §5 step 5,
3592// H16, H28). Before it invites a box its region's group launched, and
3593// again before it enrolls it, the console asks the cloud, read-only and
3594// with its own credentials, whether the instance is a current member of
3595// the region's group. No box writes or contributes membership evidence:
3596// a confirmation exists only inside this package, built by
3597// confirmMembership from one MembershipReader answer, and nothing
3598// exported takes one in.
3599//
3600// What the cloud says is the reader's business; the AWS adapter is
3601// internal/hostactors/awsmembership. This file names no provider (§25).
3602
3603// MembershipReader answers, read-only, from the cloud the roster's region
3604// entries name. It is the console's own client.
3605type MembershipReader interface {
3606 // Member describes instance in awsRegion: its group, if any, and its
3607 // state. An instance in no group is a Membership with no GroupName. An
3608 // error is a read that failed, never an answer.
3609 Member(ctx context.Context, awsRegion, instance string) (Membership, error)
3610}
3611
3612// Membership is one instance's group membership, as the cloud reported it.
3613type Membership struct {
3614 GroupName string
3615 AvailabilityZoneRegion string
3616 LifecycleState string
3617 InstanceState string
3618 Owner string
3619 // RequestIDs are the cloud's request ids for the reads, one per call.
3620 RequestIDs []string
3621}
3622
3623// MembershipEvidence is what a confirmation read, for the console's log:
3624// the cloud's request ids, and when the read began.
3625type MembershipEvidence struct {
3626 RequestIDs []string
3627 ObservedAt time.Time
3628}
3629
3630// membershipFreshness is the write deadline: a read's write is issued
3631// less than membershipFreshness after the read's anchor, never at or after
3632// it.
3633const membershipFreshness = time.Minute
3634
3635var (
3636 // ErrMembershipUnknown is a membership read that failed. It says
3637 // nothing about the instance: the console tries again with the same
3638 // attempt.
3639 ErrMembershipUnknown = errors.New("hostactors: group membership could not be read")
3640 // ErrNotMember is the cloud's positive answer that the instance is not
3641 // a current, running member of the region's group in the deployment's
3642 // account and AWS region.
3643 ErrNotMember = errors.New("hostactors: the instance is not a current member of the region's group")
3644 // ErrMembershipStale is a read that was already membershipFreshness old
3645 // or more when the describe returned: the cloud was slow, and the
3646 // answer counts against the attempt.
3647 ErrMembershipStale = errors.New("hostactors: the membership read is more than a minute old")
3648 // ErrWriteDeadline is a write the deadline stopped (writeBound): no
3649 // store call was issued at or after it, but one already in flight was
3650 // cancelled, so whether it committed is unknown. The next pass reads
3651 // back and describes again.
3652 ErrWriteDeadline = errors.New("hostactors: the write would be issued more than a minute after its membership read")
3653)
3654
3655// membershipConfirmation is one read of an attempt's instance. It is built
3656// only by confirmMembership, from the reader's answer, and never leaves
3657// this package. A read the cloud answered carries its request ids and
3658// time whether or not it found a member, so every read can be logged.
3659type membershipConfirmation struct {
3660 requestIDs []string
3661 observedAt time.Time
3662}
3663
3664// confirmMembership asks r about instance in region's AWS region, and
3665// returns the confirmation only when the answer is a current member of
3666// region's group: Pending or InService, pending or running, owned by the
3667// region's account, in its AWS region, with a request id for each call.
3668// The read's anchor, its time, is taken before the call: never later than
3669// the describe's completion, so its age is never understated.
3670//
3671// The call gets at most storeCallTimeout, as each bound store call does:
3672// neither describe sets a deadline of its own, and a connection that
3673// stalls would otherwise hold the serial pass, and every box behind this
3674// one, for as long as it stalls. A call cut off, by the timeout or by the
3675// pass's own context, is unknown whatever it returned: the attempt stands
3676// and its wait does not grow.
3677func confirmMembership(ctx context.Context, r MembershipReader, region Region, instance string, now func() time.Time) (membershipConfirmation, error) {
3678 if r == nil || now == nil {
3679 return membershipConfirmation{}, fmt.Errorf("%w: no reader", ErrMembershipUnknown)
3680 }
3681 at := now().UTC()
3682 rctx, cancel := context.WithTimeout(ctx, storeCallTimeout)
3683 m, err := r.Member(rctx, region.AWSRegion, instance)
3684 cut := rctx.Err()
3685 cancel()
3686 // The ids the cloud gave are kept whatever came of the read, failed
3687 // calls included, for the console's log.
3688 c := membershipConfirmation{requestIDs: append([]string(nil), m.RequestIDs...), observedAt: at}
3689 if cut != nil {
3690 return c, fmt.Errorf("%w: the read did not finish in time: %v", ErrMembershipUnknown, cut)
3691 }
3692 if err != nil {
3693 return c, fmt.Errorf("%w: %v", ErrMembershipUnknown, err)
3694 }
3695 // A reply without an id from each call is not an answer, whatever it
3696 // claims, so it is never counted against the attempt.
3697 if len(m.RequestIDs) < 2 {
3698 return c, fmt.Errorf("%w: the cloud gave no request ids", ErrMembershipUnknown)
3699 }
3700 for _, id := range m.RequestIDs {
3701 if id == "" || id == NoRequestID {
3702 return c, fmt.Errorf("%w: the cloud gave a blank request id", ErrMembershipUnknown)
3703 }
3704 }
3705 switch {
3706 case m.GroupName != region.GroupName:
3707 return c, fmt.Errorf("%w: it is in group %q", ErrNotMember, m.GroupName)
3708 case m.LifecycleState != "Pending" && m.LifecycleState != "InService":
3709 return c, fmt.Errorf("%w: its lifecycle state is %q", ErrNotMember, m.LifecycleState)
3710 case m.InstanceState == "" || m.Owner == "":
3711 // Auto Scaling knows it but EC2 does not list it (yet, or any
3712 // more): no answer.
3713 return c, fmt.Errorf("%w: EC2 does not list it", ErrMembershipUnknown)
3714 case m.InstanceState != "pending" && m.InstanceState != "running":
3715 return c, fmt.Errorf("%w: it is %q", ErrNotMember, m.InstanceState)
3716 case m.Owner != region.Account || m.AvailabilityZoneRegion != region.AWSRegion:
3717 return c, fmt.Errorf("%w: it is in account %q, region %q", ErrNotMember, m.Owner, m.AvailabilityZoneRegion)
3718 }
3719 return c, nil
3720}
3721
3722// fresh refuses a confirmation membershipFreshness old or more at now, or
3723// one from the future: the describe itself was too slow.
3724func (c membershipConfirmation) fresh(now time.Time) error {
3725 if c.observedAt.IsZero() || now.Sub(c.observedAt) >= membershipFreshness || c.observedAt.After(now.Add(5*time.Second)) {
3726 return fmt.Errorf("%w (read at %s, checked at %s)", ErrMembershipStale, c.observedAt.Format(time.RFC3339), now.UTC().Format(time.RFC3339))
3727 }
3728 return nil
3729}
3730
3731// deadline is the last moment a write this read allows may be issued.
3732func (c membershipConfirmation) deadline() time.Time { return c.observedAt.Add(membershipFreshness) }
3733
3734// atWrite is the bound store's check before each call: at or after the
3735// deadline it is ErrWriteDeadline, unknown, never a refusal.
3736func (c membershipConfirmation) atWrite(now time.Time) error {
3737 if c.observedAt.IsZero() || !now.Before(c.deadline()) {
3738 return fmt.Errorf("%w (read at %s, write at %s)", ErrWriteDeadline, c.observedAt.Format(time.RFC3339), now.UTC().Format(time.RFC3339))
3739 }
3740 return nil
3741}
3742
3743func (c membershipConfirmation) evidence() MembershipEvidence {
3744 return MembershipEvidence{RequestIDs: append([]string(nil), c.requestIDs...), ObservedAt: c.observedAt}
3745}
3746
3747// writeBound is d with every store call bound to c's deadline (C2, S1),
3748// the single enforcement point of the write's freshness. Each store call
3749// (a read, the write, the read-back after a lost write) is refused before
3750// it is issued once the deadline is reached, by either clock, the
3751// context's or now; the returned context ends at the deadline; and each
3752// call it does issue gets at most storeCallTimeout (U2). The sealed
3753// writers (Invite, Replace and the checkMembership it makes) run on it
3754// unchanged, so no read, signature's Put or read-back of theirs is issued
3755// 60 seconds or more after the membership read's anchor, and any outcome
3756// not confirmed in time is ErrWriteDeadline, unknown.
3757func writeBound(ctx context.Context, d Directory, c membershipConfirmation, now func() time.Time) (Directory, context.Context, context.CancelFunc) {
3758 bctx, cancel := context.WithTimeout(ctx, c.deadline().Sub(now()))
3759 bd := d
3760 bd.Store = deadlineStore{Store: d.Store, check: func(callCtx context.Context) error {
3761 if bctx.Err() != nil || callCtx.Err() != nil {
3762 return fmt.Errorf("%w: the deadline passed", ErrWriteDeadline)
3763 }
3764 return c.atWrite(now())
3765 }}
3766 return bd, bctx, cancel
3767}
3768
3769// storeCallTimeout is the hard limit on one store call of a bound write
3770// (U2): a call the store has not answered within it is unknown, and its
3771// context is cancelled, so an S3 request is abandoned at the HTTP layer.
3772// It bounds how long the console waits, not when a write commits: a
3773// call cut off may still commit later, and is settled from the roster on
3774// a later pass. confirmMembership gives each membership read the same
3775// limit. A var so tests can shorten it.
3776var storeCallTimeout = 15 * time.Second
3777
3778// deadlineStore refuses every call once check says the deadline has been
3779// reached, before the call reaches the store underneath, and gives each
3780// call it issues at most storeCallTimeout. The bound cannot rest on the
3781// store alone: release.DirStore ignores its context, so the wrapper
3782// returns on its own when the call outlives the timeout or the deadline,
3783// whatever the store does. Every outcome it cannot confirm in time is
3784// ErrWriteDeadline, unknown: a call cut off in flight, one that never
3785// answered, and one that answered, even successfully, only after the
3786// deadline.
3787type deadlineStore struct {
3788 release.Store
3789 check func(context.Context) error
3790}
3791
3792// call runs one store call under the bound.
3793func (s deadlineStore) call(ctx context.Context, f func(context.Context) error) error {
3794 if err := s.check(ctx); err != nil {
3795 return err
3796 }
3797 cctx, cancel := context.WithTimeout(ctx, storeCallTimeout)
3798 defer cancel()
3799 done := make(chan error, 1)
3800 go func() { done <- f(cctx) }()
3801 var err error
3802 select {
3803 case err = <-done:
3804 case <-cctx.Done():
3805 }
3806 // Cut off, whether the store gave up on the context or not: by the
3807 // deadline, or by the hard timeout. Whatever it answered, if anything,
3808 // is not known to be the outcome.
3809 if cctx.Err() != nil {
3810 if ctx.Err() != nil {
3811 return fmt.Errorf("%w: in flight at the deadline", ErrWriteDeadline)
3812 }
3813 return fmt.Errorf("%w: the store did not answer in time", ErrWriteDeadline)
3814 }
3815 if err == nil {
3816 if late := s.check(ctx); late != nil {
3817 return fmt.Errorf("%w: it answered only after the deadline", ErrWriteDeadline)
3818 }
3819 }
3820 return err
3821}
3822
3823func (s deadlineStore) Get(ctx context.Context, key string) ([]byte, string, error) {
3824 var body []byte
3825 var etag string
3826 err := s.call(ctx, func(cctx context.Context) error {
3827 var err error
3828 body, etag, err = s.Store.Get(cctx, key)
3829 return err
3830 })
3831 if err != nil {
3832 return nil, "", err
3833 }
3834 return body, etag, nil
3835}
3836
3837func (s deadlineStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
3838 var etag string
3839 err := s.call(ctx, func(cctx context.Context) error {
3840 var err error
3841 etag, err = s.Store.Put(cctx, key, body, ifMatch)
3842 return err
3843 })
3844 if err != nil {
3845 return "", err
3846 }
3847 return etag, nil
3848}
3849
3850func (s deadlineStore) List(ctx context.Context, prefix string) ([]string, error) {
3851 var keys []string
3852 err := s.call(ctx, func(cctx context.Context) error {
3853 var err error
3854 keys, err = s.Store.List(cctx, prefix)
3855 return err
3856 })
3857 if err != nil {
3858 return nil, err
3859 }
3860 return keys, nil
3861}
3862
3863func (s deadlineStore) ListDirs(ctx context.Context, prefix string) ([]string, error) {
3864 var dirs []string
3865 err := s.call(ctx, func(cctx context.Context) error {
3866 var err error
3867 dirs, err = s.Store.ListDirs(cctx, prefix)
3868 return err
3869 })
3870 if err != nil {
3871 return nil, err
3872 }
3873 return dirs, nil
3874}
3875
3876func (s deadlineStore) Delete(ctx context.Context, key string) error {
3877 return s.call(ctx, func(cctx context.Context) error { return s.Store.Delete(cctx, key) })
3878}
3879````
3880
3881`internal/hostactors/groupref.go`:
3882
3883````go
3884package hostactors
3885
3886import (
3887 "crypto/sha256"
3888 "encoding/hex"
3889)
3890
3891// GroupBoxReference is the reference of a box its region's group launched
3892// (census ruling region-ha-2b): "g-" and the first 20 hex digits of
3893// sha256("carlos-box-ref-v1|" + account + "|" + instance). Status labels
3894// come from 1,600 values and collide as groups replace their boxes;
3895// instance ids never repeat in an account, and 80 bits of their digest
3896// make a collision negligible. It is a pseudonym, so the public status
3897// feed never shows an instance id. The box derives it to publish under it,
3898// and the console derives it again from the instance it proved (the
3899// attempt's, then the roster's pinned one) to read and admit it, never
3900// trusting the key a box chose. Boxes an operator enrolled keep their
3901// labels.
3902func GroupBoxReference(account, instance string) string {
3903 sum := sha256.Sum256([]byte("carlos-box-ref-v1|" + account + "|" + instance))
3904 return "g-" + hex.EncodeToString(sum[:])[:20]
3905}
3906````
3907
3908`internal/hostactors/enrollment_confirmation.go`:
3909
3910````diff
3911--- a/internal/hostactors/enrollment_confirmation.go
3912+++ b/internal/hostactors/enrollment_confirmation.go
3913@@ -4,7 +4,6 @@
3914 "context"
3915 "crypto/rand"
3916 "encoding/hex"
3917- "encoding/json"
3918 "github.com/carlosframework/platform/internal/boxid"
3919 "github.com/carlosframework/platform/internal/hostfloor"
3920 "github.com/carlosframework/platform/internal/release"
3921@@ -22,97 +21,75 @@
3922 // group takes (roles.go): "" (default) or legacy.
3923 Role string `json:"role,omitempty"`
3924 }
3925-type TerminatedEnrollment struct {
3926- PassID string `json:"pass_id"`
3927- Box string `json:"box"`
3928- Enrollment string `json:"enrollment"`
3929- InstanceID string `json:"instance_id"`
3930- State string `json:"state"`
3931- ObservedAt time.Time `json:"observed_at"`
3932- RequestID string `json:"request_id"`
3933-}
3934-type MembershipConfirmation struct {
3935- PassID string `json:"pass_id"`
3936- AttemptID string `json:"attempt_id"`
3937- ConfirmationID string `json:"confirmation_id"`
3938- EvidenceSHA256 string `json:"evidence_sha256"`
3939
3940- TargetPlacement string `json:"target_placement"`
3941- AWSRegion string `json:"aws_region"`
3942- GroupName string `json:"group_name"`
3943- GroupARN string `json:"group_arn"`
3944- InstanceID string `json:"instance_id"`
3945- Account string `json:"account"`
3946- AvailabilityZoneRegion string `json:"availability_zone_region"`
3947- LifecycleState string `json:"lifecycle_state"`
3948- InstanceState string `json:"instance_state"`
3949- ObservedAt time.Time `json:"observed_at"`
3950- RequestIDs []string `json:"request_ids"`
3951- Confirmer struct {
3952- Box string `json:"box"`
3953- Enrollment string `json:"enrollment"`
3954- Region string `json:"region"`
3955- } `json:"confirmer"`
3956- TerminatedEnrollments []TerminatedEnrollment `json:"terminated_enrollments"`
3957+// EnrollAutomatic admits a box its region's group launched (H16, H28). The
3958+// console confirms the box's group membership itself, here: Member is
3959+// asked, through r, and every store call of the enrolling write,
3960+// checkMembership's reads and Replace's Put included, runs on the store
3961+// writeBound binds to that read's deadline, the single enforcement point:
3962+// none is issued at or after it. No box writes or contributes membership
3963+// evidence, and no caller can hand a confirmation in: membershipread.go
3964+// builds it from r's answer alone. The evidence comes back whenever the
3965+// cloud answered, so every read can be logged.
3966+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) {
3967+ pinned, c, err := d.checkConfirmed(ctx, o, v, in, raw, instance, r, now)
3968+ if err != nil {
3969+ return Observed{}, c.evidence(), err
3970+ }
3971+ bd, bctx, cancel := writeBound(ctx, d, c, now)
3972+ defer cancel()
3973+ next, err := bd.enroll(bctx, sg, o, v, pinned, raw, true)
3974+ if err != nil {
3975+ return Observed{}, c.evidence(), err
3976+ }
3977+ // Callers go on with the store itself, not the bound one.
3978+ return next, c.evidence(), nil
3979 }
3980
3981-// Confirmers write this create-only evidence with read-only ASG/EC2 results.
3982-// This consumer gives H16/H17 their admission boundary; spec 4 owns producing it.
3983-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) {
3984+// checkConfirmed is EnrollAutomatic's admission check, made without
3985+// writing: the attempt in the roster matches the request and the record,
3986+// the request's reference is the instance's group reference
3987+// (GroupBoxReference), and a describe made now confirms the attempt's
3988+// instance is a current member of the region's group. It returns in
3989+// pinned to that instance and the confirmation the read built, which
3990+// never leaves this package. The console's enrollment pass asks it
3991+// immediately before it invites a box, and makes the invitation on the
3992+// store writeBound binds to that confirmation (autoenroll.go).
3993+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) {
3994 if err := validateScope(o.Roster, Scope{Namespace: in.Namespace, Regions: []string{in.Region}, Pools: []string{in.Pool}}); err != nil {
3995- return Observed{}, err
3996+ return EnrollmentRequest{}, membershipConfirmation{}, err
3997 }
3998 region, ok := o.Roster.Regions[in.Region]
3999 if !ok || region.Account != v.Account || region.AWSRegion != in.AWSRegion || region.GroupARN == "" || region.GroupName == "" || !segment(instance) || !segment(in.Reference) || in.ExistingReference {
4000- return Observed{}, ErrRoster
4001+ return EnrollmentRequest{}, membershipConfirmation{}, ErrRoster
4002+ }
4003+ // A group box is enrolled only under the reference derived from its
4004+ // instance, never one it chose (census ruling region-ha-2b).
4005+ if in.Reference != GroupBoxReference(region.Account, instance) {
4006+ return EnrollmentRequest{}, membershipConfirmation{}, ErrRoster
4007 }
4008 attempt, ok := o.Roster.EnrollmentAttempts[in.Reference]
4009 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 {
4010- return Observed{}, ErrRoster
4011+ return EnrollmentRequest{}, membershipConfirmation{}, ErrRoster
4012 }
4013- body, _, err := d.Store.Get(ctx, ConfirmationKey(in.Reference, in.AttemptID, in.ConfirmationID))
4014+ c, err := confirmMembership(ctx, r, region, instance, now)
4015 if err != nil {
4016- return Observed{}, err
4017+ return EnrollmentRequest{}, c, err
4018 }
4019- var c MembershipConfirmation
4020- 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 {
4021- return Observed{}, ErrRoster
4022- }
4023- for _, id := range c.RequestIDs {
4024- if id == "" {
4025- return Observed{}, ErrRoster
4026- }
4027- }
4028- confirmer, ok := o.Roster.Boxes[c.Confirmer.Box]
4029- if !ok || confirmer.State != "active" || confirmer.Enrollment != c.Confirmer.Enrollment || confirmer.Region != c.Confirmer.Region {
4030- return Observed{}, ErrRoster
4031- }
4032- fenced, err := d.TerminationFence(ctx, o, c.Confirmer.Box, now)
4033- if err != nil || fenced {
4034- return Observed{}, ErrRoster
4035- }
4036- if confirmer.Region != in.Region {
4037- seen := map[string]bool{}
4038- for _, read := range c.TerminatedEnrollments {
4039- b, ok := o.Roster.Boxes[read.Box]
4040- 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) {
4041- return Observed{}, ErrRoster
4042- }
4043- seen[read.Box] = true
4044- }
4045- for ref, b := range o.Roster.Boxes {
4046- if b.Region == in.Region && (b.State == "active" || b.State == "quarantined") && !seen[ref] {
4047- return Observed{}, ErrRoster
4048- }
4049- }
4050+ if err := c.fresh(now()); err != nil {
4051+ return EnrollmentRequest{}, c, err
4052 }
4053 // Pin the verified IMDS identity to the instance that membership proved.
4054 in.TofuInstanceID = instance
4055 in.ExistingReference = true
4056- return d.enroll(ctx, sg, o, v, in, raw, true)
4057-}
4058-func freshConfirmation(at, now time.Time) bool {
4059- return !at.IsZero() && now.Sub(at) <= time.Minute && !at.After(now.Add(5*time.Second))
4060+ return in, c, nil
4061 }
4062
4063 type EnrollmentAttempt struct {
4064@@ -126,10 +103,6 @@
4065 GroupName string `json:"group_name"`
4066 GroupARN string `json:"group_arn"`
4067 Consumed bool `json:"consumed"`
4068-}
4069-
4070-func ConfirmationKey(ref, attempt, confirmation string) string {
4071- return "control/box-enrollment/" + ref + "/" + attempt + "/" + confirmation + ".json"
4072 }
4073
4074 // BeginEnrollmentAttempt supersedes evidence by CAS in the signed authority.
4075````
4076
4077`internal/hostactors/private.go`:
4078
4079````diff
4080--- a/internal/hostactors/private.go
4081+++ b/internal/hostactors/private.go
4082@@ -7,9 +7,18 @@
4083 "strings"
4084 )
4085
4086+// PrivateBoxReport reads the private status report of box ref. A box its
4087+// region's group enrolled (b.Group set) is read under the reference the
4088+// console derives from the instance the roster pins, never under the key
4089+// the box chose (GroupBoxReference); every other box under ref, as before.
4090 func (d Directory) PrivateBoxReport(ctx context.Context, ref string, b Box) (Report, error) {
4091 key := "status/boxes/" + ref + ".json"
4092- if strings.HasPrefix(b.Namespace, "fleet:") {
4093+ if b.Group != "" {
4094+ if b.Cloud.Account == "" || !segment(b.Cloud.InstanceID) {
4095+ return Report{}, ErrRoster
4096+ }
4097+ key = "status/boxes/" + GroupBoxReference(b.Cloud.Account, b.Cloud.InstanceID) + ".json"
4098+ } else if strings.HasPrefix(b.Namespace, "fleet:") {
4099 fleet := strings.TrimPrefix(b.Namespace, "fleet:")
4100 if !segment(fleet) || !segment(b.Label) {
4101 return Report{}, ErrRoster
4102````
4103
4104`internal/hostactors/awsmembership/awsmembership.go`:
4105
4106````go
4107// Package awsmembership is the AWS adapter of the console's membership
4108// read (region HA spec 2 §5 step 5, §25; H16, H28): read-only Auto Scaling
4109// and EC2 describe calls, made in the AWS region the roster names for the
4110// attempt's placement. The console role needs
4111// autoscaling:DescribeAutoScalingInstances and ec2:DescribeInstances,
4112// conditioned on aws:RequestedRegion being one of the deployment's AWS
4113// regions. No box role has either.
4114package awsmembership
4115
4116import (
4117 "context"
4118
4119 "github.com/aws/aws-sdk-go-v2/aws"
4120 awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware"
4121 "github.com/aws/aws-sdk-go-v2/config"
4122 "github.com/aws/aws-sdk-go-v2/service/autoscaling"
4123 "github.com/aws/aws-sdk-go-v2/service/ec2"
4124 ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types"
4125 "github.com/aws/smithy-go/middleware"
4126
4127 "github.com/carlosframework/platform/internal/hostactors"
4128)
4129
4130// Reader is hostactors.MembershipReader over the console's own credentials.
4131type Reader struct{ cfg aws.Config }
4132
4133// New loads the console's AWS configuration (its instance role).
4134func New(ctx context.Context) (*Reader, error) {
4135 cfg, err := config.LoadDefaultConfig(ctx)
4136 if err != nil {
4137 return nil, err
4138 }
4139 return &Reader{cfg: cfg}, nil
4140}
4141
4142// Member describes instance's Auto Scaling membership, then its EC2 state
4143// and owner: two calls, two request ids.
4144func (r *Reader) Member(ctx context.Context, awsRegion, instance string) (hostactors.Membership, error) {
4145 asg := autoscaling.NewFromConfig(r.cfg, func(o *autoscaling.Options) { o.Region = awsRegion })
4146 ao, err := asg.DescribeAutoScalingInstances(ctx, &autoscaling.DescribeAutoScalingInstancesInput{InstanceIds: []string{instance}})
4147 if err != nil {
4148 return hostactors.Membership{}, err
4149 }
4150 ec := ec2.NewFromConfig(r.cfg, func(o *ec2.Options) { o.Region = awsRegion })
4151 eo, err := ec.DescribeInstances(ctx, &ec2.DescribeInstancesInput{Filters: []ec2types.Filter{{Name: aws.String("instance-id"), Values: []string{instance}}}})
4152 if err != nil {
4153 return hostactors.Membership{}, err
4154 }
4155 return membershipFrom(instance, ao, eo), nil
4156}
4157
4158// membershipFrom reads the two replies. Anything but exactly the one
4159// instance asked about, in exactly one group, is no membership.
4160func membershipFrom(instance string, ao *autoscaling.DescribeAutoScalingInstancesOutput, eo *ec2.DescribeInstancesOutput) hostactors.Membership {
4161 m := hostactors.Membership{RequestIDs: []string{requestID(ao.ResultMetadata), requestID(eo.ResultMetadata)}}
4162 if len(ao.AutoScalingInstances) == 1 && aws.ToString(ao.AutoScalingInstances[0].InstanceId) == instance {
4163 d := ao.AutoScalingInstances[0]
4164 m.GroupName = aws.ToString(d.AutoScalingGroupName)
4165 m.LifecycleState = aws.ToString(d.LifecycleState)
4166 m.AvailabilityZoneRegion = zoneRegion(aws.ToString(d.AvailabilityZone))
4167 }
4168 if len(eo.Reservations) == 1 && len(eo.Reservations[0].Instances) == 1 && aws.ToString(eo.Reservations[0].Instances[0].InstanceId) == instance {
4169 res := eo.Reservations[0]
4170 m.Owner = aws.ToString(res.OwnerId)
4171 if st := res.Instances[0].State; st != nil {
4172 m.InstanceState = string(st.Name)
4173 }
4174 }
4175 return m
4176}
4177
4178// zoneRegion is the region of an availability zone name ("eu-west-1a" is
4179// in "eu-west-1"): the name less one trailing letter after a digit. Any
4180// other shape (a Local Zone, a zone id) answers "", which matches no
4181// region.
4182func zoneRegion(az string) string {
4183 n := len(az)
4184 if n < 3 || az[n-1] < 'a' || az[n-1] > 'z' || az[n-2] < '0' || az[n-2] > '9' {
4185 return ""
4186 }
4187 return az[:n-1]
4188}
4189
4190func requestID(md middleware.Metadata) string {
4191 id, _ := awsmiddleware.GetRequestIDMetadata(md)
4192 return id
4193}
4194
4195var _ hostactors.MembershipReader = (*Reader)(nil)
4196````
4197
4198- [ ] **Step 5: Run the tests**
4199
4200Run: `nice -n 19 go test ./internal/hostactors/... -count=1`
4201Expected: PASS, main's tests included.
4202
4203- [ ] **Step 6: The provider boundary**
4204
4205`region_ha_boundary_test.go`:
4206
4207````diff
4208--- a/region_ha_boundary_test.go
4209+++ b/region_ha_boundary_test.go
4210@@ -35,6 +35,8 @@
4211 "internal/hostenv/write_linux.go",
4212 "internal/hostenv/write_other.go",
4213 "internal/host/installchannel.go",
4214+ "internal/hostactors/membershipread.go",
4215+ "internal/hostactors/groupref.go",
4216 }
4217
4218 // regionHAAdapterFiles are spec 2's AWS adapters. Each is named for AWS so
4219@@ -43,6 +45,7 @@
4220 "internal/host/awsaddr.go",
4221 "internal/console/awsregionmetric.go",
4222 "internal/hostenv/awsssm.go",
4223+ "internal/hostactors/awsmembership/awsmembership.go",
4224 }
4225
4226 // regionHAPackages are the packages spec 2 touches. Every S3 client built
4227@@ -55,6 +58,7 @@
4228 "internal/edgesfile",
4229 "internal/host",
4230 "internal/hostactors",
4231+ "internal/hostactors/awsmembership",
4232 "internal/hostenv",
4233 "internal/hostops",
4234 "internal/lbhealth",
4235````
4236
4237Run: `nice -n 19 go test . -run 'TestRegionHA(Core|Adapters|S3)' -count=1`
4238Expected: PASS.
4239
4240- [ ] **Step 7: Census**
4241
4242No 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:
4243
4244`internal/provision/plan9_b8_census_test.go`:
4245
4246````diff
4247--- a/internal/provision/plan9_b8_census_test.go
4248+++ b/internal/provision/plan9_b8_census_test.go
4249@@ -814,6 +814,11 @@
4250 {"internal/hostactors/censuskey.go", "Directory.BindCensusKey", "nonrecord", "sel:Put", "7d9d673435e8c8a6aac3105e3a754bde2adcbc5d603945dd599cef703f334a5b"},
4251 {"internal/hostactors/invitation.go", "Directory.Invite", "nonrecord", "sel:Put", "356618a75a23d0157d8e0402e20187a42956f67a4332d698bb22bf05133ac489"},
4252 {"internal/hostactors/roster.go", "Directory.Replace", "nonrecord", "sel:Put", "e6e8ac0070650dfacc4a438b080cb69738530b3d9013de55c1bcf06431b0a847"},
4253+ // Region HA plan 2b (Task 4): the store a membership read binds to its
4254+ // deadline; it forwards Put, refuses it once the deadline passes, gives
4255+ // it at most storeCallTimeout, and reports one that answers late as
4256+ // unknown.
4257+ {"internal/hostactors/membershipread.go", "deadlineStore.Put", "nonrecord", "def:Put,sel:Put", "848f2b529d87e34f128dd481f14213e1cfaf8c54555806e6314864ef9a2dbb14"},
4258 {"internal/hostbox/regionalworker.go", "RegionalWorker.Census", "key-site", "lit:instances", "477bcb896b5ae8ec80af592d68eceb7992ad8a23cd6cff6043ad03b3ef2a8085"},
4259 {"internal/hostbox/requeststore.go", "RequestStore.CopyVersion", "versioned-transport", "def:CopyVersion,sel:CopyVersion", "3a50049ce7ca7f2cbc770bef46218944cdc7000205a86c20e4932f0b510da28f"},
4260 {"internal/hostbox/requeststore.go", "RequestStore.PutIfAbsent", "versioned-transport", "def:PutIfAbsent,sel:PutIfAbsent", "3666cbe5d2b91c1c652ffb4459c10a374fbd92e325e4e5c574fa3525d0ce2ffe"},
4261````
4262
4263`internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv`:
4264
4265````diff
4266--- a/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv
4267+++ b/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv
4268@@ -1,10 +1,13 @@
4269 # 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.
4270 # 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.
4271 # 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.
4272-@coverage . be7c96bb2ff00b5b018b436f976f0d33bbcc8765c17e357baa6cd0290f59f42d
4273+# 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).
4274+@coverage . 3678cfe22582d35fe0d8e93d3fed898cdffa6fb2ce2d81e0252c8a8da77993b3
4275 @coverage internal/hostenv a3f3274a86cdf8194afb871301c1ba3289dfbdc9b703dd547178273df151cdaa
4276 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
4277 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
4278 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
4279 @coverage internal/host 64ee4f561f2a28d584a6f7c82e068e57db4822f6cbb130116284bca583d2d390
4280 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
4281+@coverage internal/hostactors f6dbcea46bed878257c5b34f8caeef83e71cc995cae7b3a4ae59339ed0740c68
4282+@coverage internal/hostactors/awsmembership a3defad7179245492a3c1dcfce5e20f0f85db2e240b474c654acf211da16df98
4283````
4284
4285Run: `nice -n 19 go test ./internal/provision/ -run 'TestPlan9B8RecordWriteCensus|TestPlan14RootReadCensus$' -count=1 && nice -n 19 go test ./internal/console/ -run TestClaimAuthoritySourceCensus -count=1`
4286Expected: PASS.
4287
4288- [ ] **Step 8: Commit**
4289
4290```bash
4291git 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
4292git commit -m "region HA 2b: group membership, read by the console"
4293```
4294
4295---
4296
4297### Task 5: The console's automatic enrolment pass
4298
4299**Files:**
4300- Create: `internal/hostactors/autoenroll.go`, `internal/hostactors/autoenroll_test.go`
4301- Create: `internal/console/enrollpass.go`, `internal/console/enrollpass_test.go`, `internal/console/awsmembership.go`
4302- Modify: `internal/console/hostclaimops.go` (start the pass with region HA on)
4303- Modify: `internal/console/claim_authority_census_test.go`, `internal/console/testdata/claim-authority-sites.txt` (R6)
4304- Modify: `region_ha_boundary_test.go`, the overlay, `internal/provision/plan14_root_reads_test.go` (the seal)
4305
4306**Interfaces:**
4307- 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`).
4308- 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`.
4309- 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`.
4310
4311For 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.
4312
4313A 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.
4314
4315**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.
4316
4317The 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.
4318
4319The 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 clai