| index 27ba5556e..9ce13445c 100644 |
| --- a/docs/superpowers/plans/2026-10-17-region-ha-2b-joining.md |
| +++ b/docs/superpowers/plans/2026-10-17-region-ha-2b-joining.md |
| @@ -4,11 +4,11 @@ |
| |
| **Goal:** Build spec 2 §5's box side and console side of joining, dormant behind `CARLOS_REGION_HA` and claim mode: the agent chooses hosting or joining at every start, a joining box publishes its identity record and waits behind a 503 health answer, the console confirms the box's group membership itself and then invites and enrolls it, root arms the box's guard, a fresh box installs its channel's release into both protected slots, and `host.env` is assembled from SSM parameters. |
| |
| -**Architecture:** The mode is chosen once, in `RunAgent`, before anything else starts (`hostingGate`). Joining is its own small run (`internal/host/joining.go`): the identity record loop, a 503-only health listener, the guard-ready report, and a marker that asks root (`carlos system join`, behind a path unit) to arm the guard with the invitation's enrollment. Hosting is main's claim agent, which reports hosting to the health answer only while its claim worker runs. The console is the only membership confirmer (H28): its enrolment pass, `hostactors.AutoEnroller` driven by `internal/console/enrollpass.go`, asks the cloud through `hostactors.MembershipReader`, its own read-only client, and `EnrollAutomatic` and `CheckConfirmed` make that read themselves and build the confirmation inside `hostactors` where no caller can supply one. 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; an enrolling write whose outcome that deadline leaves unknown is read back, with a fresh describe, on the next pass. 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`). |
| +**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 `CheckConfirmed` make that read themselves and build the confirmation inside `hostactors` where no caller can supply one. 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 each call it does issue is cut off 15 seconds after it began, so a write that commits late is at most 60 + 15 seconds behind its read. An outcome the bound cannot confirm is unknown, and the next pass settles it the ordinary way, by reading the roster. They stand on main's `BeginEnrollmentAttempt`, `Invite`, `Replace` and `membership.go`, unchanged. A box its region's group launched is referenced by its group reference, `g-` and 20 hex digits of its account and instance's digest (`GroupBoxReference`): it publishes under it, `statusLoop` included (and publishes nothing while the reference cannot be read), and the console derives it again from the instance it proved, never trusting the key the box chose. The status reader drops reports no box has rewritten in a day before its key cap, so replaced boxes' history never hides the running ones. Each AWS part is an adapter named for AWS: the SSM parameter store (`internal/hostenv/awsssm.go`) and the Auto Scaling and EC2 describe calls (`internal/hostactors/awsmembership`, opened by the console in `internal/console/awsmembership.go`). |
| |
| **Tech Stack:** Go 1.26, module `github.com/carlosframework/platform`; `internal/hostenv` (new), `internal/host`, `internal/hostactors`, `internal/console`, the `carlos` CLI (repo root); AWS SDK v2 SSM and Auto Scaling (new, v1.74.0, the versions built against the repo's `aws-sdk-go-v2 v1.44.0`), EC2 (already a dependency). |
| |
| -**Spec:** `docs/superpowers/specs/2026-10-15-region-ha-two-box-regions-design.md`, **rev 18**: §5 (boot and enrolment, the write deadline and the read-back, group references, the status listing and the activation gates), §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. |
| +**Spec:** `docs/superpowers/specs/2026-10-15-region-ha-two-box-regions-design.md`, **rev 19**: §5 (boot and enrolment, host.env's fixed path and checked write, the write deadline and its per-call timeout, unknown outcomes settled by the roster, group references, the status listing and the activation gates), §12 (spec 2's hand-off to spec 3), §4 (the SSM layout and IAM, for 2c's seams), §25 (the provider boundary), rulings H1 to H28 (H28: the console is the only membership confirmer), and platform-1e's census ruling region-ha-2b (RULED by the Plan 14 controller 2026-10-16, with its conditions). Plan 2a (`2026-10-16-region-ha-2a-core.md`, built and merged as 6834659c) for format and the seams it left. |
| |
| **Review round 1.** Astra's review of the first draft (`CODEX-REVIEW-REGION-HA-2B-1.md`) found eight things; this revision answers all of them, with the claim owner's conditions on the first (C1 to C4): |
| |
| @@ -45,6 +45,14 @@ |
| | `runHostEnv` conditions 1 to 3 | No box write to the parameters (2c's negative check); an allowlist of names, safe values, an atomic write through a checked directory; the group marker and a group box's region cross-checked against the roster at the agent's use (T4, Tasks 2, 6) | |
| | The join trigger condition | Root's join reads nothing from its trigger file; hostile contents change nothing (Task 7) | |
| |
| +**Review round 4.** Astra's fourth review (`CODEX-REVIEW-REGION-HA-2B-4.md`) closed round 3's items but the dev check of `pendingTime`, which stays an activation gate, and found four more. The claim owner and platform-1e agreed two simplifications in answer (binding, U1 to U3): |
| + |
| +| Finding or ruling | What changed | |
| +|---|---| |
| +| #1 (P2): a success that returns after the deadline escapes the bound | U2: every store call of a bound write runs under `deadlineStore.call`, which gives it a hard 15-second timeout (`storeCallTimeout`) on top of the 60-second issue bound. A store that ignores its context is cut off by the wrapper itself, and a call that answers, even successfully, only after the deadline is `ErrWriteDeadline`, unknown. A write that commits late is at most 60 + 15 seconds behind its read (Tasks 4, 5) | |
| +| #2 (P2): the read-back accepts a stale describe; #3 (P2): a failed page discards the escalation | U1: the read-back, its in-memory table, its fresh describe and its page are removed, and with them RHB-C65 and RHB-C66. An unknown outcome is settled on the next pass by the roster read every pass makes: a box the roster lists is enrolled and done; one it does not list goes on with the same attempt, if it is still valid, and no backoff growth. A detached instance that keeps running is spec 3's: its regional worker reconciles roster membership against the group's (Activation notes, Open question 9) (Task 5) | |
| +| #4 (P2): host.env's directory check leaves the write path open | U3: `host-env write` has no `--out`; the path is fixed at `/etc/carlos/host.env`. `writeHostEnv` (Linux) opens `/etc` and `/etc/carlos` by descriptor, `O_NOFOLLOW\|O_DIRECTORY`, and refuses either unless `fstat` shows a directory root owns that no group or other can write; it writes a temporary file 0600 inside the held directory (`openat`), fsyncs it, renames it within the directory and fsyncs the directory. Off Linux, `write` refuses. RHB-C67 is reworded and RHB-C68 added; no pending row named `--out` (Task 2) | |
| + |
| ## Scope |
| |
| Spec 2 is four plans. This is **2b**. What 2a left it, and what it leaves the others: |
| @@ -74,17 +82,17 @@ Spec 2 is four plans. This is **2b**. What 2a left it, and what it leaves the ot |
| - **Membership (H16, H28; C1, C2).** The console is the only membership confirmer. No box writes or contributes membership evidence, and no box role can describe the group or create under `control/box-enrollment/`. |
| - `EnrollAutomatic` and `CheckConfirmed` take a `MembershipReader` (the console's AWS client) and call `Member` themselves. The confirmation is built from that answer alone, by the unexported `confirmMembership`, and never leaves `hostactors`; no exported function takes a confirmation or a `Membership`. Tests use a fake reader. |
| - A member is in the region's group, `Pending` or `InService`, `pending` or `running`, owned by the region's account, in its AWS region, with a request id from each of the two calls. |
| - - **Freshness bound: 60 seconds, enforced at the write (S1, T3).** 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: the write is issued no later than 60 s after the membership read; a write whose result is lost to the deadline is unknown and is read back next pass.** `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; and a write whose commit cannot be confirmed before the deadline is `ErrWriteDeadline`, unknown, and is read back on the next pass. The store is wrapped, not trusted: `release.S3Store` passes its context to the AWS SDK, which honours the deadline, but `release.DirStore` ignores its context. 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. |
| - - **Read-back of an unknown enrolment (the claim owner's ruling).** When an enrolling write's outcome is unknown and a later pass finds that it landed, the pass does not simply accept it: it makes a fresh describe through the same `MembershipReader`. A current member is done, logged with the attempt id, the instance id and both request ids. An instance that is no longer a member is logged and paged once, and 2b changes nothing else: what then keeps it from hosting or counting toward capacity is main's termination fence for a terminated instance (`internal/hostactors/fence_authority.go:14`, honoured at `internal/hostops/executor.go:204`) and C3's stale-edges exclusion and restart hold for a stopped one (`internal/boxchoice/boxchoice.go:131`, Task 6's `relaunched`); an instance that left its group but still runs keeps its enrolment until an operator removes it, which is what the page says. The pass keeps its memory of unknown outcomes in memory; it is lost at a restart, and after a restart such an enrolment is treated like any other enrolled box. That is acceptable because its roster write was issued before the deadline, so the confirmation behind it was under 60 seconds old when the write began, and at most a moment past that when it landed. |
| + - **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`). So a write is issued no later than 60 s after the membership read, and one that commits late is at most 60 + 15 = 75 s behind it.** `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. A missed deadline keeps the attempt and its wait. A describe that itself took 60 seconds or more is `ErrMembershipStale`, checked once in `checkConfirmed` after the describe, and counts against the attempt. Nothing else checks freshness: no callback in `enroll` and no check before `Invite`, which would only repeat the bound; between the describe and `Invite` sits nothing that waits. |
| + - **Unknown outcomes are settled by the roster (U1).** An outcome the bound cannot confirm (`ErrWriteDeadline`) is resolved the ordinary way on the next pass, which reads the signed roster as every pass does: a box the roster lists is enrolled, and the pass has nothing more to do for it; one it does not list goes on with the same attempt while it is still valid, and its wait does not grow. There is no read-back, no memory of unknown outcomes and no page. An instance that left its group but still runs after its enrolment landed keeps that enrolment until an operator removes it or spec 3's regional worker reconciles it ("Activation gates", note); a terminated one is kept from hosting and from capacity by main's termination fence (`internal/hostactors/fence_authority.go:14`, honoured at `internal/hostops/executor.go:204`), and a stopped one by the stale-edges rule (`internal/boxchoice/boxchoice.go:131`) and C3's restart hold (Task 6's `relaunched`). |
| - **Audit (S5).** Main's roster wire has no field for request ids, on the attempt or on the box entry, so none is added: the console logs every membership read, with the box's reference, its attempt id, its instance id, both request ids (or "none" for a call that failed before the cloud answered) and the read's time, and what came of it: invited, enrolled, refused or unknown (`AutoEnroller.Logf`). |
| - **Unknown is not invalid (#7).** A read that fails, or is not an answer (EC2 does not list the instance, a missing request id), is `ErrMembershipUnknown`: the pass asks again next time with the same attempt, which it neither supersedes nor counts. Only the cloud's positive answer that the instance is not a current member (`ErrNotMember`), or a read that went stale before its write (`ErrMembershipStale`), counts against the attempt: the pass keeps it, asking nothing more, for `Patience`, doubling with each refusal up to 32 times, then begins another. A record whose instance is gone costs one roster write and one describe an hour. |
| - **Predecessors until spec 3 (C3).** 2a's readers already exclude fenced and terminated boxes: every one goes through `ExecutorChooser.eligible` (`internal/hostops/executor.go:172`), which keeps only `active` roster entries (`:201`) and drops a box whose termination fence validates (`:204`, `TerminationFence`), and `boxchoice.Eligible` then requires a fresh edges file (`internal/boxchoice/boxchoice.go:131`) and drops the claim's lost boxes (`:143`), and `boxchoice.Placeable` drops headroom older than 15 minutes (`internal/boxchoice/policy.go:69`). `PlaceableBoxes` (`executor.go:103`, the create, move and no-room doors: `internal/console/apiinstances.go:1267`, `boxselect.go:204`, `nocapacity.go:171`), `Choose` and `Permits` (`executor.go:61`, `:85`) and `Capacity` (`executor.go:122`, the `EligibleBoxes` metric through `internal/console/regioncapacity.go:108`) all read that set. A terminated box is out as soon as its fence is written, and in any case once its edges file is 15 minutes old; a stopped box drops out by the same freshness rule. 2b adds no exclusion. |
| - **The channel install (A1-6, A2-2).** `carlos system install-channel` verifies the signed pointer (`verifyPointer`), refuses a release that does not pass the published host floor exactly as guard-arm will check it (`hostfloor.CheckImage`), fetches against the signed sha256, stages through `stageCandidate`, and puts the artifact in both `carlos` and `carlos.prev`. It keeps the release identity for guard-arm, and writes `applied.json` and `seen.json` as an apply would. On every run, each directory on the agent's cache path (`system`, `system/router` and the version's own) is created as bootstrap creates directories (`bootstrapMakeDir`, no symlink followed) and handed, alone, to the service user (`ownByService`: bootstrap's descriptor-anchored open, then `Fchown`), whether this run or an earlier failed one created it; a symlinked parent is refused; nothing else is chowned. The agent's next update can then download and ask. It refuses an armed guard, a box that already runs another release, a missing floor, and any read that fails. |
| -- **host.env (§5 step 2).** SecureString parameters named for their variables, under `/carlos/<deployment>/<region>/` and, for the five shared variables, `/carlos/<deployment>/shared/`. Every variable §5 step 2 names is required. The file is written 0600 by root, sorted `KEY=value` lines, with values restricted to letters, digits and `_ . / : , @ + = % -` so systemd and a shell read it alike; anything else is refused, naming the variable and never the value. A variable in both scopes is refused. Only allowlisted names are accepted (`Required` and `Optional`, census condition 2): any other name, under either scope, is refused by name by `write` and by `seed`, and keys that would select a box's authority from the store alone (fleet or tunnel identity, address overrides, pools, directories, relay settings, tokens) are not on the list. The file is replaced whole (temporary file, fsync, rename), and only through a directory that is a real directory root owns, not a symlink. `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. |
| +- **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. The path is fixed, `/etc/carlos/host.env`; `write` takes no flag to move it (U3). It is written only through `/etc` and `/etc/carlos` held by descriptor: each opened `O_NOFOLLOW|O_DIRECTORY` and refused unless `fstat` shows a directory root owns that no group or other can write (`/etc/carlos` is created 0755 first if missing, then opened and checked the same way); the new file is created 0600 inside the held directory, fsynced, renamed over `host.env` within it, and the directory fsynced, so a reader sees the old file or the new one. That code is Linux's (`write_linux.go`); elsewhere `write` refuses. `write` adds `CARLOS_BOX_REFERENCE=group`, which is never a parameter. The marker and `CARLOS_REGION`, which do select authority, are cross-checked against the signed roster at the agent's use (condition 3, Task 6), not at write: at boot the box has no roster entry to check against, and before enrolment neither grants anything, since the console derives a joining box's reference and region from its proven instance. `seed` writes a region's own values with overwrite and the shared ones create-only (`SetNew`, SSM `Overwrite: false`); when another seed got there first, it reads the value back and goes on only if it is the value it would have written, otherwise it refuses, naming the variable. |
| - **No roster field is added.** The roster wire is unchanged (`EnrollmentAttempts` and the region's group fields are main's), so 2b has no reader-ordering constraint. The only new objects are box-local (`/opt/carlos/system/join/`, `/opt/carlos/system/router/channel-identity.json`, `/data/system-join-requested`) or main's keys (invitations, status reports, identity records). Nothing is written under `control/box-enrollment/`: main's `ConfirmationKey` and the stored confirmation (`MembershipConfirmation`, `TerminatedEnrollment`) had no reader but `EnrollAutomatic` and its tests, so Task 4 removes them. |
| - **The provider boundary (§25).** Spec 2's core files import no `github.com/aws/...` package; AWS parts are adapters named for AWS; `region_ha_boundary_test.go` holds both. 2b builds no S3 client. |
| - **The status listing (T2).** Every replaced group box leaves a status report under its own reference, so `status/boxes/` keeps growing. `loadBoxesReport` (the reader behind `status.LoadBoxes` and `LoadBoxesComplete`) lists the prefix with each object's write time where the store gives one (`release.ModLister`: both real stores do), and drops reports no box has rewritten in a day before any read and before its 200-key cap, so the cap bounds current reports only. It is an optimisation, never authority: every reader still judges each report's own `GeneratedAt` as before (`BoxStale`, the console's `VerifyReport`), and a day keeps every box an operator would still look for as stale. A store without write times (a fleet's prefix store) is listed whole, as before. 2c adds a 30-day expiry on `status/boxes/` as hygiene; nothing depends on it, so a self-hosted store without lifecycle rules works the same. |
| -- **Copy.** Every string a person reads is a pending row in `docs/superpowers/plans/2026-10-17-region-ha-2b-copy.md`, gated by `approved_region_ha_2b_copy_test.go`. Tasks 2, 3, 5, 7 and 9 write rows and wait for Paul's approval. No em dashes. |
| +- **Copy.** Every string a person reads is a pending row in `docs/superpowers/plans/2026-10-17-region-ha-2b-copy.md`, gated by `approved_region_ha_2b_copy_test.go`. Tasks 2, 3, 7 and 9 write rows and wait for Paul's approval. No em dashes. |
| - **Go commands:** `export GOFLAGS=-buildvcs=false`, with `GOCACHE` left at its default (`~/.cache/go-build`). Run tests with `nice -n 19` and a `-run` filter; never the whole `internal/host` suite (CI's `make test` is the full run). Tests that bind unix sockets fail inside the sandbox ("socket: operation not permitted"); that is the sandbox, not the change. A sandbox that cannot write the default cache needs the go commands run outside it, not a cache under `$TMPDIR`. |
| - **Census:** every task that changes a file under a package directory re-pins that directory's `@coverage` row in `internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv`, loaded last. Each task runs all three censuses: the record-write census (`TestPlan9B8RecordWriteCensus`), the Plan 14 root-read census (`TestPlan14RootReadCensus`) and the console claim-authority source census (`TestClaimAuthoritySourceCensus`). |
| |
| @@ -95,20 +103,24 @@ Spec 2 is four plans. This is **2b**. What 2a left it, and what it leaves the ot |
| 1. **Group references have shipped** (Astra round 2, #3). A release with all of 2b, Tasks 2, 4, 5, 6 and 8 in particular (the references, the status tick that publishes only under them, and the status listing that keeps replaced boxes' history from hiding the running ones), runs on every box of the region and on the console. Without them labels collide, and a region in its first build has no healthy target, so its group never replaces the box that cannot publish (spec §5's first-build exception); an alarm reports that stall and does not repair it. They ship in 2b, so this gate is that release being rolled. |
| 2. **`pendingTime` is verified on dev** (Open question 7): verify on dev before the first region that a group box stopped and started is held and replaced, and that one rebooted hosts again. |
| |
| +**Activation note: detached instances are spec 3's.** 2b removes nothing from the roster. An instance detached from its group but still running keeps its enrolment until an operator removes it. Spec 3's requirements carry it: **the regional worker reconciles roster membership against Auto Scaling group membership, including detached-but-running instances** (spec §12, Open question 9). |
| + |
| ## Census items for platform-1e (census ruling region-ha-2b) |
| |
| -**RULED by the Plan 14 controller 2026-10-16 as census-ruling-region-ha-2b**, approved on conditions, all built here (round 3). The overlay's first line says so. One legacy-sealed unit changes, as ruled: `statusLoop`. Every other legacy-sealed unit 2b touches is called, never edited: `runAgent`, `edgesFileLoop`, `ExecutorChooser.eligible`, `stageCandidate`, `Directory.Invite`, `Directory.BeginEnrollmentAttempt`, `Directory.checkMembership`, `Directory.Replace`, `readIdentityRecord`, `status.CollectBox`, `status.BuildFleet` and `RunSystemGuardArm`. The gate sits in `RunAgent`, which is not a census unit, so `runAgent` keeps its digest. Rows that changed in round 3 are marked; their new values are platform-1e's to confirm. |
| +**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 4 are marked, with their old values; the new values, and the two new rows marked PROPOSED, are platform-1e's to confirm. The seal does not move in round 4. |
| |
| **Census rows.** |
| |
| | Task | Census | Unit | Class (ruled) | Basis, change and the ruling's conditions | Seal | |
| |---|---|---|---|---|---| |
| -| 2 | root-read | `internal/hostenv/cli.go:runHostEnv` | authority, A,J4 | New; **changed in round 3** (kinds `file:Lstat@1495,file:ReadFile@1930`, digest `b1cdca1b...`). `write` runs as root, takes only allowlisted names with safe values, refuses a directory that is a symlink or not root's (the new read site), and writes `/etc/carlos/host.env` 0600 whole; `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), authority-selecting values cross-checked against the roster at the agent's use (Task 6) | stands | |
| +| 2 | root-read | `internal/hostenv/cli.go:runHostEnv` | authority, A,J4 | New; **changed in round 4** (kinds `file:ReadFile@1500`, digest `df447017...`; were `file:Lstat@1495,file:ReadFile@1930`, `b1cdca1b...`). `write` runs as root, takes only allowlisted names with safe values, and hands the file to `writeHostEnv` at its fixed path, so its `Lstat` of the directory goes; `seed`'s read site is its `--from` file. Conditions: no box write to the parameters (2c seam 3), the allowlist and atomic, checked write (built, U3), authority-selecting values cross-checked against the roster at the agent's use (Task 6) | stands | |
| +| 2 | root-read | `internal/hostenv/write_linux.go:writeHostEnv` | **PROPOSED** authority, A,J4 (`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. Proposed under `runHostEnv`'s ruling, as the checked parent its condition 2 asks for | stands | |
| +| 2 | root-read | `internal/hostenv/write_linux.go:checkedDir` | **PROPOSED** authority, A,J4 (`PROPOSED-region-ha-2b-r4`) | **New in round 4** (kinds `file:Fstat@576,file:Openat@168,file:Openat@372`, digest `012257ac...`). Opens one directory under one already held, `O_NOFOLLOW\|O_DIRECTORY`, creating it 0755 first only when asked and missing, and refuses it unless `fstat` shows a directory owned by root (this process's user, which `write` requires to be root) that no group or other can write | stands | |
| | 3 | root-read | `internal/host/installchannel.go:channelInstall.run` | authority, F,J4 | New. Its one read site opens root's apply lock (`systemApplier.lockPath`); the install goes through `verifyPointer`, `hostfloor.CheckImage` and `stageCandidate`, unchanged; cache directories root-created, symlinks refused, as ruled | stands | |
| -| 4 | record-write (B8) | `internal/hostactors/membershipread.go:deadlineStore.Put` | nonrecord (`def:Put,sel:Put`) | New; **digest changed in round 3** (`e0f34b1d...`, was `eeb17218...`): a call that fails after its context ended is reported as `ErrWriteDeadline`. It forwards `Put` and refuses it at or after the deadline; it names no record key | n/a | |
| -| 5 | root-read | `internal/hostactors/autoenroll.go:AutoEnroller.inputs` | accepted-legacy, J4 root-input | New, unchanged in round 3. The signed roster and the host floor, in the console | moves | |
| -| 5 | claim-authority | Sites `enrollpass.go\|startEnrollPass\|newAutoEnroller(s.cfg.ClaimAuthority, members)`, `enrollpass.go\|startEnrollPass\|runEnrollPass(ctx, e, enrollPassInterval, log.Printf)`, `enrollpass.go\|runEnrollPass\|e.EnrollPass(ctx)` | reviewed authority sites | New, unchanged in round 3 | n/a | |
| -| 6 | root-read | `internal/host/agent.go:statusLoop` | accepted-legacy, re-sealed: `accepted-legacy;rev3=census-ruling-region-ha-2b` | **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 | |
| +| 4 | record-write (B8) | `internal/hostactors/membershipread.go:deadlineStore.Put` | nonrecord (`def:Put,sel:Put`) | New; **digest changed in round 4** (`848f2b52...`, was `e0f34b1d...`): it runs under `deadlineStore.call`, the 15-second hard timeout, and a success that arrives after the deadline is unknown. It forwards `Put` and refuses it at or after the deadline; it names no record key | n/a | |
| +| 5 | root-read | `internal/hostactors/autoenroll.go:AutoEnroller.inputs` | accepted-legacy, J4 root-input | New, unchanged in rounds 3 and 4 (U1 removed the read-back, not this unit). The signed roster and the host floor, in the console | moves | |
| +| 5 | claim-authority | Sites `enrollpass.go\|startEnrollPass\|newAutoEnroller(s.cfg.ClaimAuthority, members)`, `enrollpass.go\|startEnrollPass\|runEnrollPass(ctx, e, enrollPassInterval, log.Printf)`, `enrollpass.go\|runEnrollPass\|e.EnrollPass(ctx)` | reviewed authority sites | New, unchanged in rounds 3 and 4 | n/a | |
| +| 6 | root-read | `internal/host/agent.go:statusLoop` | accepted-legacy, re-sealed: `accepted-legacy;rev3=census-ruling-region-ha-2b` | Unchanged in round 4. Re-sealed again in round 3 (digest `666ddbc4...`, was `eb4850f8...`; kinds back to main's `whole:Route@6154,@6181,@7423,@9437`): main's body with one call changed, its tick through `groupStatusTick`. A box with the group marker publishes only under its group reference, resolved each tick, and nothing until it resolves; every other box gets `statusTick` with today's label and key. The seal moves to `fc584e11...` (was `78fb616c...`) | moves | |
| | 7 | root-read | `internal/host/units/carlos-system-join.path` and `carlos-system-join.service` (`external`) | authority, F,J4 | New, unchanged files. Condition: the path unit's file is only a trigger; root's join reads nothing from it and takes no parameter from it, and decides from the signed invitation, the published record, the signed floor and IMDS (tested) | stands | |
| | 9 | root-read | `system.go:runSystem`, `system.go:systemVerb` | authority, B,C,D,F,J4 (seed overlay, kept) | Re-digest only: three verbs added; class, basis and (empty) kinds unchanged | stands | |
| |
| @@ -116,12 +128,12 @@ Spec 2 is four plans. This is **2b**. What 2a left it, and what it leaves the ot |
| |
| | Task | Unit | Change | Why no census row | |
| |---|---|---|---| |
| -| 2 | `internal/hostenv` (`Fetch`, `Assemble`, `Seed`, `sameShared`, `Allowed`, `notAllowed`, `ownedByEUID`) | The group-box marker; create-only shared values; the allowlist; the owner check | No root-read site; `SetNew` and `Set` are not `Put` | |
| +| 2 | `internal/hostenv` (`Fetch`, `Assemble`, `Seed`, `sameShared`, `Allowed`, `notAllowed`; `write_other.go:writeHostEnv`) | The group-box marker; create-only shared values; the allowlist; off Linux, `write` refuses | No root-read site; `SetNew` and `Set` are not `Put`. Round 4 removed `ownedByEUID` with its files | |
| | 3 | `channelInstall.cacheDir`, `ownByService`, `ownDir` | Every cache parent made and handed to the service user, each run; symlinks refused | They call `bootstrapMakeDir`, `bootstrapOpenDir` and `bootstrapOwner` (main's, unchanged) | |
| -| 4 | `Directory.EnrollAutomatic`, `CheckConfirmed`, `checkConfirmed`, `confirmMembership`, `writeBound`, `inFlight`, `deadlineStore`'s other methods | The console's own read; the write bound, the single enforcement point; the group-reference check | Not root-read units; `deadlineStore.Put` is the B8 row above. `Directory.enroll` is main's again (round 3) | |
| +| 4 | `Directory.EnrollAutomatic`, `CheckConfirmed`, `checkConfirmed`, `confirmMembership`, `writeBound`, `deadlineStore.call`, `deadlineStore`'s other methods | The console's own read; the write bound, the single enforcement point, with each call's 15-second hard timeout; the group-reference check | Not root-read units; `deadlineStore.Put` is the B8 row above. `Directory.enroll` is main's again (round 3) | |
| | 4 | `Directory.PrivateBoxReport` (`private.go`) | A group box's report is read under the reference derived from its pinned instance | Not a census unit. It feeds `VerifyReport` (unchanged) for `checkMembership`, `Census` and activation; non-group boxes unchanged | |
| | 4 | `GroupBoxReference` (`groupref.go`) | New: the reference function | No I/O | |
| -| 5 | `AutoEnroller.EnrollPass`, `step`, `confirmAndWrite`, `readBack`, `pageNotMember`, `logRead`, `logOutcome` | The pass, its write bound, its reference check, its read-back of unknown enrolments, its page and log lines | Not root-read units; its construction and invocation are the claim-authority sites above | |
| +| 5 | `AutoEnroller.EnrollPass`, `step`, `confirmAndWrite`, `logRead`, `logOutcome` | The pass, its write bound, its reference check and its log lines (round 4 removed `readBack` and `pageNotMember`) | Not root-read units; its construction and invocation are the claim-authority sites above | |
| | 6 | `metricsBoxLabel`, `boxRecordLoop`, `productionModeFacts`, `crossCheck`, `groupReferenced`, `groupBoxReference`, `groupStatusTick` | A group box names itself by its group reference and publishes status only under it; the marker and region cross-checked against the roster | Not census units; `statusLoop` is the row above, and `statusTick` (sealed) is called unchanged | |
| | 6 | `internal/status/fleet.go:loadBoxesReport`, `listBoxKeys` | Reports no box has rewritten in a day dropped from the listing before the key cap | Not census units (no read site of the census's kinds); `BuildFleet` (sealed) is unchanged and calls it as before | |
| | 8 | `productionJoining`'s report | The guard-ready report under the group reference | Not a census unit | |
| @@ -181,7 +193,7 @@ nice -n 19 go test ./internal/console/ -run 'TestClaimAuthoritySourceCensus' -co |
| 3. **The health answer must drop when the claim worker stops.** Task 8's `TestClaimWiringHostingFollowsTheModeAndTheWorker`. |
| 4. **No enrolment without the guard-ready report.** Task 5's `TestAutomaticEnrollmentFromRecordToRoster` holds an invitation and an unready report together and expects no enrolment and no membership read; with the `VerifyReport` gate removed the pass enrolls and the test fails. |
| 5. **Only the console's own read makes a box a member (H28, C1).** Task 4's `TestEnrollAutomaticReadsMembershipItself`, `TestEnrollAutomaticRefusesANonMember` and `TestNoExportedPathTakesAConfirmation`, and Task 5's `TestAnInstanceOutsideTheGroupIsNeverInvitedOrEnrolled`: an instance in the deployment's account that the cloud places in another group, or none, is never invited or enrolled however long it waits, and one that leaves the group after its invitation is not enrolled on its guard-ready report. |
| -6. **No store call of a write is issued 60 seconds or more after its read (C2, S1, T3).** Task 4's `TestEnrollAutomaticRefusesAReadOlderThanAMinuteAtTheWrite` (a describe of exactly 60 seconds, and a read that reaches its deadline before the roster CAS), and Task 5's `TestAnInvitationNeedsAReadUnderAMinuteOldAtTheWrite`, `TestASlowReadInsideInviteWritesNothing` and `TestASlowReadInsideTheRosterCheckWritesNothing` (a bucket read inside the sealed `Invite` or `checkMembership` that answers exactly at the deadline leaves no `Put` issued), and `TestAPutInFlightAtTheDeadlineIsUnknown` (a `Put` still in flight at the deadline is cancelled; the outcome is unknown, the attempt and its wait stand, and a write that did land is read back). |
| +6. **No store call of a write is issued 60 seconds or more after its read (C2, S1, T3).** Task 4's `TestEnrollAutomaticRefusesAReadOlderThanAMinuteAtTheWrite` (a describe of exactly 60 seconds, and a read that reaches its deadline before the roster CAS), and Task 5's `TestAnInvitationNeedsAReadUnderAMinuteOldAtTheWrite`, `TestASlowReadInsideInviteWritesNothing` and `TestASlowReadInsideTheRosterCheckWritesNothing` (a bucket read inside the sealed `Invite` or `checkMembership` that answers exactly at the deadline leaves no `Put` issued), and `TestAPutInFlightAtTheDeadlineIsUnknown` (a `Put` still in flight at the deadline is cancelled; the outcome is unknown, the attempt and its wait stand, and a write that did land is found by the next pass's ordinary read). |
| 7. **A failed read is unknown (#7).** Task 4's `TestAMembershipReadThatIsNotAnAnswerIsUnknown` and Task 5's `TestAMembershipReadThatFailsKeepsTheAttempt`: ten hours of failed reads keep the same attempt and the same wait. |
| 8. **Root arms the guard only for this box's own invitation and record.** Task 7's `TestRootJoinRefusesAnInvitationThatIsNotThisBoxs` (another instance, another record, another release key, another deployment, an unreadable store). |
| 9. **Both slots hold the channel's artifact, nothing below the floor is installed, and the agent can still update.** Task 3's tests; a store that cannot be read is a refusal, never "no floor"; the directories install-channel creates go to the service user, and the agent then downloads and requests the next release. |
| @@ -192,14 +204,14 @@ nice -n 19 go test ./internal/console/ -run 'TestClaimAuthoritySourceCensus' -co |
| 14. **Ownership is reconciled on a retry (S2).** Task 3's `TestInstallChannelReconcilesOwnershipOnARetry` and `TestInstallChannelRefusesASymlinkedParent`. |
| 15. **A group box never publishes status under a key it doesn't own (T1).** Task 6's `TestAGroupBoxPublishesOnlyUnderItsReference`, through `statusLoop`'s own tick and `WriteBox`: while IMDS fails, another box's report under the colliding label is untouched and nothing is written, with the switches on or off; the reference is asked again each tick, and on recovery the box publishes under it. `TestABoxThatIsNotAGroupBoxKeepsItsLabelAndKey` compares every other box's keys with `statusTick`'s. |
| 16. **Replaced boxes' history never hides the running ones (T2).** Task 6's `TestStaleReportsDoNotHideLiveBoxes`: 250 day-old reports and two live boxes: both live boxes found, and the reading complete. |
| -17. **An unknown enrolment is accepted only on a fresh read (the claim owner's ruling).** Task 5's `TestAnEnrolmentLostToTheDeadlineIsReadBack`: landed and still a member, logged with its ids; landed and no longer a member, paged once; not landed, the attempt kept and enrolled next pass. |
| -18. **host.env takes nothing that could change a box's authority unchecked (runHostEnv conditions).** Task 2's `TestWriteTakesOnlyAllowedNamesAndSafeValues` and `TestWriteRefusesASymlinkedDirectory`; Task 6's `TestSelectModeHoldsAStoreValueTheRosterDisagreesWith`; Task 7's `TestRootJoinTakesNothingFromItsTrigger`. |
| +17. **No store call outlives its bound, and an unknown outcome is settled by the roster (U1, U2).** Task 5's `TestAStoreThatIgnoresItsContextIsCutOffAtTheHardTimeout`: a store that ignores its context and never answers is cut off at the hard timeout, the outcome unknown, the attempt and its wait unchanged. `TestAnUnknownEnrolmentIsSettledByTheRoster`: an enrolling write that did not land goes on with the same attempt and enrols next pass; one that landed, whether cut off in flight or answering success only after the deadline, is unknown, and the next pass, reading the roster, has nothing left to do: no step and no describe. |
| +18. **host.env takes nothing that could change a box's authority unchecked (runHostEnv conditions, U3).** Task 2's `TestWriteTakesOnlyAllowedNamesAndSafeValues`, `TestHostEnvCommand` (no flag moves the path) and `TestWriteGoesOnlyThroughDirectoriesRootAloneCanWrite` (a group-writable `etc/carlos`, a world-writable `etc`, an `etc/carlos` another user owns, a symlinked `etc/carlos` and a symlinked `etc` each refused with nothing written; a normal write lands 0600, replaces the old file by rename, never in place, and leaves no temporary file); Task 6's `TestSelectModeHoldsAStoreValueTheRosterDisagreesWith`; Task 7's `TestRootJoinTakesNothingFromItsTrigger`. |
| |
| ## Dry run |
| |
| -Every code block below was written into this branch on main `6834659c` (plan 2a merged), built for linux, darwin and windows, vetted for linux and darwin, 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 (20 after Task 2, 67 after Task 9). Through `go test -overlay`, with a scratch record that marks every row approved, the applied check passes for all 67 rows, and fails for a row whose text was edited. |
| +Every code block below was written into this branch on main `6834659c` (plan 2a merged), built for linux, darwin and windows, vetted for linux and darwin, and its tests run as each task's steps say, with `nice -n 19` and a `-run` filter; the whole `internal/host` suite was never run. The plan was then replayed from a clean base, task by task in order, by applying its own code blocks: every intermediate state built on all three platforms, vetted, passed its task's tests and all three censuses, and the census values here are those runs'. The copy gate was checked without approving anything: with every row pending, `TestRegionHA2bCopyRecordIsComplete`, `TestRegionHA2bCopyHasNoEmDash` and `TestRegionHA2bBuiltCopyIsApplied` pass, and `TestRegionHA2bBuiltCopyIsApproved` fails naming exactly the built ids (21 after Task 2, 66 after Task 9). With a scratch record that marks every row approved, the applied check passes for all 66 rows, and fails for a row whose text was edited. |
| |
| -Rounds 1, 2 and 3 were dry-run the same way, each from a clean base for every task. Round 3's mutations were all run through `go test -overlay` from `$TMPDIR`, never editing source in place, and every one of the 41 was caught by the test named. Kept from rounds 1 and 2: 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. New in round 3: a group box's status tick falling back to its label (`TestAGroupBoxPublishesOnlyUnderItsReference`), or resolving its reference once; the status listing ignoring write times, or keeping history (`TestStaleReportsDoNotHideLiveBoxes`); the bound with no deadline context (`TestAPutInFlightAtTheDeadlineIsUnknown`, which then hangs and times out); an unknown enrolment not remembered, accepted without a fresh describe, or paged every pass (`TestAnEnrolmentLostToTheDeadlineIsReadBack`); a read of exactly 60 seconds taken as fresh, and a store call exactly at the deadline issued (`TestEnrollAutomaticRefusesAReadOlderThanAMinuteAtTheWrite`, `TestASlowRead...`); no allowlist, and no check of host.env's directory (`TestWriteTakesOnlyAllowedNamesAndSafeValues`, `TestWriteRefusesASymlinkedDirectory`); no cross-check against the roster (`TestSelectModeHoldsAStoreValueTheRosterDisagreesWith`); and root's join reading its trigger (`TestRootJoinTakesNothingFromItsTrigger`). The claim-authority census reads source with `go/parser`, which an overlay does not reach, so 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. `internal/hostenv`, `internal/hostactors/...` and `internal/status` passed in full on the final tree, and the console's enrolment, capacity, no-capacity and authority-census tests on their own. The code was then removed, leaving the plan, its copy record and the spec. |
| +Rounds 1 to 4 were dry-run the same way, each from a clean base for every task. Round 4's mutations were all run through `go test -overlay` from `$TMPDIR`, never editing source in place, and every one of the 51 was caught by the test named. Kept from rounds 1 to 3 and run again on round 4's code (37): 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. Round 3's three read-back mutations went with the read-back, and its directory-check mutation with `--out`. New in round 4 (14): no hard per-call timeout (`TestAStoreThatIgnoresItsContextIsCutOffAtTheHardTimeout`, which then hangs and times out); a success after the deadline accepted, an unanswered call reported as success, and a call cut off taken at the store's word (`TestAnUnknownEnrolmentIsSettledByTheRoster`, `TestAPutInFlightAtTheDeadlineIsUnknown`); an unknown counted against the attempt, or the attempt forgotten so a fresh one begins with its wait reset (`TestAnUnknownEnrolmentIsSettledByTheRoster`); 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 (`TestWriteGoesOnlyThroughDirectoriesRootAloneCanWrite`, `TestHostEnvCommand`). 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 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. With a scratch record marking every row approved, the copy gate passed, and went red with RHB-C67's text edited. `internal/hostenv`, `internal/hostactors/...` and `internal/status` passed in full on the final tree, the three timeout and unknown-outcome 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. |
| |
| --- |
| |
| @@ -211,9 +223,9 @@ Rounds 1, 2 and 3 were dry-run the same way, each from a clean base for every ta |
| |
| **Interfaces:** |
| - Consumes: the run and moves gates' helpers at the repo root (`parseCopyTable`, `checkMovesInventory`, `checkMovesNoEmDash`, `checkRunBuilt`, `checkRunApplied`, `builtRows`, `reportAll`, `copyRow`), as plan 2a's gate does. |
| -- Produces: `regionHA2bCopyBuilt []string`, which Tasks 2, 3, 5, 7 and 9 extend; the record's ids `RHB-C1` to `RHB-C67` (RHB-C62 to RHB-C64 were added in review round 1 and RHB-C65 to RHB-C67 in round 3, each in its task's table). |
| +- Produces: `regionHA2bCopyBuilt []string`, which Tasks 2, 3, 7 and 9 extend; the record's ids `RHB-C1` to `RHB-C64`, `RHB-C67` and `RHB-C68` (RHB-C62 to RHB-C64 were added in review round 1, RHB-C67 in round 3 and RHB-C68 in round 4, each in its task's table; RHB-C65 and RHB-C66, round 3's operator alert, were withdrawn in round 4 with the read-back, and their ids are not reused). |
| |
| -Every user-facing string 2b writes is drafted here first, all `pending`: the output and refusals of the three new commands, host.env's first line, the operator alert the console's enrolment pass sends, the descriptions systemd shows for the two join units, the `carlos system` usage lines and the docs. 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. |
| +Every user-facing string 2b writes is drafted here first, all `pending`: the output and refusals of the three new commands, host.env's first line, the descriptions systemd shows for the two join units, the `carlos system` usage lines and the docs. Log lines are not rows (2a's rule). The gate is plan 2a's over this record: an id in `regionHA2bCopyBuilt` must be an approved row whose text is in the file it names. |
| |
| - [ ] **Step 1: Write the record** |
| |
| @@ -240,7 +252,7 @@ The rules: |
| - No pending string reaches code, CLI help or docs. The task that writes |
| a row's text adds its id to `regionHA2bCopyBuilt` in the gate in the |
| same commit, and from then on the gate fails unless the row is |
| - `approved` and its text is in the file it names. Tasks 2, 3, 5, 7 and 9 |
| + `approved` and its text is in the file it names. Tasks 2, 3, 7 and 9 |
| therefore wait for their rows' approval. |
| - In a Go file the text is one string literal, or one `fmt.Sprintf`, |
| `fmt.Errorf` or `errors.New` format, equal to the row with a format |
| @@ -252,7 +264,9 @@ The rules: |
| console enrollment pass's. |
| - Ids are not renumbered: a row added in review takes the next free id |
| and goes in its task's table (RHB-C62 to RHB-C64, round 1; RHB-C65 to |
| - RHB-C67, round 3). |
| + RHB-C67, round 3; RHB-C68, round 4). A withdrawn row's id is not |
| + reused: RHB-C65 and RHB-C66, the operator alert of round 3, went with |
| + the read-back they belonged to in round 4. |
| - No em dashes. A full stop and a new sentence instead. Refusals start |
| lower case, as the CLI's others do. |
| |
| @@ -263,8 +277,7 @@ file; `<region>` a placement code; `<deployment>` a deployment's name; |
| variable names; `<variable>` and `<value>` a variable and its value; |
| `<version>` a platform release such as v0.31.0; `<tag>` its release tag; |
| `<release>` the host floor's minimum release; `<channel>` a channel such |
| -as stable; `<platform>` such as linux-arm64; `<box>` a box's reference; |
| -`<instance>` an EC2 instance id; `<group>` an Auto Scaling group's name. |
| +as stable; `<platform>` such as linux-arm64; `<box>` a box's reference. |
| |
| ## host.env from the deployment's parameters (Task 2) |
| |
| @@ -289,7 +302,8 @@ as stable; `<platform>` such as linux-arm64; `<box>` a box's reference; |
| | RHB-C17 | internal/hostenv/cli.go | the parameters were not all written: <reason> | pending | |
| | RHB-C18 | internal/hostenv/cli.go | wrote <n> parameters under <prefix> and <n> under <prefix> | pending | |
| | RHB-C64 | internal/hostenv/hostenv.go | already set by another seed | pending | |
| -| RHB-C67 | internal/hostenv/cli.go | <path> is not a directory root owns, so host.env was not written | pending | |
| +| RHB-C67 | internal/hostenv/write_linux.go | <path> is not a directory only root can write | pending | |
| +| RHB-C68 | internal/hostenv/write_other.go | carlos system host-env write runs only on Linux | pending | |
| |
| ## carlos system install-channel (Task 3) |
| |
| @@ -317,13 +331,6 @@ as stable; `<platform>` such as linux-arm64; `<box>` a box's reference; |
| | RHB-C38 | internal/host/installchannel.go | the release identity for <tag> could not be read, so nothing was installed: <reason> | pending | |
| | RHB-C39 | internal/host/installchannel.go | <version> (<tag>) is below the host floor <release> or is not the release its identity names, so nothing was installed | pending | |
| |
| -## The console's enrolment pass (Task 5) |
| - |
| -| id | file | text | status | |
| -|---|---|---|---| |
| -| RHB-C65 | internal/hostactors/autoenroll.go (the operator alert's subject) | Region HA: <box> was enrolled, but its group no longer lists it | pending | |
| -| RHB-C66 | internal/hostactors/autoenroll.go (the operator alert's body) | The console enrolled <box> (instance <instance>) from the group <group>, but the enrolling write's result came back after its deadline, and a fresh check finds the instance is no longer a current member of that group: <reason>. Nothing more is done automatically. A terminated instance is fenced and leaves placement on its own. One that left the group but still runs keeps its enrolment until an operator removes it. | pending | |
| - |
| ## carlos system join (Task 7) |
| |
| | id | file | text | status | |
| @@ -379,8 +386,8 @@ import ( |
| // format, read with the moves gate's parser and checked with the run |
| // gate's helpers, so "approved" and "applied" mean what they mean there: |
| // |
| -// - TestRegionHA2bCopyRecordIsComplete: the record names exactly RHB-C1 |
| -// to RHB-C67; |
| +// - TestRegionHA2bCopyRecordIsComplete: the record names exactly |
| +// regionHA2bCopyIDs; |
| // - TestRegionHA2bCopyHasNoEmDash; |
| // - TestRegionHA2bBuiltCopyIsApproved: an id in regionHA2bCopyBuilt is an |
| // approved row. No pending string reaches code, CLI help or docs; |
| @@ -390,13 +397,15 @@ const regionHA2bCopyRecordPath = "docs/superpowers/plans/2026-10-17-region-ha-2b |
| |
| var regionHA2bCopyIDPattern = regexp.MustCompile(`^RHB-C[0-9]+$`) |
| |
| -// regionHA2bCopyIDs is the record's inventory: RHB-C1 to RHB-C67, pinned. |
| +// regionHA2bCopyIDs is the record's inventory, pinned: RHB-C1 to RHB-C64, |
| +// RHB-C67 and RHB-C68. RHB-C65 and RHB-C66 were withdrawn in review and |
| +// are not reused. |
| var regionHA2bCopyIDs = func() []string { |
| - ids := make([]string, 0, 67) |
| - for i := 1; i <= 67; i++ { |
| + ids := make([]string, 0, 66) |
| + for i := 1; i <= 64; i++ { |
| ids = append(ids, "RHB-C"+strconv.Itoa(i)) |
| } |
| - return ids |
| + return append(ids, "RHB-C67", "RHB-C68") |
| }() |
| |
| // regionHA2bCopyBuilt is every id whose text the code or docs carry. The |
| @@ -441,7 +450,7 @@ Expected: PASS (nothing is built yet). |
| |
| - [ ] **Step 4: Put the record in front of Paul** |
| |
| -Run the `copy-review` skill over the record's rows. Apply his edits verbatim to the record, mark each row he approves `approved`, and commit that on its own. Tasks 2, 3, 5, 7 and 9 wire their rows only once they are approved, with Paul's text. Tasks 4, 6 and 8 do not wait. |
| +Run the `copy-review` skill over the record's rows. Apply his edits verbatim to the record, mark each row he approves `approved`, and commit that on its own. Tasks 2, 3, 7 and 9 wire their rows only once they are approved, with Paul's text. Tasks 4, 5, 6 and 8 do not wait. |
| |
| - [ ] **Step 5: Commit** |
| |
| @@ -457,18 +466,18 @@ No package envelope changes: neither file is a non-test Go file. |
| ### Task 2: host.env from the deployment's parameters (`internal/hostenv`) |
| |
| **Files:** |
| -- Create: `internal/hostenv/hostenv.go`, `internal/hostenv/cli.go`, `internal/hostenv/owner_unix.go`, `internal/hostenv/owner_other.go`, `internal/hostenv/awsssm.go`, `internal/hostenv/hostenv_test.go` |
| +- Create: `internal/hostenv/hostenv.go`, `internal/hostenv/cli.go`, `internal/hostenv/write_linux.go`, `internal/hostenv/write_other.go`, `internal/hostenv/awsssm.go`, `internal/hostenv/hostenv_test.go`, `internal/hostenv/write_linux_test.go`, `internal/hostenv/write_other_test.go` |
| - Modify: `go.mod`, `go.sum` (`github.com/aws/aws-sdk-go-v2/service/ssm v1.74.0`) |
| - Modify: `region_ha_boundary_test.go`, `approved_region_ha_2b_copy_test.go` |
| - Create: `internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv`; Modify: `internal/provision/plan14_root_reads_test.go` (load it last) |
| |
| **Interfaces:** |
| -- Consumes: `hostfloor.Durable` (the guard's whole-or-nothing write). |
| -- Produces (`internal/hostenv`): `const Path = "/etc/carlos/host.env"`, `const SharedScope = "shared"`; `const BoxReference = "CARLOS_BOX_REFERENCE"`, `const GroupReference = "group"` (Task 6 reads them); `var Required, Optional, SharedKeys []string`; `Allowed(name string) bool`; `type Params interface { ByPath(ctx, prefix string) (map[string]string, error); Set(ctx, name, value string) error; SetNew(ctx, name, value string) error }`; `ErrMissing`, `ErrConflict`, `ErrValue`, `ErrExists`; `Prefix(deployment, scope string) (string, error)`; `ValidKey(string) bool`; `Assemble(shared, regional map[string]string) (map[string]string, error)`; `Missing(map[string]string) []string`; `Render(map[string]string) ([]byte, error)`; `Parse([]byte) (map[string]string, error)`; `Split(map[string]string) (shared, regional map[string]string)`; `Fetch(ctx, Params, deployment, region string) ([]byte, error)`; `type Seeded`; `Seed(ctx, Params, deployment, region string, env map[string]string) (Seeded, error)`; `RunSystemHostEnv([]string)` (wired into `carlos system` in Task 9). |
| +- Consumes: `golang.org/x/sys/unix` (already required) for the descriptor-held write. |
| +- Produces (`internal/hostenv`): `const Path = "/etc/carlos/host.env"`, `const SharedScope = "shared"`; `const BoxReference = "CARLOS_BOX_REFERENCE"`, `const GroupReference = "group"` (Task 6 reads them); `var Required, Optional, SharedKeys []string`; `Allowed(name string) bool`; `type Params interface { ByPath(ctx, prefix string) (map[string]string, error); Set(ctx, name, value string) error; SetNew(ctx, name, value string) error }`; `ErrMissing`, `ErrConflict`, `ErrValue`, `ErrExists`; `Prefix(deployment, scope string) (string, error)`; `ValidKey(string) bool`; `Assemble(shared, regional map[string]string) (map[string]string, error)`; `Missing(map[string]string) []string`; `Render(map[string]string) ([]byte, error)`; `Parse([]byte) (map[string]string, error)`; `Split(map[string]string) (shared, regional map[string]string)`; `Fetch(ctx, Params, deployment, region string) ([]byte, error)`; `type Seeded`; `Seed(ctx, Params, deployment, region string, env map[string]string) (Seeded, error)`; `RunSystemHostEnv([]string)` (wired into `carlos system` in Task 9). Unexported: `writeHostEnv(root string, body []byte) error` and `checkedDir` (Linux, `write_linux.go`; elsewhere `writeHostEnv` refuses, `write_other.go`); the test seams `var hostEnvRoot = "/"` (the root the fixed path is under) and `var hostEnvOwner = os.Geteuid`. |
| - Produces (adapter, `awsssm.go`): `openSSMParams(ctx, awsRegion, kmsKey string) (Params, error)` over SSM `GetParametersByPath` (decrypted, paged; any failed page fails the read) and `PutParameter` (SecureString, the deployment's KMS key): `Set` with `Overwrite: true`, `SetNew` with `Overwrite: false`, SSM's `ParameterAlreadyExists` mapped to `ErrExists`. |
| - Produces: the census overlay every later task edits, and its load in `TestPlan14RootReadCensus`. |
| |
| -This task writes rows RHB-C1 to RHB-C18, RHB-C64 and RHB-C67 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. |
| +This task writes rows RHB-C1 to RHB-C18, RHB-C64, RHB-C67 and RHB-C68 of the copy record. Build it only once Paul has approved them, and where his text differs from a draft, use his in `internal/hostenv` too: the strings in the code below are the drafts. Adding the ids to `regionHA2bCopyBuilt` makes the gate hold the code to the approved text. |
| |
| The core treats host.env as a file and names no provider (§25): `Fetch` assembles it from a `Params`, `Seed` writes a box's existing file into one. The SSM adapter is the only file that imports AWS. The parameter store's write methods are `Set` and `SetNew`, not `Put`, so the record-write census (which matches the name `Put`) has nothing to say about them; `SetNew` is not `Create` either, which the root-read census reads as a file open. |
| |
| @@ -478,7 +487,7 @@ Shared values are written create-only (R4). Two seeds can both read a shared var |
| |
| **The ruling's conditions on `runHostEnv` (census ruling region-ha-2b).** |
| 1. No box writes the parameters it reads: the box role has no `ssm:PutParameter` or `ssm:DeleteParameter` on either path; only the operator's (and the console's) principal writes. That is 2c's to grant and to prove with a negative check (2c's seam 3). |
| -2. `write` takes only allowlisted names (`Required` and `Optional`; `Allowed`): any other name, under either scope, is refused by name, and `seed` refuses a file that carries one. Values are restricted to the safe set, so a newline, a carriage return, a NUL, whitespace, quotes or a `$` (anything that could start a second key or change how systemd reads the line) is refused, naming the variable and never the value. The file is written whole (`hostfloor.Durable`: a temporary file beside it, fsync, rename, the directory synced), 0600, by root, and only into a directory that is a real directory root owns: `write` refuses a symlink or one another user owns (RHB-C67). |
| +2. `write` takes only allowlisted names (`Required` and `Optional`; `Allowed`): any other name, under either scope, is refused by name, and `seed` refuses a file that carries one. Values are restricted to the safe set, so a newline, a carriage return, a NUL, whitespace, quotes or a `$` (anything that could start a second key or change how systemd reads the line) is refused, naming the variable and never the value. The path is fixed at `/etc/carlos/host.env` (U3): `write` has no `--out`, and tests reach the code through the unexported `hostEnvRoot`, never a flag. `writeHostEnv` holds `/etc` and then `/etc/carlos` by descriptor, each opened `O_NOFOLLOW|O_DIRECTORY` under the one before (`checkedDir`), so a symlink at either is refused and neither is looked up by name again; each must be a directory, owned by root (`write` already refuses any other user), and writable by no group or other, or `write` refuses it (RHB-C67). A missing `/etc/carlos` is created 0755, then opened and checked like the rest. The file is created 0600, `O_EXCL|O_NOFOLLOW`, under a random name inside the held directory (`openat`), written and fsynced, renamed over `host.env` within the same directory, and the directory fsynced: a reader sees the old file or the new one, never part of either, and no other user can swap the file or the directory under it. The descriptor calls are Linux's (`//go:build linux`); elsewhere `writeHostEnv` refuses (`write_other.go`, RHB-C68), so darwin and windows build. |
| 3. The values that select authority, the group marker and `CARLOS_REGION`, are cross-checked against the signed roster at the agent's use, in mode selection (Task 6), not here: at boot there is no roster entry for the box to check against, and before enrolment neither value grants anything. Keys that would select authority with no roster to check them by are kept off the allowlist (fleet and tunnel identity, address overrides, pools, directories, relay settings, tokens). |
| |
| - [ ] **Step 1: Write the failing tests** |
| @@ -495,6 +504,7 @@ import ( |
| "os" |
| "path/filepath" |
| "reflect" |
| + "runtime" |
| "slices" |
| "strings" |
| "testing" |
| @@ -745,12 +755,12 @@ func TestPrefixAndParse(t *testing.T) { |
| // assembly; with root it writes 0600; seed reads the named file. |
| func TestHostEnvCommand(t *testing.T) { |
| ctx := context.Background() |
| - dir := t.TempDir() |
| + dir := withHostEnvRoot(t) |
| out := filepath.Join(dir, "etc", "carlos", "host.env") |
| p := &fakeParams{vals: map[string]string{}} |
| open := func(context.Context, string, string) (Params, error) { return p, nil } |
| var stdout bytes.Buffer |
| - args := []string{"write", "--deployment", "flagship", "--region", "ie", "--out", out} |
| + args := []string{"write", "--deployment", "flagship", "--region", "ie"} |
| if err := runHostEnv(ctx, args, &stdout, 1000, open); err == nil || !strings.Contains(err.Error(), "root") { |
| t.Fatalf("non-root write: %v", err) |
| } |
| @@ -769,17 +779,28 @@ func TestHostEnvCommand(t *testing.T) { |
| if err := runHostEnv(ctx, seed, &stdout, 1000, open); err != nil { |
| t.Fatal(err) |
| } |
| - if err := runHostEnv(ctx, args, &stdout, 0, open); err != nil { |
| - t.Fatal(err) |
| - } |
| - fi, err := os.Stat(out) |
| - if err != nil || fi.Mode().Perm() != 0o600 { |
| - t.Fatalf("host.env: %v %v", fi, err) |
| - } |
| - if !strings.Contains(stdout.String(), "wrote 7 parameters under /carlos/flagship/ie/ and 5 under /carlos/flagship/shared/\n") || !strings.Contains(stdout.String(), "wrote "+out+" from the parameters for ie\n") { |
| + if !strings.Contains(stdout.String(), "wrote 7 parameters under /carlos/flagship/ie/ and 5 under /carlos/flagship/shared/\n") { |
| t.Fatalf("output %q", stdout.String()) |
| } |
| - for _, bad := range [][]string{nil, {"seed", "--deployment", "flagship", "--region", "ie"}, {"write", "--region", "ie"}, {"read"}} { |
| + err := runHostEnv(ctx, args, &stdout, 0, open) |
| + if runtime.GOOS != "linux" { |
| + if err == nil || !strings.Contains(err.Error(), "runs only on Linux") { |
| + t.Fatalf("write off Linux: %v", err) |
| + } |
| + } else { |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + fi, err := os.Stat(out) |
| + if err != nil || fi.Mode().Perm() != 0o600 { |
| + t.Fatalf("host.env: %v %v", fi, err) |
| + } |
| + if !strings.Contains(stdout.String(), "wrote "+out+" from the parameters for ie\n") { |
| + t.Fatalf("output %q", stdout.String()) |
| + } |
| + } |
| + // The path is fixed: there is no flag to move it. |
| + for _, bad := range [][]string{nil, {"seed", "--deployment", "flagship", "--region", "ie"}, {"write", "--region", "ie"}, {"read"}, append(args, "--out", filepath.Join(dir, "elsewhere"))} { |
| if err := runHostEnv(ctx, bad, &stdout, 0, open); err == nil || !strings.HasPrefix(err.Error(), "usage: carlos system host-env") { |
| t.Fatalf("%v: %v", bad, err) |
| } |
| @@ -823,25 +844,163 @@ func TestWriteTakesOnlyAllowedNamesAndSafeValues(t *testing.T) { |
| } |
| } |
| |
| -func TestWriteRefusesASymlinkedDirectory(t *testing.T) { |
| - ctx := context.Background() |
| - p := &fakeParams{vals: map[string]string{}} |
| - if _, err := Seed(ctx, p, "flagship", "ie", today()); err != nil { |
| +// withHostEnvRoot points write's fixed path at a directory of the test's |
| +// own, with an etc in it as a box has one. |
| +func withHostEnvRoot(t *testing.T) string { |
| + t.Helper() |
| + root := t.TempDir() |
| + if err := os.Mkdir(filepath.Join(root, "etc"), 0o755); err != nil { |
| t.Fatal(err) |
| } |
| - open := func(context.Context, string, string) (Params, error) { return p, nil } |
| - root := t.TempDir() |
| - elsewhere := t.TempDir() |
| - if err := os.Symlink(elsewhere, filepath.Join(root, "carlos")); err != nil { |
| + if err := os.Chmod(filepath.Join(root, "etc"), 0o755); err != nil { |
| t.Fatal(err) |
| } |
| - var out bytes.Buffer |
| - err := runHostEnv(ctx, []string{"write", "--deployment", "flagship", "--region", "ie", "--out", filepath.Join(root, "carlos", "host.env")}, &out, 0, open) |
| - if err == nil || !strings.Contains(err.Error(), "is not a directory root owns") { |
| - t.Fatalf("symlinked directory: %v", err) |
| + was := hostEnvRoot |
| + hostEnvRoot = root |
| + t.Cleanup(func() { hostEnvRoot = was }) |
| + return root |
| +} |
| +```` |
| + |
| +The checked write's own tests, Linux's and the refusal elsewhere: |
| + |
| +`internal/hostenv/write_linux_test.go`: |
| + |
| +````go |
| +//go:build linux |
| + |
| +package hostenv |
| + |
| +import ( |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// U3 (census condition 2 on runHostEnv): write's path is fixed, and the |
| +// file is written only through etc and etc/carlos held by descriptor, |
| +// each a real directory this user owns and nobody else can write. |
| +func TestWriteGoesOnlyThroughDirectoriesRootAloneCanWrite(t *testing.T) { |
| + read := func(root string) string { |
| + b, _ := os.ReadFile(filepath.Join(root, "etc", "carlos", "host.env")) |
| + return string(b) |
| + } |
| + t.Run("lands whole, and replaces whole", func(t *testing.T) { |
| + root := withHostEnvRoot(t) |
| + if err := writeHostEnv(root, []byte("CARLOS_REGION=ie\n")); err != nil { |
| + t.Fatal(err) |
| + } |
| + fi, err := os.Stat(filepath.Join(root, "etc", "carlos")) |
| + if err != nil || fi.Mode().Perm()&0o022 != 0 || !fi.IsDir() { |
| + t.Fatalf("created directory %v %v", fi, err) |
| + } |
| + first, err := os.Stat(filepath.Join(root, "etc", "carlos", "host.env")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := writeHostEnv(root, []byte("CARLOS_REGION=uk\n")); err != nil { |
| + t.Fatal(err) |
| + } |
| + f, err := os.Stat(filepath.Join(root, "etc", "carlos", "host.env")) |
| + if err != nil || f.Mode().Perm() != 0o600 || read(root) != "CARLOS_REGION=uk\n" { |
| + t.Fatalf("host.env %v %v %q", f, err, read(root)) |
| + } |
| + // Replaced by rename, not rewritten in place: a reader holding the |
| + // old file never sees part of the new one. |
| + if os.SameFile(first, f) { |
| + t.Fatal("host.env was rewritten in place") |
| + } |
| + ents, _ := os.ReadDir(filepath.Join(root, "etc", "carlos")) |
| + if len(ents) != 1 { |
| + t.Fatalf("left behind: %v", ents) |
| + } |
| + }) |
| + for name, spoil := range map[string]func(t *testing.T, root string){ |
| + "a group-writable etc/carlos": func(t *testing.T, root string) { |
| + dir := filepath.Join(root, "etc", "carlos") |
| + if err := os.Mkdir(dir, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Chmod(dir, 0o775); err != nil { |
| + t.Fatal(err) |
| + } |
| + }, |
| + "a world-writable etc": func(t *testing.T, root string) { |
| + if err := os.Chmod(filepath.Join(root, "etc"), 0o757); err != nil { |
| + t.Fatal(err) |
| + } |
| + }, |
| + "an etc/carlos another user owns": func(t *testing.T, root string) { |
| + if err := os.Mkdir(filepath.Join(root, "etc", "carlos"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + was := hostEnvOwner |
| + hostEnvOwner = func() int { return was() + 1 } |
| + t.Cleanup(func() { hostEnvOwner = was }) |
| + }, |
| + "a symlinked etc/carlos": func(t *testing.T, root string) { |
| + elsewhere := safeDir(t) |
| + if err := os.Symlink(elsewhere, filepath.Join(root, "etc", "carlos")); err != nil { |
| + t.Fatal(err) |
| + } |
| + }, |
| + "a symlinked etc, an ancestor": func(t *testing.T, root string) { |
| + elsewhere := safeDir(t) |
| + if err := os.Mkdir(filepath.Join(elsewhere, "carlos"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Remove(filepath.Join(root, "etc")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Symlink(elsewhere, filepath.Join(root, "etc")); err != nil { |
| + t.Fatal(err) |
| + } |
| + }, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + root := withHostEnvRoot(t) |
| + spoil(t, root) |
| + err := writeHostEnv(root, []byte("CARLOS_REGION=ie\n")) |
| + if err == nil || !strings.Contains(err.Error(), "is not a directory only root can write") { |
| + t.Fatalf("%v", err) |
| + } |
| + if got := read(root); got != "" { |
| + t.Fatalf("wrote %q", got) |
| + } |
| + }) |
| } |
| - if ents, _ := os.ReadDir(elsewhere); len(ents) != 0 { |
| - t.Fatalf("wrote through the symlink: %v", ents) |
| +} |
| + |
| +// safeDir is a directory that passes every check but the one under test: |
| +// this user's, 0755 whatever the umask, so a symlink to it is refused only |
| +// for being a symlink. |
| +func safeDir(t *testing.T) string { |
| + t.Helper() |
| + dir := t.TempDir() |
| + if err := os.Chmod(dir, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + return dir |
| +} |
| +```` |
| + |
| +`internal/hostenv/write_other_test.go`: |
| + |
| +````go |
| +//go:build !linux |
| + |
| +package hostenv |
| + |
| +import "testing" |
| + |
| +// U3: off Linux there is no checked write, so write refuses and leaves |
| +// nothing behind. |
| +func TestWriteRefusesOffLinux(t *testing.T) { |
| + root := t.TempDir() |
| + err := writeHostEnv(root, []byte("CARLOS_REGION=ie\n")) |
| + if err == nil || err.Error() != "carlos system host-env write runs only on Linux" { |
| + t.Fatalf("%v", err) |
| } |
| } |
| ```` |
| @@ -1343,8 +1502,6 @@ import ( |
| "io" |
| "os" |
| "path/filepath" |
| - |
| - "github.com/carlosframework/platform/internal/hostfloor" |
| ) |
| |
| // carlos system host-env write|seed (region HA spec 2 §5 step 2). |
| @@ -1355,13 +1512,17 @@ import ( |
| // - seed is the operator's: it writes a box's existing host.env into |
| // the parameter layout, once per region. |
| |
| -const hostEnvUsage = `usage: carlos system host-env write --deployment <name> --region <code> [--aws-region <region>] [--out <path>] |
| +const hostEnvUsage = `usage: carlos system host-env write --deployment <name> --region <code> [--aws-region <region>] |
| carlos system host-env seed --deployment <name> --region <code> --kms-key <key> [--aws-region <region>] [--from <path>] |
| |
| write build host.env from the deployment's parameters (root) |
| seed copy a box's host.env into the deployment's parameters |
| ` |
| |
| +// hostEnvRoot is the root write's fixed path is under: "/" on a box. |
| +// Tests point it at a directory of their own; there is no flag. |
| +var hostEnvRoot = "/" |
| + |
| // openParams is the parameter store: the AWS adapter's (awsssm.go). |
| // kmsKey is the key seed encrypts with; write passes "". |
| var openParams = openSSMParams |
| @@ -1385,7 +1546,6 @@ func runHostEnv(ctx context.Context, args []string, out io.Writer, euid int, ope |
| region := fs.String("region", "", "") |
| awsRegion := fs.String("aws-region", "", "") |
| kmsKey := fs.String("kms-key", "", "") |
| - file := fs.String("out", Path, "") |
| from := fs.String("from", Path, "") |
| if err := fs.Parse(args[1:]); err != nil || fs.NArg() != 0 || *deployment == "" || *region == "" { |
| return usageError() |
| @@ -1403,26 +1563,13 @@ func runHostEnv(ctx context.Context, args []string, out io.Writer, euid int, ope |
| if err != nil { |
| return fmt.Errorf("host.env was not written: %w", err) |
| } |
| - // The file is replaced whole (hostfloor.Durable: a temporary file |
| - // beside it, synced, renamed over it), so a reader sees the old |
| - // file or the new one. Its directory must be a real directory |
| - // root owns, not a symlink: root writes nothing through a path |
| - // another user could redirect. |
| - dir := filepath.Dir(*file) |
| - if err := os.MkdirAll(dir, 0o755); err != nil { |
| - return err |
| - } |
| - fi, err := os.Lstat(dir) |
| - if err != nil { |
| - return err |
| - } |
| - if fi.Mode()&os.ModeSymlink != 0 || !fi.IsDir() || !ownedByEUID(fi) { |
| - return fmt.Errorf("%s is not a directory root owns, so host.env was not written", dir) |
| - } |
| - if err := hostfloor.Durable(*file, body, 0o600); err != nil { |
| - return err |
| + // The path is fixed, /etc/carlos/host.env, and the file is written |
| + // only through directories proved to be root's alone, then |
| + // replaced whole (writeHostEnv). |
| + if err := writeHostEnv(hostEnvRoot, body); err != nil { |
| + return fmt.Errorf("host.env was not written: %w", err) |
| } |
| - fmt.Fprintln(out, wroteFileLine(*file, *region)) |
| + fmt.Fprintln(out, wroteFileLine(filepath.Join(hostEnvRoot, Path), *region)) |
| return nil |
| default: |
| if *kmsKey == "" { |
| @@ -1462,38 +1609,133 @@ func seededLine(s Seeded) string { |
| } |
| ```` |
| |
| -`internal/hostenv/owner_unix.go`: |
| +`internal/hostenv/write_linux.go`: |
| |
| ````go |
| -//go:build unix |
| +//go:build linux |
| |
| package hostenv |
| |
| import ( |
| + "crypto/rand" |
| + "errors" |
| + "fmt" |
| "os" |
| - "syscall" |
| + "path/filepath" |
| + |
| + "golang.org/x/sys/unix" |
| ) |
| |
| -// ownedByEUID reports whether fi belongs to this process's user: root, |
| -// for write, which refuses any other. |
| -func ownedByEUID(fi os.FileInfo) bool { |
| - st, ok := fi.Sys().(*syscall.Stat_t) |
| - return ok && int(st.Uid) == os.Geteuid() |
| +// writeHostEnv writes body as <root>/etc/carlos/host.env, 0600, owned by |
| +// the process's user (root on a box). Every directory on the way is held |
| +// by descriptor, never looked up again by name: etc and etc/carlos are |
| +// opened with O_NOFOLLOW|O_DIRECTORY, so a symlink at either is refused, |
| +// and each must be owned by this user and writable by no one else |
| +// (checkedDir), so no other user can swap the file or the directory under |
| +// it. etc/carlos is created 0755 if it is missing, then opened and |
| +// checked like the rest. The new file is written beside the old one in |
| +// that directory, synced, renamed over it, and the directory synced, so a |
| +// reader sees the old file or the new one, never part of either. |
| +func writeHostEnv(root string, body []byte) error { |
| + rootFD, err := unix.Open(root, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) |
| + if err != nil { |
| + return err |
| + } |
| + defer unix.Close(rootFD) |
| + etc, err := checkedDir(rootFD, filepath.Join(root, "etc"), "etc", false) |
| + if err != nil { |
| + return err |
| + } |
| + defer unix.Close(etc) |
| + dir, err := checkedDir(etc, filepath.Join(root, "etc", "carlos"), "carlos", true) |
| + if err != nil { |
| + return err |
| + } |
| + defer unix.Close(dir) |
| + tmp := ".host.env." + rand.Text() |
| + fd, err := unix.Openat(dir, tmp, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0o600) |
| + if err != nil { |
| + return err |
| + } |
| + defer unix.Unlinkat(dir, tmp, 0) // gone once renamed |
| + f := os.NewFile(uintptr(fd), tmp) |
| + if err := f.Chmod(0o600); err != nil { |
| + f.Close() |
| + return err |
| + } |
| + if _, err := f.Write(body); err != nil { |
| + f.Close() |
| + return err |
| + } |
| + if err := f.Sync(); err != nil { |
| + f.Close() |
| + return err |
| + } |
| + if err := f.Close(); err != nil { |
| + return err |
| + } |
| + if err := unix.Renameat(dir, tmp, dir, "host.env"); err != nil { |
| + return err |
| + } |
| + return unix.Fsync(dir) |
| +} |
| + |
| +// checkedDir opens name under the directory parent as a real directory, |
| +// creating it 0755 first when create is set and it is missing, and refuses |
| +// one that is a symlink, is not a directory, is not owned by this |
| +// process's user (hostEnvOwner), or is writable by group or others. path |
| +// is its name for the refusal. |
| +func checkedDir(parent int, path, name string, create bool) (int, error) { |
| + const how = unix.O_RDONLY | unix.O_DIRECTORY | unix.O_NOFOLLOW | unix.O_CLOEXEC |
| + fd, err := unix.Openat(parent, name, how, 0) |
| + if errors.Is(err, unix.ENOENT) && create { |
| + if err := unix.Mkdirat(parent, name, 0o755); err != nil && !errors.Is(err, unix.EEXIST) { |
| + return -1, err |
| + } |
| + fd, err = unix.Openat(parent, name, how, 0) |
| + } |
| + if errors.Is(err, unix.ELOOP) || errors.Is(err, unix.ENOTDIR) { |
| + return -1, notRootsDir(path) |
| + } |
| + if err != nil { |
| + return -1, err |
| + } |
| + var st unix.Stat_t |
| + if err := unix.Fstat(fd, &st); err != nil { |
| + unix.Close(fd) |
| + return -1, err |
| + } |
| + if st.Mode&unix.S_IFMT != unix.S_IFDIR || int(st.Uid) != hostEnvOwner() || st.Mode&0o022 != 0 { |
| + unix.Close(fd) |
| + return -1, notRootsDir(path) |
| + } |
| + return fd, nil |
| +} |
| + |
| +// hostEnvOwner is the user etc and etc/carlos must belong to: this |
| +// process's, which runHostEnv has already required to be root. A var so a |
| +// test can stand for a directory another user owns. |
| +var hostEnvOwner = os.Geteuid |
| + |
| +func notRootsDir(path string) error { |
| + return fmt.Errorf("%s is not a directory only root can write", path) |
| } |
| ```` |
| |
| -`internal/hostenv/owner_other.go`: |
| +`internal/hostenv/write_other.go`: |
| |
| ````go |
| -//go:build !unix |
| +//go:build !linux |
| |
| package hostenv |
| |
| -import "os" |
| +import "errors" |
| |
| -// ownedByEUID cannot tell an owner here: host.env is written only on a |
| -// Linux box, and nowhere else is a directory taken as root's. |
| -func ownedByEUID(os.FileInfo) bool { return false } |
| +// writeHostEnv is Linux's: a box that writes host.env runs Linux, and only |
| +// there are its directories opened and checked by descriptor. |
| +func writeHostEnv(root string, body []byte) error { |
| + return errors.New("carlos system host-env write runs only on Linux") |
| +} |
| ```` |
| |
| `internal/hostenv/awsssm.go`: |
| @@ -1613,16 +1855,18 @@ List the core files, the adapter and the package: |
| ````diff |
| --- a/region_ha_boundary_test.go |
| +++ b/region_ha_boundary_test.go |
| -@@ -30,6 +30,8 @@ |
| +@@ -30,6 +30,10 @@ |
| "internal/console/nocapacity.go", |
| "internal/hostops/chosen.go", |
| "internal/console/regioncapacity.go", |
| + "internal/hostenv/hostenv.go", |
| + "internal/hostenv/cli.go", |
| ++ "internal/hostenv/write_linux.go", |
| ++ "internal/hostenv/write_other.go", |
| } |
| |
| // regionHAAdapterFiles are spec 2's AWS adapters. Each is named for AWS so |
| -@@ -37,6 +39,7 @@ |
| +@@ -37,6 +41,7 @@ |
| var regionHAAdapterFiles = []string{ |
| "internal/host/awsaddr.go", |
| "internal/console/awsregionmetric.go", |
| @@ -1630,7 +1874,7 @@ List the core files, the adapter and the package: |
| } |
| |
| // regionHAPackages are the packages spec 2 touches. Every S3 client built |
| -@@ -49,6 +52,7 @@ |
| +@@ -49,6 +54,7 @@ |
| "internal/edgesfile", |
| "internal/host", |
| "internal/hostactors", |
| @@ -1645,19 +1889,19 @@ Expected: PASS. |
| |
| - [ ] **Step 7: Mark the rows built** |
| |
| -Add RHB-C1 to RHB-C18, RHB-C64 and RHB-C67 to `regionHA2bCopyBuilt`, in the same commit as their text: |
| +Add RHB-C1 to RHB-C18, RHB-C64, RHB-C67 and RHB-C68 to `regionHA2bCopyBuilt`, in the same commit as their text: |
| |
| `approved_region_ha_2b_copy_test.go`: |
| |
| ````diff |
| --- a/approved_region_ha_2b_copy_test.go |
| +++ b/approved_region_ha_2b_copy_test.go |
| -@@ -36,7 +36,7 @@ |
| +@@ -38,7 +38,7 @@ |
| // regionHA2bCopyBuilt is every id whose text the code or docs carry. The |
| // task that writes a row's text adds its ids here in the same commit, once |
| // Paul has approved the rows. |
| -var regionHA2bCopyBuilt = []string{} |
| -+var regionHA2bCopyBuilt = []string{"RHB-C1", "RHB-C2", "RHB-C3", "RHB-C4", "RHB-C5", "RHB-C6", "RHB-C7", "RHB-C8", "RHB-C9", "RHB-C10", "RHB-C11", "RHB-C12", "RHB-C13", "RHB-C14", "RHB-C15", "RHB-C16", "RHB-C17", "RHB-C18", "RHB-C64", "RHB-C67"} |
| ++var regionHA2bCopyBuilt = []string{"RHB-C1", "RHB-C2", "RHB-C3", "RHB-C4", "RHB-C5", "RHB-C6", "RHB-C7", "RHB-C8", "RHB-C9", "RHB-C10", "RHB-C11", "RHB-C12", "RHB-C13", "RHB-C14", "RHB-C15", "RHB-C16", "RHB-C17", "RHB-C18", "RHB-C64", "RHB-C67", "RHB-C68"} |
| |
| func mustParseRegionHA2bRecord(t *testing.T) []copyRow { |
| t.Helper() |
| @@ -1668,16 +1912,18 @@ Expected: PASS once the rows are approved. While any built row is pending, `Test |
| |
| - [ ] **Step 8: Census** |
| |
| -Create the overlay and load it last. `runHostEnv` is a new root-read unit (its `seed` path reads the `--from` file); census ruling region-ha-2b is asked to class it. No legacy row is added, so the seal stands. |
| +Create the overlay and load it last. `runHostEnv` is a new root-read unit (its `seed` path reads the `--from` file), ruled A,J4 by census ruling region-ha-2b. `writeHostEnv` and `checkedDir` are new in round 4 (their read sites open the root, `etc`, `etc/carlos` and the temporary file); they are proposed A,J4 under `runHostEnv`'s ruling, marked `PROPOSED-region-ha-2b-r4` until platform-1e rules. No legacy row is added, so the seal stands. |
| |
| `internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv`: |
| |
| ````text |
| -# Plan 14 census overlay region-ha-2b (region HA spec 2, plan 2b: docs/superpowers/plans/2026-10-17-region-ha-2b-joining.md), loaded last. RULED by the Plan 14 controller 2026-10-16 as census-ruling-region-ha-2b. |
| -# Task 2: host.env from the deployment's parameters (internal/hostenv: hostenv.go, the layout, assembly and rendering, with an allowlist of names, shared values written create-only and the group-box marker added on write; cli.go, carlos system host-env write and seed; owner_unix.go and owner_other.go; awsssm.go, the SSM adapter) and the SSM SDK in go.mod. One new root-read unit, runHostEnv: seed's --from file, and write's check that /etc/carlos is a directory root owns and not a symlink; write runs as root and writes /etc/carlos/host.env 0600, whole, from the parameters. 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). The legacy seal stands. |
| +# 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; the two rows marked PROPOSED-region-ha-2b-r4 (Task 2, review round 4) await its ruling. |
| +# 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; proposed A,J4 under runHostEnv's ruling, as the checked parent its condition asks for. The legacy seal stands. |
| @coverage . be7c96bb2ff00b5b018b436f976f0d33bbcc8765c17e357baa6cd0290f59f42d |
| -@coverage internal/hostenv 9e4a59312727547813535b526635f612b27148f262155e48aa307e85e2499a01 |
| -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 and a directory that is a symlink or not root's; its read sites are that check of the directory and seed's --from file, an operator's host.env whose values go only to the parameter store file:Lstat@1495,file:ReadFile@1930 b1cdca1bc9ea21f6aaf8c06700f6f50446e738c1b3025321720b219ad1c90a00 |
| +@coverage internal/hostenv a3f3274a86cdf8194afb871301c1ba3289dfbdc9b703dd547178273df151cdaa |
| +internal/hostenv/cli.go runHostEnv authority A,J4 rev3=census-ruling-region-ha-2b;basis=carlos system host-env: write runs as root at a box's start-up and writes /etc/carlos/host.env, 0600, whole (temporary file and rename), from allowlisted parameters with safe values only, refusing a missing required value; its one read site is seed's --from file, an operator's host.env whose values go only to the parameter store; write's path is fixed and reached only through writeHostEnv file:ReadFile@1500 df4470176b229b311927ff0ac4f268d354c3a2357cac4300ac0992d2ebb90899 |
| +internal/hostenv/write_linux.go checkedDir authority A,J4 rev3=PROPOSED-region-ha-2b-r4;basis=carlos system host-env write (root, Linux): opens one directory under one already held, O_NOFOLLOW|O_DIRECTORY, creating it 0755 first only when asked and missing, and refuses it unless fstat shows a directory owned by this process's user (root, as write requires) that no group or other can write file:Fstat@576,file:Openat@168,file:Openat@372 012257ac0866f3385044785d8abae9c31e61134928c394e22a2aeaef39ec9f3c |
| +internal/hostenv/write_linux.go writeHostEnv authority A,J4 rev3=PROPOSED-region-ha-2b-r4;basis=carlos system host-env write (root, Linux): writes /etc/carlos/host.env only through /etc and /etc/carlos held by descriptor and checked by checkedDir; the new file is created 0600, O_EXCL|O_NOFOLLOW, in that directory, synced, renamed over host.env within it, and the directory synced; its read sites open the root and the temporary file file:Open@68,file:Openat@509 1245fc0f819ba73fb45545ffdcf92d5d4941802175e73bac209ab4d4238adff1 |
| ```` |
| |
| `internal/provision/plan14_root_reads_test.go`: |
| @@ -2370,10 +2616,10 @@ Expected: PASS (on linux: staging opens the cache with `openat`, which only the |
| ````diff |
| --- a/region_ha_boundary_test.go |
| +++ b/region_ha_boundary_test.go |
| -@@ -32,6 +32,7 @@ |
| - "internal/console/regioncapacity.go", |
| - "internal/hostenv/hostenv.go", |
| +@@ -34,6 +34,7 @@ |
| "internal/hostenv/cli.go", |
| + "internal/hostenv/write_linux.go", |
| + "internal/hostenv/write_other.go", |
| + "internal/host/installchannel.go", |
| } |
| |
| @@ -2392,12 +2638,12 @@ Add RHB-C19 to RHB-C39 to `regionHA2bCopyBuilt`, in the same commit as their tex |
| ````diff |
| --- a/approved_region_ha_2b_copy_test.go |
| +++ b/approved_region_ha_2b_copy_test.go |
| -@@ -36,7 +36,7 @@ |
| +@@ -38,7 +38,7 @@ |
| // regionHA2bCopyBuilt is every id whose text the code or docs carry. The |
| // task that writes a row's text adds its ids here in the same commit, once |
| // Paul has approved the rows. |
| --var regionHA2bCopyBuilt = []string{"RHB-C1", "RHB-C2", "RHB-C3", "RHB-C4", "RHB-C5", "RHB-C6", "RHB-C7", "RHB-C8", "RHB-C9", "RHB-C10", "RHB-C11", "RHB-C12", "RHB-C13", "RHB-C14", "RHB-C15", "RHB-C16", "RHB-C17", "RHB-C18", "RHB-C64", "RHB-C67"} |
| -+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-C19", "RHB-C20", "RHB-C21", "RHB-C22", "RHB-C23", "RHB-C24", "RHB-C25", "RHB-C26", "RHB-C27", "RHB-C28", "RHB-C29", "RHB-C30", "RHB-C31", "RHB-C32", "RHB-C33", "RHB-C34", "RHB-C35", "RHB-C36", "RHB-C37", "RHB-C38", "RHB-C39"} |
| +-var regionHA2bCopyBuilt = []string{"RHB-C1", "RHB-C2", "RHB-C3", "RHB-C4", "RHB-C5", "RHB-C6", "RHB-C7", "RHB-C8", "RHB-C9", "RHB-C10", "RHB-C11", "RHB-C12", "RHB-C13", "RHB-C14", "RHB-C15", "RHB-C16", "RHB-C17", "RHB-C18", "RHB-C64", "RHB-C67", "RHB-C68"} |
| ++var regionHA2bCopyBuilt = []string{"RHB-C1", "RHB-C2", "RHB-C3", "RHB-C4", "RHB-C5", "RHB-C6", "RHB-C7", "RHB-C8", "RHB-C9", "RHB-C10", "RHB-C11", "RHB-C12", "RHB-C13", "RHB-C14", "RHB-C15", "RHB-C16", "RHB-C17", "RHB-C18", "RHB-C64", "RHB-C67", "RHB-C68", "RHB-C19", "RHB-C20", "RHB-C21", "RHB-C22", "RHB-C23", "RHB-C24", "RHB-C25", "RHB-C26", "RHB-C27", "RHB-C28", "RHB-C29", "RHB-C30", "RHB-C31", "RHB-C32", "RHB-C33", "RHB-C34", "RHB-C35", "RHB-C36", "RHB-C37", "RHB-C38", "RHB-C39"} |
| |
| func mustParseRegionHA2bRecord(t *testing.T) []copyRow { |
| t.Helper() |
| @@ -2415,13 +2661,15 @@ Expected: PASS once the rows are approved. While any built row is pending, `Test |
| ````diff |
| --- a/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv |
| -@@ -1,5 +1,8 @@ |
| - # Plan 14 census overlay region-ha-2b (region HA spec 2, plan 2b: docs/superpowers/plans/2026-10-17-region-ha-2b-joining.md), loaded last. RULED by the Plan 14 controller 2026-10-16 as census-ruling-region-ha-2b. |
| - # Task 2: host.env from the deployment's parameters (internal/hostenv: hostenv.go, the layout, assembly and rendering, with an allowlist of names, shared values written create-only and the group-box marker added on write; cli.go, carlos system host-env write and seed; owner_unix.go and owner_other.go; awsssm.go, the SSM adapter) and the SSM SDK in go.mod. One new root-read unit, runHostEnv: seed's --from file, and write's check that /etc/carlos is a directory root owns and not a symlink; write runs as root and writes /etc/carlos/host.env 0600, whole, from the parameters. 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). The legacy seal stands. |
| +@@ -1,7 +1,10 @@ |
| + # Plan 14 census overlay region-ha-2b (region HA spec 2, plan 2b: docs/superpowers/plans/2026-10-17-region-ha-2b-joining.md), loaded last. RULED by the Plan 14 controller 2026-10-16 as census-ruling-region-ha-2b; the two rows marked PROPOSED-region-ha-2b-r4 (Task 2, review round 4) await its ruling. |
| + # 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; proposed A,J4 under runHostEnv's ruling, as the checked parent its condition asks for. The legacy seal stands. |
| +# Task 3: carlos system install-channel (internal/host installchannel.go, installchannel_linux.go): a fresh box installs its signed channel release into both protected slots through verifyPointer, hostfloor.CheckImage and stageCandidate, all unchanged, and on every run hands each directory on the agent's cache path (system, system/router and the version's) to the service user through bootstrap's own descriptor-anchored open, refusing a symlinked parent. One new root-read unit, channelInstall.run, whose one read site opens root's apply lock (systemApplier's lockPath). Ruled authority F,J4. stageCandidate, accepted-legacy, is called and not changed. The legacy seal stands. |
| @coverage . be7c96bb2ff00b5b018b436f976f0d33bbcc8765c17e357baa6cd0290f59f42d |
| - @coverage internal/hostenv 9e4a59312727547813535b526635f612b27148f262155e48aa307e85e2499a01 |
| - 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 and a directory that is a symlink or not root's; its read sites are that check of the directory and seed's --from file, an operator's host.env whose values go only to the parameter store file:Lstat@1495,file:ReadFile@1930 b1cdca1bc9ea21f6aaf8c06700f6f50446e738c1b3025321720b219ad1c90a00 |
| + @coverage internal/hostenv a3f3274a86cdf8194afb871301c1ba3289dfbdc9b703dd547178273df151cdaa |
| + internal/hostenv/cli.go runHostEnv authority A,J4 rev3=census-ruling-region-ha-2b;basis=carlos system host-env: write runs as root at a box's start-up and writes /etc/carlos/host.env, 0600, whole (temporary file and rename), from allowlisted parameters with safe values only, refusing a missing required value; its one read site is seed's --from file, an operator's host.env whose values go only to the parameter store; write's path is fixed and reached only through writeHostEnv file:ReadFile@1500 df4470176b229b311927ff0ac4f268d354c3a2357cac4300ac0992d2ebb90899 |
| + internal/hostenv/write_linux.go checkedDir authority A,J4 rev3=PROPOSED-region-ha-2b-r4;basis=carlos system host-env write (root, Linux): opens one directory under one already held, O_NOFOLLOW|O_DIRECTORY, creating it 0755 first only when asked and missing, and refuses it unless fstat shows a directory owned by this process's user (root, as write requires) that no group or other can write file:Fstat@576,file:Openat@168,file:Openat@372 012257ac0866f3385044785d8abae9c31e61134928c394e22a2aeaef39ec9f3c |
| + internal/hostenv/write_linux.go writeHostEnv authority A,J4 rev3=PROPOSED-region-ha-2b-r4;basis=carlos system host-env write (root, Linux): writes /etc/carlos/host.env only through /etc and /etc/carlos held by descriptor and checked by checkedDir; the new file is created 0600, O_EXCL|O_NOFOLLOW, in that directory, synced, renamed over host.env within it, and the directory synced; its read sites open the root and the temporary file file:Open@68,file:Openat@509 1245fc0f819ba73fb45545ffdcf92d5d4941802175e73bac209ab4d4238adff1 |
| +@coverage internal/host 64ee4f561f2a28d584a6f7c82e068e57db4822f6cbb130116284bca583d2d390 |
| +internal/host/installchannel.go channelInstall.run authority F,J4 rev3=census-ruling-region-ha-2b;basis=carlos system install-channel (root, at a fresh box's start-up): its one read site opens root's apply lock; the install goes through verifyPointer, hostfloor.CheckImage and stageCandidate into both protected slots, and refuses a box whose guard is armed or that already runs another release file:OpenFile@196 f0cdb6dd28e83da282c3b0d7bc2012dcd80db181a8cecbf1a2db6bcc41d1d5e3 |
| ```` |
| @@ -2450,7 +2698,7 @@ git commit -m "region HA 2b: carlos system install-channel" |
| |
| **Interfaces:** |
| - Consumes: `validateScope`, `segment`, `token`, `EnrollmentAttempt`, `Directory.enroll`, `Replace` and `checkMembership` (all main's, unchanged, run on the bound store), `release.Store`. |
| -- Produces (`hostactors`): `type MembershipReader interface { Member(ctx, awsRegion, instance string) (Membership, error) }`; `Membership`; `MembershipEvidence{RequestIDs []string; ObservedAt time.Time}`; `ErrMembershipUnknown`, `ErrNotMember`, `ErrMembershipStale`, `ErrWriteDeadline`; `GroupBoxReference(account, instance string) string`; `func (d Directory) CheckConfirmed(ctx, o Observed, v boxid.Verifier, in EnrollmentRequest, raw []byte, instance string, r MembershipReader, now func() time.Time) (EnrollmentRequest, MembershipEvidence, error)`; `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: `membershipConfirmation` (with `fresh`, `deadline`, `atWrite`), `confirmMembership`, `writeBound`, `deadlineStore`, `inFlight`, `membershipFreshness = time.Minute`. Changed: `Directory.PrivateBoxReport` reads a group box (`Box.Group` set) under `GroupBoxReference` of its pinned account and instance. |
| +- 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) CheckConfirmed(ctx, o Observed, v boxid.Verifier, in EnrollmentRequest, raw []byte, instance string, r MembershipReader, now func() time.Time) (EnrollmentRequest, MembershipEvidence, error)`; `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: `membershipConfirmation` (with `fresh`, `deadline`, `atWrite`), `confirmMembership`, `writeBound`, `deadlineStore` (with `call`), `membershipFreshness = time.Minute`, and `var storeCallTimeout = 15 * time.Second` (a var so tests can shorten it). Changed: `Directory.PrivateBoxReport` reads a group box (`Box.Group` set) under `GroupBoxReference` of its pinned account and instance. |
| - Removes (`hostactors`): `ConfirmationKey`, `MembershipConfirmation`, `TerminatedEnrollment`, `freshConfirmation`. Nothing on main but `EnrollAutomatic` and its two tests used them. |
| - Produces (`hostactors/awsmembership`): `New(ctx) (*Reader, error)` over the console's credentials; `(*Reader).Member` (`DescribeAutoScalingInstances`, then `DescribeInstances` by the `instance-id` filter). |
| |
| @@ -2460,12 +2708,13 @@ Every other admission check stands, unchanged: the attempt in the roster matches |
| |
| The read (C2) is a member only when the instance is in the region's group, `Pending` or `InService`, `pending` or `running`, owned by the region's account and in its AWS region, with a request id from each call. Its anchor, its time, is taken before the call, so never later than the describe's completion. `checkConfirmed` refuses a read that was already 60 seconds old or more when the describe returned (`ErrMembershipStale`, which counts against the attempt); that is the only freshness check besides the bound. A failed call, or an answer that is not one (EC2 does not list the instance, a request id is missing), is `ErrMembershipUnknown`, never `ErrNotMember` (#7). The request ids come back as `MembershipEvidence` whenever the cloud answered, refusals included, for the console's log: main's roster has no field for them, and none is added. |
| |
| -**The write bound: the single enforcement point (S1, T3).** The write deadline is the read's anchor plus 60 seconds, and a write at or after it is refused: the write is issued no later than 60 s after the membership read; a write whose result is lost to the deadline is unknown and is read back next pass. `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: |
| +**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), so a write that commits late is at most 60 + 15 s behind its read. A write whose result the bound cannot confirm is unknown, and the next pass settles it from the roster. `Invite`, `checkMembership` and `Replace` are sealed and stay unchanged, and so does `Directory.enroll` (round 3 removed the callback round 1 gave it); instead `writeBound` gives them a copy of the `Directory` whose store is a `deadlineStore` and a context that ends at the deadline. What the bound guarantees: |
| - no store call (`Get`, `Put`, `List`, `ListDirs`, `Delete`), the roster `Put` included, is issued at or after the deadline, by the read's clock or the context's: `deadlineStore` refuses it before it reaches the store underneath; |
| -- a call still in flight at the deadline is cancelled by the context, and reported as `ErrWriteDeadline`, so a write whose commit can't be confirmed before the deadline is unknown; |
| -- an unknown write keeps the attempt and its wait, and the pass reads it back next time (Task 5). |
| +- every call runs under `deadlineStore.call`, with its own context that ends 15 seconds after it was issued (`storeCallTimeout`) or at the deadline, whichever is first. A call still in flight at the deadline, or still unanswered at its timeout, is cut off: `call` returns `ErrWriteDeadline` itself, without waiting for a store that ignores its context, and cancels the call's context, which the AWS SDK enforces at the HTTP layer; |
| +- a call that answers only after the deadline, even with success, is `ErrWriteDeadline` too, so a write whose commit can't be confirmed before the deadline is unknown; |
| +- an unknown write keeps the attempt and its wait, and the next pass reads the roster as every pass does (Task 5, U1). |
| |
| -The store is wrapped because it can't be trusted to do this: `release.DirStore` ignores its context (`release.S3Store` passes it to the AWS SDK, which does honour it). `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, and a `Put` still in flight there. |
| +The store is wrapped because it can't be trusted to do this: `release.DirStore` ignores its context (`release.S3Store` passes it to the AWS SDK, which does honour it). The pass's S3 store is the console's claim data boundary (`internal/console/claim_runtime.go:42`, `NewConditionalRecordStore`, then `NewObservedMutationBoundary`), whose S3 client the console shares; U2 preferred a dedicated client for the pass, but a second client means a second boundary, with its own census-selected construction and its own mutation observer. The wrapper gives the same hard limit without one: each call's own 15-second context reaches the SDK's HTTP request, and `call` stops waiting at that point whatever the store does. `EnrollAutomatic` runs main's `enroll` on the bound directory. The tests drive a describe of exactly 60 seconds, a clock that reaches the deadline between the check and the roster write, and (Task 5) a store read that answers at the deadline inside the sealed functions, a `Put` still in flight there, a store that ignores its context and never answers, and a `Put` that answers success after the deadline. |
| |
| **Group references (S3).** `GroupBoxReference` is platform-1e's function: `g-` and the first 20 hex digits of `sha256("carlos-box-ref-v1|" + account + "|" + instance)`. `checkConfirmed` refuses a request whose reference is not the attempt instance's group reference, before the cloud is asked, and `PrivateBoxReport` reads a group box's report under the reference derived from the instance its entry pins, so `checkMembership`'s `VerifyReport`, the census and activation never read a group box's report under a key the box chose. A report another box writes there names its own instance and enrollment, and `VerifyReport` refuses it. Non-group boxes are read exactly as before. `deadlineStore.Put` is a new site in the record-write census (nonrecord). |
| |
| @@ -3190,14 +3439,15 @@ func (c membershipConfirmation) evidence() MembershipEvidence { |
| } |
| |
| // writeBound is d with every store call bound to c's deadline (C2, S1), |
| -// the single enforcement point of the write's freshness: each store call |
| +// the single enforcement point of the write's freshness. Each store call |
| // (a read, the write, the read-back after a lost write) is refused before |
| -// it is issued once the deadline is reached by either clock, the |
| -// context's or now, and the returned context ends at the deadline, so a |
| -// call in flight is cancelled there and reported as ErrWriteDeadline. The |
| -// sealed writers (Invite, Replace and the checkMembership it makes) run on |
| -// it unchanged, so no read, signature's Put or read-back of theirs is |
| -// issued 60 seconds or more after the membership read's anchor. |
| +// it is issued once the deadline is reached, by either clock, the |
| +// context's or now; the returned context ends at the deadline; and each |
| +// call it does issue gets at most storeCallTimeout (U2). The sealed |
| +// writers (Invite, Replace and the checkMembership it makes) run on it |
| +// unchanged, so no read, signature's Put or read-back of theirs is issued |
| +// 60 seconds or more after the membership read's anchor, and any outcome |
| +// not confirmed in time is ErrWriteDeadline, unknown. |
| func writeBound(ctx context.Context, d Directory, c membershipConfirmation, now func() time.Time) (Directory, context.Context, context.CancelFunc) { |
| bctx, cancel := context.WithTimeout(ctx, c.deadline().Sub(now())) |
| bd := d |
| @@ -3210,61 +3460,114 @@ func writeBound(ctx context.Context, d Directory, c membershipConfirmation, now |
| return bd, bctx, cancel |
| } |
| |
| +// storeCallTimeout is the hard limit on one store call of a bound write |
| +// (U2): a call the store has not answered within it is unknown, and its |
| +// context is cancelled, so an S3 request is abandoned at the HTTP layer. |
| +// With the 60-second issue bound, a write that commits late is at most |
| +// 60 + 15 seconds behind its membership read. A var so tests can shorten |
| +// it. |
| +var storeCallTimeout = 15 * time.Second |
| + |
| // deadlineStore refuses every call once check says the deadline has been |
| -// reached, before the call reaches the store underneath: release.DirStore |
| -// ignores its context, so the bound cannot rest on the store alone. A |
| -// call that fails after its context ended was cut off in flight, and is |
| -// reported as ErrWriteDeadline: whether it committed is unknown. |
| +// reached, before the call reaches the store underneath, and gives each |
| +// call it issues at most storeCallTimeout. The bound cannot rest on the |
| +// store alone: release.DirStore ignores its context, so the wrapper |
| +// returns on its own when the call outlives the timeout or the deadline, |
| +// whatever the store does. Every outcome it cannot confirm in time is |
| +// ErrWriteDeadline, unknown: a call cut off in flight, one that never |
| +// answered, and one that answered, even successfully, only after the |
| +// deadline. |
| type deadlineStore struct { |
| release.Store |
| check func(context.Context) error |
| } |
| |
| -// inFlight is a call's own error, unless its context ended under it. |
| -func inFlight(ctx context.Context, err error) error { |
| - if err != nil && ctx.Err() != nil { |
| - return fmt.Errorf("%w: in flight at the deadline: %v", ErrWriteDeadline, err) |
| +// call runs one store call under the bound. |
| +func (s deadlineStore) call(ctx context.Context, f func(context.Context) error) error { |
| + if err := s.check(ctx); err != nil { |
| + return err |
| + } |
| + cctx, cancel := context.WithTimeout(ctx, storeCallTimeout) |
| + defer cancel() |
| + done := make(chan error, 1) |
| + go func() { done <- f(cctx) }() |
| + var err error |
| + select { |
| + case err = <-done: |
| + case <-cctx.Done(): |
| + } |
| + // Cut off, whether the store gave up on the context or not: by the |
| + // deadline, or by the hard timeout. Whatever it answered, if anything, |
| + // is not known to be the outcome. |
| + if cctx.Err() != nil { |
| + if ctx.Err() != nil { |
| + return fmt.Errorf("%w: in flight at the deadline", ErrWriteDeadline) |
| + } |
| + return fmt.Errorf("%w: the store did not answer in time", ErrWriteDeadline) |
| + } |
| + if err == nil { |
| + if late := s.check(ctx); late != nil { |
| + return fmt.Errorf("%w: it answered only after the deadline", ErrWriteDeadline) |
| + } |
| } |
| return err |
| } |
| |
| func (s deadlineStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| - if err := s.check(ctx); err != nil { |
| + var body []byte |
| + var etag string |
| + err := s.call(ctx, func(cctx context.Context) error { |
| + var err error |
| + body, etag, err = s.Store.Get(cctx, key) |
| + return err |
| + }) |
| + if err != nil { |
| return nil, "", err |
| } |
| - body, etag, err := s.Store.Get(ctx, key) |
| - return body, etag, inFlight(ctx, err) |
| + return body, etag, nil |
| } |
| |
| func (s deadlineStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| - if err := s.check(ctx); err != nil { |
| + var etag string |
| + err := s.call(ctx, func(cctx context.Context) error { |
| + var err error |
| + etag, err = s.Store.Put(cctx, key, body, ifMatch) |
| + return err |
| + }) |
| + if err != nil { |
| return "", err |
| } |
| - etag, err := s.Store.Put(ctx, key, body, ifMatch) |
| - return etag, inFlight(ctx, err) |
| + return etag, nil |
| } |
| |
| func (s deadlineStore) List(ctx context.Context, prefix string) ([]string, error) { |
| - if err := s.check(ctx); err != nil { |
| + var keys []string |
| + err := s.call(ctx, func(cctx context.Context) error { |
| + var err error |
| + keys, err = s.Store.List(cctx, prefix) |
| + return err |
| + }) |
| + if err != nil { |
| return nil, err |
| } |
| - keys, err := s.Store.List(ctx, prefix) |
| - return keys, inFlight(ctx, err) |
| + return keys, nil |
| } |
| |
| func (s deadlineStore) ListDirs(ctx context.Context, prefix string) ([]string, error) { |
| - if err := s.check(ctx); err != nil { |
| + var dirs []string |
| + err := s.call(ctx, func(cctx context.Context) error { |
| + var err error |
| + dirs, err = s.Store.ListDirs(cctx, prefix) |
| + return err |
| + }) |
| + if err != nil { |
| return nil, err |
| } |
| - dirs, err := s.Store.ListDirs(ctx, prefix) |
| - return dirs, inFlight(ctx, err) |
| + return dirs, nil |
| } |
| |
| func (s deadlineStore) Delete(ctx context.Context, key string) error { |
| - if err := s.check(ctx); err != nil { |
| - return err |
| - } |
| - return inFlight(ctx, s.Store.Delete(ctx, key)) |
| + return s.call(ctx, func(cctx context.Context) error { return s.Store.Delete(cctx, key) }) |
| } |
| ```` |
| |
| @@ -3604,16 +3907,16 @@ Expected: PASS, main's tests included. |
| ````diff |
| --- a/region_ha_boundary_test.go |
| +++ b/region_ha_boundary_test.go |
| -@@ -33,6 +33,8 @@ |
| - "internal/hostenv/hostenv.go", |
| - "internal/hostenv/cli.go", |
| +@@ -35,6 +35,8 @@ |
| + "internal/hostenv/write_linux.go", |
| + "internal/hostenv/write_other.go", |
| "internal/host/installchannel.go", |
| + "internal/hostactors/membershipread.go", |
| + "internal/hostactors/groupref.go", |
| } |
| |
| // regionHAAdapterFiles are spec 2's AWS adapters. Each is named for AWS so |
| -@@ -41,6 +43,7 @@ |
| +@@ -43,6 +45,7 @@ |
| "internal/host/awsaddr.go", |
| "internal/console/awsregionmetric.go", |
| "internal/hostenv/awsssm.go", |
| @@ -3621,7 +3924,7 @@ Expected: PASS, main's tests included. |
| } |
| |
| // regionHAPackages are the packages spec 2 touches. Every S3 client built |
| -@@ -53,6 +56,7 @@ |
| +@@ -55,6 +58,7 @@ |
| "internal/edgesfile", |
| "internal/host", |
| "internal/hostactors", |
| @@ -3643,13 +3946,15 @@ No root-read unit is added or changed: the read is the cloud's, through the read |
| ````diff |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| -@@ -814,6 +814,9 @@ |
| +@@ -814,6 +814,11 @@ |
| {"internal/hostactors/censuskey.go", "Directory.BindCensusKey", "nonrecord", "sel:Put", "7d9d673435e8c8a6aac3105e3a754bde2adcbc5d603945dd599cef703f334a5b"}, |
| {"internal/hostactors/invitation.go", "Directory.Invite", "nonrecord", "sel:Put", "356618a75a23d0157d8e0402e20187a42956f67a4332d698bb22bf05133ac489"}, |
| {"internal/hostactors/roster.go", "Directory.Replace", "nonrecord", "sel:Put", "e6e8ac0070650dfacc4a438b080cb69738530b3d9013de55c1bcf06431b0a847"}, |
| + // Region HA plan 2b (Task 4): the store a membership read binds to its |
| -+ // deadline; it forwards Put, and refuses it once the deadline passes. |
| -+ {"internal/hostactors/membershipread.go", "deadlineStore.Put", "nonrecord", "def:Put,sel:Put", "e0f34b1d095e16976cb8a4d77a0e4f2deba86d4b8a88f922af0c70be54bfdc6e"}, |
| ++ // deadline; it forwards Put, refuses it once the deadline passes, gives |
| ++ // it at most storeCallTimeout, and reports one that answers late as |
| ++ // unknown. |
| ++ {"internal/hostactors/membershipread.go", "deadlineStore.Put", "nonrecord", "def:Put,sel:Put", "848f2b529d87e34f128dd481f14213e1cfaf8c54555806e6314864ef9a2dbb14"}, |
| {"internal/hostbox/regionalworker.go", "RegionalWorker.Census", "key-site", "lit:instances", "477bcb896b5ae8ec80af592d68eceb7992ad8a23cd6cff6043ad03b3ef2a8085"}, |
| {"internal/hostbox/requeststore.go", "RequestStore.CopyVersion", "versioned-transport", "def:CopyVersion,sel:CopyVersion", "3a50049ce7ca7f2cbc770bef46218944cdc7000205a86c20e4932f0b510da28f"}, |
| {"internal/hostbox/requeststore.go", "RequestStore.PutIfAbsent", "versioned-transport", "def:PutIfAbsent,sel:PutIfAbsent", "3666cbe5d2b91c1c652ffb4459c10a374fbd92e325e4e5c574fa3525d0ce2ffe"}, |
| @@ -3660,18 +3965,20 @@ No root-read unit is added or changed: the read is the cloud's, through the read |
| ````diff |
| --- a/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv |
| -@@ -1,8 +1,11 @@ |
| - # Plan 14 census overlay region-ha-2b (region HA spec 2, plan 2b: docs/superpowers/plans/2026-10-17-region-ha-2b-joining.md), loaded last. RULED by the Plan 14 controller 2026-10-16 as census-ruling-region-ha-2b. |
| - # Task 2: host.env from the deployment's parameters (internal/hostenv: hostenv.go, the layout, assembly and rendering, with an allowlist of names, shared values written create-only and the group-box marker added on write; cli.go, carlos system host-env write and seed; owner_unix.go and owner_other.go; awsssm.go, the SSM adapter) and the SSM SDK in go.mod. One new root-read unit, runHostEnv: seed's --from file, and write's check that /etc/carlos is a directory root owns and not a symlink; write runs as root and writes /etc/carlos/host.env 0600, whole, from the parameters. 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). The legacy seal stands. |
| +@@ -1,10 +1,13 @@ |
| + # Plan 14 census overlay region-ha-2b (region HA spec 2, plan 2b: docs/superpowers/plans/2026-10-17-region-ha-2b-joining.md), loaded last. RULED by the Plan 14 controller 2026-10-16 as census-ruling-region-ha-2b; the two rows marked PROPOSED-region-ha-2b-r4 (Task 2, review round 4) await its ruling. |
| + # 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; proposed A,J4 under runHostEnv's ruling, as the checked parent its condition asks for. The legacy seal stands. |
| # Task 3: carlos system install-channel (internal/host installchannel.go, installchannel_linux.go): a fresh box installs its signed channel release into both protected slots through verifyPointer, hostfloor.CheckImage and stageCandidate, all unchanged, and on every run hands each directory on the agent's cache path (system, system/router and the version's) to the service user through bootstrap's own descriptor-anchored open, refusing a symlinked parent. One new root-read unit, channelInstall.run, whose one read site opens root's apply lock (systemApplier's lockPath). Ruled authority F,J4. stageCandidate, accepted-legacy, is called and not changed. The legacy seal stands. |
| -@coverage . be7c96bb2ff00b5b018b436f976f0d33bbcc8765c17e357baa6cd0290f59f42d |
| -+# Task 4: group membership, read by the console (internal/hostactors membershipread.go: MembershipReader, the unexported confirmation and the write bound, the single enforcement point of the 60-second deadline; enrollment_confirmation.go: EnrollAutomatic and CheckConfirmed make the read themselves and write on the bound store, and the box-written confirmation object is removed; groupref.go: GroupBoxReference; private.go: a group box's report read under the reference the console derives; awsmembership, the Auto Scaling and EC2 describe adapter) and the Auto Scaling SDK in go.mod. No root-read unit is added or changed: the read is the cloud's, through the reader. Envelope-only, and the seal stands. The record-write census gains deadlineStore.Put (nonrecord, ruled). |
| ++# Task 4: group membership, read by the console (internal/hostactors membershipread.go: MembershipReader, the unexported confirmation and the write bound, the single enforcement point of the 60-second deadline, each store call cut off at 15 seconds; enrollment_confirmation.go: EnrollAutomatic and CheckConfirmed make the read themselves and write on the bound store, and the box-written confirmation object is removed; groupref.go: GroupBoxReference; private.go: a group box's report read under the reference the console derives; awsmembership, the Auto Scaling and EC2 describe adapter) and the Auto Scaling SDK in go.mod. No root-read unit is added or changed: the read is the cloud's, through the reader. Envelope-only, and the seal stands. The record-write census gains deadlineStore.Put (nonrecord, ruled). |
| +@coverage . 3678cfe22582d35fe0d8e93d3fed898cdffa6fb2ce2d81e0252c8a8da77993b3 |
| - @coverage internal/hostenv 9e4a59312727547813535b526635f612b27148f262155e48aa307e85e2499a01 |
| - 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 and a directory that is a symlink or not root's; its read sites are that check of the directory and seed's --from file, an operator's host.env whose values go only to the parameter store file:Lstat@1495,file:ReadFile@1930 b1cdca1bc9ea21f6aaf8c06700f6f50446e738c1b3025321720b219ad1c90a00 |
| + @coverage internal/hostenv a3f3274a86cdf8194afb871301c1ba3289dfbdc9b703dd547178273df151cdaa |
| + internal/hostenv/cli.go runHostEnv authority A,J4 rev3=census-ruling-region-ha-2b;basis=carlos system host-env: write runs as root at a box's start-up and writes /etc/carlos/host.env, 0600, whole (temporary file and rename), from allowlisted parameters with safe values only, refusing a missing required value; its one read site is seed's --from file, an operator's host.env whose values go only to the parameter store; write's path is fixed and reached only through writeHostEnv file:ReadFile@1500 df4470176b229b311927ff0ac4f268d354c3a2357cac4300ac0992d2ebb90899 |
| + internal/hostenv/write_linux.go checkedDir authority A,J4 rev3=PROPOSED-region-ha-2b-r4;basis=carlos system host-env write (root, Linux): opens one directory under one already held, O_NOFOLLOW|O_DIRECTORY, creating it 0755 first only when asked and missing, and refuses it unless fstat shows a directory owned by this process's user (root, as write requires) that no group or other can write file:Fstat@576,file:Openat@168,file:Openat@372 012257ac0866f3385044785d8abae9c31e61134928c394e22a2aeaef39ec9f3c |
| + internal/hostenv/write_linux.go writeHostEnv authority A,J4 rev3=PROPOSED-region-ha-2b-r4;basis=carlos system host-env write (root, Linux): writes /etc/carlos/host.env only through /etc and /etc/carlos held by descriptor and checked by checkedDir; the new file is created 0600, O_EXCL|O_NOFOLLOW, in that directory, synced, renamed over host.env within it, and the directory synced; its read sites open the root and the temporary file file:Open@68,file:Openat@509 1245fc0f819ba73fb45545ffdcf92d5d4941802175e73bac209ab4d4238adff1 |
| @coverage internal/host 64ee4f561f2a28d584a6f7c82e068e57db4822f6cbb130116284bca583d2d390 |
| internal/host/installchannel.go channelInstall.run authority F,J4 rev3=census-ruling-region-ha-2b;basis=carlos system install-channel (root, at a fresh box's start-up): its one read site opens root's apply lock; the install goes through verifyPointer, hostfloor.CheckImage and stageCandidate into both protected slots, and refuses a box whose guard is armed or that already runs another release file:OpenFile@196 f0cdb6dd28e83da282c3b0d7bc2012dcd80db181a8cecbf1a2db6bcc41d1d5e3 |
| -+@coverage internal/hostactors c35bd4a722d8502c2b0b55838acdd7d729000287a5b5dd6e2dbc435933bcfb8d |
| ++@coverage internal/hostactors 4bb1e4b527662d1fe59845dab28fad14215f73f1e69c4701b92da02d824c8de3 |
| +@coverage internal/hostactors/awsmembership a3defad7179245492a3c1dcfce5e20f0f85db2e240b474c654acf211da16df98 |
| ```` |
| |
| @@ -3694,18 +4001,18 @@ git commit -m "region HA 2b: group membership, read by the console" |
| - Create: `internal/console/enrollpass.go`, `internal/console/enrollpass_test.go`, `internal/console/awsmembership.go` |
| - Modify: `internal/console/hostclaimops.go` (start the pass with region HA on) |
| - Modify: `internal/console/claim_authority_census_test.go`, `internal/console/testdata/claim-authority-sites.txt` (R6) |
| -- Modify: `region_ha_boundary_test.go`, `approved_region_ha_2b_copy_test.go`, the overlay, `internal/provision/plan14_root_reads_test.go` (the seal) |
| +- Modify: `region_ha_boundary_test.go`, the overlay, `internal/provision/plan14_root_reads_test.go` (the seal) |
| |
| **Interfaces:** |
| - Consumes: `Directory.Read`, `Invite`, `BeginEnrollmentAttempt`, `CheckConfirmed`, `EnrollAutomatic`, `GroupBoxReference`, `writeBound` and `ErrWriteDeadline` (Task 4), `MembershipReader`, `VerifyReport`, `Evidence.BoxReport`, `RoleName`; `awsmembership.New` (Task 4); `boxid.Load`, `Verifier`, `EmbeddedCerts`; `hostfloor.Read`, `Digest`; the console's `ClaimAuthority` (`Directory`, `Signer`, `Evidence`, `Namespace`). |
| -- Produces (`hostactors`): `type AutoEnroller struct { Directory; Signer; Evidence; Members MembershipReader; Certs; Namespace, Pool string; Now func() time.Time; Patience time.Duration; Logf func(string, ...any); Page func(ctx, subject, body string) error }`; `func (e *AutoEnroller) EnrollPass(ctx) ([]EnrollStep, error)`; `EnrollStep{Box, Step string}`; `ErrNoFloor`. |
| -- Produces (`console`): `newAutoEnroller(*hostops.ProductionAuthority, hostactors.MembershipReader) (*hostactors.AutoEnroller, error)`; `runEnrollPass(ctx, e, every, logf)`; `(*Server).startEnrollPass(ctx)`; `pageEnrollment` (the console's `alerting.Page`, a seam); `consoleMembershipReader(ctx)` (`awsmembership.go`, the adapter); `enrollPassInterval = 10s`, `enrollPatience = 2m`. |
| +- Produces (`hostactors`): `type AutoEnroller struct { Directory; Signer; Evidence; Members MembershipReader; Certs; Namespace, Pool string; Now func() time.Time; Patience time.Duration; Logf func(string, ...any) }`; `func (e *AutoEnroller) EnrollPass(ctx) ([]EnrollStep, error)`; `EnrollStep{Box, Step string}`; `ErrNoFloor`. |
| +- Produces (`console`): `newAutoEnroller(*hostops.ProductionAuthority, hostactors.MembershipReader) (*hostactors.AutoEnroller, error)`; `runEnrollPass(ctx, e, every, logf)`; `(*Server).startEnrollPass(ctx)`; `consoleMembershipReader(ctx)` (`awsmembership.go`, the adapter); `enrollPassInterval = 10s`, `enrollPatience = 2m`. |
| |
| -For each box whose identity record is published and which the roster does not list, the pass takes the next step its state allows (§5 step 5, A2-1, H28): with no usable attempt, begin one; with one, read the instance's membership itself (`CheckConfirmed`) and invite on the directory bound to that read's deadline (`writeBound`), so `Invite`'s own read, its signature's `Put` and any read-back are refused at or after the deadline and a call in flight then is cancelled; 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. 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. |
| +For each box whose identity record is published and which the roster does not list, the pass takes the next step its state allows (§5 step 5, A2-1, H28): with no usable attempt, begin one; with one, read the instance's membership itself (`CheckConfirmed`) and invite on the directory bound to that read's deadline (`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. Round 3 removed the check the pass made just before `Invite`: the bound store is the single enforcement point, and nothing that waits sits between the describe and the `Invite` call. |
| |
| -A read that fails is unknown: the step names it, and the next pass asks again with the same attempt, whose wait does not grow (#7). So is a write that missed its deadline (`ErrWriteDeadline`, S1): nothing was issued at or after the deadline, but a call in flight then may have committed, so the next pass reads back. An invitation that landed is simply found and the box goes on to its guard-ready report: an invitation grants nothing. |
| +A read that fails is unknown: the step names it, and the next pass asks again with the same attempt, whose wait does not grow (#7). So is a write the bound could not confirm (`ErrWriteDeadline`, S1, U2): nothing was issued at or after the deadline, but a call cut off in flight, or one that answered only after the deadline, may have committed. An invitation that landed is simply found and the box goes on to its guard-ready report: an invitation grants nothing. |
| |
| -**The read-back of an unknown enrolment (the claim owner's ruling).** An enrolling write whose outcome was unknown is remembered (`unknown`, by reference, with its attempt, instance and region). On a later pass, if the roster lists the box, its write landed, and the pass does not simply accept it: `readBack` describes the instance again through the same `MembershipReader`. A current member is done, and the log line carries the attempt id, the instance id and both request ids. An instance no longer a member is logged and paged once (`Page`, RHB-C65 and RHB-C66), and 2b changes nothing else: a terminated instance is then 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 Task 6's restart hold; an instance that left the group but still runs keeps its enrolment until an operator removes it, which the page tells the operator. A read that fails is asked again next pass. If the roster does not list the box, the write did not land: the memory is dropped and the attempt goes on as usual. The memory is the console's own and is lost at a restart; after one, such an enrolment is treated like any other enrolled box. That is acceptable because its roster write was issued before the deadline, so the confirmation behind it was under 60 seconds old when the write began and at most a moment past that when it landed. 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. |
| +**Unknown enrolments are settled by the roster (U1).** An enrolling write whose outcome is unknown is not remembered and not read back. The next pass reads the signed roster, as every pass does, and that settles it: a box the roster lists is enrolled, and the pass has nothing more to do for it (no step, no describe); one it does not list goes on with the same attempt while that attempt is still valid, and its wait does not grow, so the next read and write are made under the same rules as any other. A box whose instance left its group, but still runs, after its enrolment landed keeps that enrolment until an operator removes it; spec 3's regional worker reconciles roster membership against Auto Scaling group membership, including detached-but-running instances ("Activation gates", note). A terminated instance is kept from hosting and from capacity by main's termination fence, and a stopped one by the stale-edges rule and Task 6's restart hold. Only `ErrNotMember` or `ErrMembershipStale` refuses the attempt: the pass keeps it, asking the cloud nothing more, for `Patience`, then twice that, up to 32 times, before it begins another, so the record of a box the group has already replaced costs one roster write and one describe an hour. An invitation resets the wait. The memory of attempts is the console's own and starts afresh at a restart. Every membership read is logged (`Logf`, S5), with the box's reference, its attempt id, its instance id, both request ids ("none" when the call failed before the cloud answered), the read's time and what came of it: the audit main's roster has no field for. Between the final check and the `Invite` call there is nothing that waits: no I/O, channel, lock or store call, only the construction of the bound directory. |
| |
| The box's region is the one roster region whose group runs in the record's account and AWS region; the record is then verified with `boxid.Verifier` before anything is written. The record's key must be the group reference of the instance it proves (S3): a record under any other key, one the box chose, is refused before an attempt is begun. The box's report is read under that derived reference too (`Box.Group` and the record's proven `Cloud`, through `PrivateBoxReport`), never under the box's own say. The pass enrolls into the console's namespace and the `default` pool, and the box takes its group's role in the same write (2a). A deployment without a published host floor enrolls nothing (`ErrNoFloor`): no box could arm its guard. The console signs with the root key it already holds; it is the roster's only writer and the only membership confirmer. |
| |
| @@ -4097,34 +4404,51 @@ func TestABoxCannotStandInForAnother(t *testing.T) { |
| } |
| } |
| |
| -// inflightStore is the bucket with one Put that is still in flight when |
| -// the deadline passes: the first Put of key after armed is set commits |
| -// (when commit is set) and then waits for its context, as an S3 request |
| -// cut off by the deadline does. |
| +// inflightStore is the bucket with one slow Put: the first Put of key |
| +// after armed is set commits (when commit is set) and then, by mode, |
| +// waits for its context as an S3 request cut off by the deadline does |
| +// ("cancelled"), ignores its context and never answers ("ignores"), or |
| +// answers success only after the console's clock has passed the deadline |
| +// ("late"). |
| type inflightStore struct { |
| release.Store |
| key string |
| armed, commit bool |
| + mode string |
| + late func() |
| + hang chan struct{} |
| } |
| |
| func (s *inflightStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| if s.armed && key == s.key { |
| s.armed = false |
| + etag := "" |
| if s.commit { |
| - if _, err := s.Store.Put(ctx, key, body, ifMatch); err != nil { |
| + var err error |
| + if etag, err = s.Store.Put(ctx, key, body, ifMatch); err != nil { |
| return "", err |
| } |
| } |
| + switch s.mode { |
| + case "ignores": |
| + <-s.hang |
| + return etag, nil |
| + case "late": |
| + s.late() |
| + return etag, nil |
| + } |
| <-ctx.Done() |
| return "", ctx.Err() |
| } |
| return s.Store.Put(ctx, key, body, ifMatch) |
| } |
| |
| -// inflightAfterRead makes key's next Put hang past the deadline: the next |
| -// membership read takes 59.95 seconds, so its write has 50 milliseconds. |
| -func (w *enrollWorld) inflightAfterRead(key string, commit bool) *inflightStore { |
| - is := &inflightStore{Store: w.e.Directory.Store, key: key, commit: commit} |
| +// inflightAfterRead makes key's next Put slow in mode: the next |
| +// membership read takes 59.95 seconds, so its write has 50 milliseconds, |
| +// and a late answer comes a second after that. |
| +func (w *enrollWorld) inflightAfterRead(key string, commit bool, mode string) *inflightStore { |
| + is := &inflightStore{Store: w.e.Directory.Store, key: key, commit: commit, mode: mode, hang: make(chan struct{}), |
| + late: func() { w.clock = w.clock.Add(time.Second) }} |
| w.e.Directory.Store = is |
| w.cloud.during = func() { w.clock = w.clock.Add(59950 * time.Millisecond); is.armed = true } |
| return is |
| @@ -4132,20 +4456,20 @@ func (w *enrollWorld) inflightAfterRead(key string, commit bool) *inflightStore |
| |
| // S1: a Put still in flight at the deadline is cancelled there, and its |
| // result is unknown: the attempt and its wait stand, and the next pass |
| -// reads back. Here the invitation did not land, then (a second time) it |
| -// did, and the read-back finds it. |
| +// reads the bucket as it does every pass. Here the invitation did not |
| +// land, then (a second time) it did, and the next pass finds it. |
| func TestAPutInFlightAtTheDeadlineIsUnknown(t *testing.T) { |
| w := newEnrollWorld(t, "") |
| w.pass(t) |
| first, tries := w.attempt(t).AttemptID, w.e.attempts[newRef].tries |
| - w.inflightAfterRead("control/host-actor-invitations/"+newRef+".json", false) |
| + w.inflightAfterRead("control/host-actor-invitations/"+newRef+".json", false, "cancelled") |
| if got := w.pass(t); !strings.Contains(got, "in flight at the deadline") { |
| t.Fatalf("in flight: %q", got) |
| } |
| if _, ok := w.invitation(t); ok || w.attempt(t).AttemptID != first || w.e.attempts[newRef].tries != tries || w.e.attempts[newRef].refused != "" { |
| t.Fatalf("invited %v, seen %+v", ok, w.e.attempts[newRef]) |
| } |
| - w.inflightAfterRead("control/host-actor-invitations/"+newRef+".json", true) |
| + w.inflightAfterRead("control/host-actor-invitations/"+newRef+".json", true, "cancelled") |
| if got := w.pass(t); !strings.Contains(got, "in flight at the deadline") { |
| t.Fatalf("in flight, landed: %q", got) |
| } |
| @@ -4155,13 +4479,35 @@ func TestAPutInFlightAtTheDeadlineIsUnknown(t *testing.T) { |
| } |
| } |
| |
| -// The claim owner's read-back: an enrolling write cut off in flight that |
| -// did land is accepted only on a fresh membership read. A current member |
| -// is done, logged with its attempt, instance and request ids; one no |
| -// longer in the group is logged and paged once, and nothing else changes. |
| -// One that did not land leaves the attempt as it was, and the next pass |
| -// enrols. |
| -func TestAnEnrolmentLostToTheDeadlineIsReadBack(t *testing.T) { |
| +// U2: a store that ignores its context and never answers is cut off by |
| +// the hard per-call timeout, well before the deadline: the outcome is |
| +// unknown, the attempt and its wait stand, and nothing was invited. |
| +func TestAStoreThatIgnoresItsContextIsCutOffAtTheHardTimeout(t *testing.T) { |
| + was := storeCallTimeout |
| + storeCallTimeout = 100 * time.Millisecond |
| + t.Cleanup(func() { storeCallTimeout = was }) |
| + w := newEnrollWorld(t, "") |
| + w.pass(t) |
| + first, tries := w.attempt(t).AttemptID, w.e.attempts[newRef].tries |
| + is := &inflightStore{Store: w.e.Directory.Store, key: "control/host-actor-invitations/" + newRef + ".json", mode: "ignores", hang: make(chan struct{}), armed: true} |
| + t.Cleanup(func() { close(is.hang) }) |
| + w.e.Directory.Store = is |
| + start := time.Now() |
| + got := w.pass(t) |
| + if !strings.Contains(got, "did not answer in time") || time.Since(start) > 5*time.Second { |
| + t.Fatalf("after %s: %q", time.Since(start), got) |
| + } |
| + if _, ok := w.invitation(t); ok || w.attempt(t).AttemptID != first || w.e.attempts[newRef].tries != tries || w.e.attempts[newRef].refused != "" { |
| + t.Fatalf("invited %v, seen %+v", ok, w.e.attempts[newRef]) |
| + } |
| +} |
| + |
| +// U1, U2: an enrolling write whose outcome is unknown is settled by the |
| +// next pass's roster read, with nothing more. Not landed: the same |
| +// attempt, its wait unchanged, enrols on the next pass. Landed, whether |
| +// cut off in flight or answering success only after the deadline: the |
| +// roster lists the box, and the pass has nothing left to do for it. |
| +func TestAnUnknownEnrolmentIsSettledByTheRoster(t *testing.T) { |
| ready := func(t *testing.T) *enrollWorld { |
| w := newEnrollWorld(t, "") |
| w.pass(t) |
| @@ -4172,16 +4518,10 @@ func TestAnEnrolmentLostToTheDeadlineIsReadBack(t *testing.T) { |
| w.guardReady(t, inv.Enrollment, true) |
| return w |
| } |
| - var pages []string |
| - page := func(_ context.Context, subject, body string) error { |
| - pages = append(pages, subject+"\n"+body) |
| - return nil |
| - } |
| - |
| t.Run("did not land", func(t *testing.T) { |
| w := ready(t) |
| first, tries := w.attempt(t).AttemptID, w.e.attempts[newRef].tries |
| - w.inflightAfterRead(Key, false) |
| + w.inflightAfterRead(Key, false, "cancelled") |
| if got := w.pass(t); !strings.Contains(got, "in flight at the deadline") { |
| t.Fatalf("in flight: %q", got) |
| } |
| @@ -4193,48 +4533,26 @@ func TestAnEnrolmentLostToTheDeadlineIsReadBack(t *testing.T) { |
| t.Fatalf("next pass: %q", got) |
| } |
| }) |
| - t.Run("landed, still a member", func(t *testing.T) { |
| - w := ready(t) |
| - w.e.Page = page |
| - pages = nil |
| - w.inflightAfterRead(Key, true) |
| - w.pass(t) |
| - attempt := w.attempt(t).AttemptID |
| - w.cloud.during = nil |
| - steps, err := w.e.EnrollPass(context.Background()) |
| - if err != nil || len(steps) != 1 || steps[0].Box != newRef || !strings.Contains(steps[0].Step, "fresh read confirms membership") { |
| - t.Fatalf("read back: %+v %v", steps, err) |
| - } |
| - last := w.logs[len(w.logs)-1] |
| - if !strings.Contains(last, "attempt "+attempt+", instance i-new, request ids asg-req, ec2-req") || len(pages) != 0 { |
| - t.Fatalf("log %q, pages %q", last, pages) |
| - } |
| - if steps, _ := w.e.EnrollPass(context.Background()); len(steps) != 0 { |
| - t.Fatalf("read back twice: %+v", steps) |
| - } |
| - }) |
| - t.Run("landed, no longer a member", func(t *testing.T) { |
| - w := ready(t) |
| - w.e.Page = page |
| - pages = nil |
| - w.inflightAfterRead(Key, true) |
| - w.pass(t) |
| - w.cloud.during = nil |
| - w.cloud.members["i-new"] = func() Membership { m := member("routers-ie"); m.LifecycleState = "Detached"; return m }() |
| - steps, err := w.e.EnrollPass(context.Background()) |
| - if err != nil || len(steps) != 1 || !strings.Contains(steps[0].Step, "no longer a member") { |
| - t.Fatalf("read back: %+v %v", steps, err) |
| - } |
| - if len(pages) != 1 || !strings.Contains(pages[0], newRef) || !strings.Contains(pages[0], "i-new") { |
| - t.Fatalf("pages %q", pages) |
| - } |
| - // Paged once; the roster entry is left as it is. |
| - w.e.EnrollPass(context.Background()) |
| - o, _ := w.d.Read(context.Background()) |
| - if len(pages) != 1 || o.Roster.Boxes[newRef].State != "active" { |
| - t.Fatalf("pages %d, entry %+v", len(pages), o.Roster.Boxes[newRef]) |
| - } |
| - }) |
| + for name, mode := range map[string]string{"landed, cut off in flight": "cancelled", "landed, answered after the deadline": "late"} { |
| + t.Run(name, func(t *testing.T) { |
| + w := ready(t) |
| + w.inflightAfterRead(Key, true, mode) |
| + want := map[string]string{"cancelled": "in flight at the deadline", "late": "answered only after the deadline"}[mode] |
| + if got := w.pass(t); !strings.Contains(got, want) { |
| + t.Fatalf("unknown: %q", got) |
| + } |
| + w.cloud.during = nil |
| + calls := len(w.cloud.calls) |
| + steps, err := w.e.EnrollPass(context.Background()) |
| + if err != nil || len(steps) != 0 || len(w.cloud.calls) != calls { |
| + t.Fatalf("settled by the roster: %+v %v, calls %v", steps, err, w.cloud.calls) |
| + } |
| + o, _ := w.d.Read(context.Background()) |
| + if b := o.Roster.Boxes[newRef]; b.State != "active" || !o.Roster.EnrollmentAttempts[newRef].Consumed { |
| + t.Fatalf("roster %+v", b) |
| + } |
| + }) |
| + } |
| } |
| |
| // A refused attempt is kept, with no more reads, for its wait, then begun |
| @@ -4345,7 +4663,7 @@ func TestEnrollPassIsBuiltFromTheClaimAuthority(t *testing.T) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - if e.Namespace != "platform" || e.Pool != "default" || e.Patience != enrollPatience || len(e.Certs) == 0 || e.Directory.Bucket != "bucket" || e.Members != (noMembers{}) || e.Logf == nil || e.Page == nil { |
| + if e.Namespace != "platform" || e.Pool != "default" || e.Patience != enrollPatience || len(e.Certs) == 0 || e.Directory.Bucket != "bucket" || e.Members != (noMembers{}) || e.Logf == nil { |
| t.Fatalf("enroller %+v", e) |
| } |
| } |
| @@ -4382,7 +4700,7 @@ func TestEnrollPassLogsFailuresSparingly(t *testing.T) { |
| |
| - [ ] **Step 2: Run them to see them fail** |
| |
| -Run: `nice -n 19 go test ./internal/hostactors/ ./internal/console/ -run 'TestAutomaticEnrollment|TestAnInstanceOutside|TestAMembershipReadThatFails|TestAnInvitationNeeds|TestASlowRead|TestAPutInFlight|TestAnEnrolmentLost|TestABoxCannotStandIn|TestARefusedAttempt|TestEnrollPass' -count=1` |
| +Run: `nice -n 19 go test ./internal/hostactors/ ./internal/console/ -run 'TestAutomaticEnrollment|TestAnInstanceOutside|TestAMembershipReadThatFails|TestAnInvitationNeeds|TestASlowRead|TestAPutInFlight|TestAStoreThatIgnores|TestAnUnknownEnrolment|TestABoxCannotStandIn|TestARefusedAttempt|TestEnrollPass' -count=1` |
| Expected: FAIL, neither package builds (`undefined: AutoEnroller`, `undefined: newAutoEnroller`). |
| |
| - [ ] **Step 3: Write the pass and start it** |
| @@ -4467,25 +4785,8 @@ type AutoEnroller struct { |
| // Logf records each membership read's request ids (C2's audit: the |
| // roster has no field for them). nil is silent. |
| Logf func(format string, args ...any) |
| - // Page sends the operator an alert: the console's alerting.Page. nil |
| - // logs only. |
| - Page func(ctx context.Context, subject, body string) error |
| |
| attempts map[string]attemptSeen |
| - // unknown are the enrolling writes whose outcome the deadline left |
| - // unknown, by reference: the next pass reads them back (readBack). |
| - // Memory only: after a console restart such an enrolment is treated as |
| - // any other enrolled box, which is acceptable because its write was |
| - // issued before the deadline, so its membership read was less than a |
| - // minute old when the roster write began, and at most a moment past |
| - // that when it landed. |
| - unknown map[string]unknownEnrolment |
| -} |
| - |
| -// unknownEnrolment is an enrolling write the deadline cut off in flight. |
| -type unknownEnrolment struct { |
| - attemptID, instance string |
| - region Region |
| } |
| |
| // attemptSeen is what the pass remembers of a box's current attempt: its |
| @@ -4523,7 +4824,7 @@ func (e *AutoEnroller) EnrollPass(ctx context.Context) ([]EnrollStep, error) { |
| if err != nil { |
| return nil, err |
| } |
| - steps := e.readBack(ctx, o) |
| + var steps []EnrollStep |
| keys, err := e.Directory.Store.List(ctx, boxid.Prefix) |
| if err != nil { |
| return nil, err |
| @@ -4682,15 +4983,12 @@ func (e *AutoEnroller) confirmAndWrite(ctx context.Context, o Observed, v boxid. |
| d := e.Directory |
| if invited { |
| next, ev, err := d.EnrollAutomatic(ctx, e.Signer, o, v, in, raw, instance, e.Members, e.Now) |
| + // A write the deadline left unknown (ErrWriteDeadline) is settled |
| + // by the next pass's roster read: a box the roster lists is |
| + // enrolled, and the pass has nothing more to do for it; one it |
| + // does not list goes on with the same attempt, whose wait has not |
| + // grown. |
| e.logRead(in, instance, ev, "enrolled", err) |
| - if errors.Is(err, ErrWriteDeadline) { |
| - // The roster write may have landed: the next pass reads it |
| - // back and asks the cloud again before it accepts it. |
| - if e.unknown == nil { |
| - e.unknown = map[string]unknownEnrolment{} |
| - } |
| - e.unknown[in.Reference] = unknownEnrolment{attemptID: in.AttemptID, instance: instance, region: region} |
| - } |
| if err != nil { |
| return Observed{}, fmt.Sprintf("it could not be enrolled: %v", err), err |
| } |
| @@ -4704,7 +5002,10 @@ func (e *AutoEnroller) confirmAndWrite(ctx context.Context, o Observed, v boxid. |
| // Invite is main's and sealed. It runs on the store bound to the |
| // read's deadline (writeBound), the single enforcement point: its |
| // record read, its signature's Put and any read-back are refused once |
| - // the read is a minute old, and a call in flight then is cancelled. |
| + // the read is a minute old; a call in flight then, or still unanswered |
| + // 15 seconds after it began, is cut off as unknown, and so is one that |
| + // answers only after the deadline. The next pass settles it from the |
| + // bucket. |
| // Nothing that waits sits between the read and this call. |
| bd, bctx, cancel := writeBound(ctx, d, membershipConfirmation{observedAt: ev.ObservedAt}, e.Now) |
| defer cancel() |
| @@ -4716,64 +5017,6 @@ func (e *AutoEnroller) confirmAndWrite(ctx context.Context, o Observed, v boxid. |
| return Observed{}, "invited", nil |
| } |
| |
| -// readBack settles the enrolling writes whose outcome was unknown. One |
| -// that did not land is forgotten, and the box's attempt goes on as usual. |
| -// One that landed is accepted only after a fresh membership read, through |
| -// the same reader: a current member is done; an instance that is not is |
| -// logged and paged once, and 2b changes nothing else. What then keeps it |
| -// from hosting or counting toward capacity is main's: a terminated |
| -// instance's termination fence, which every placement and capacity |
| -// reader honours (hostops ExecutorChooser.eligible), and the stale-edges |
| -// and restart rules for a stopped one. An instance that left its group but |
| -// still runs keeps its enrolment until an operator removes it. A read that |
| -// fails is asked again next pass. |
| -func (e *AutoEnroller) readBack(ctx context.Context, o Observed) []EnrollStep { |
| - refs := make([]string, 0, len(e.unknown)) |
| - for ref := range e.unknown { |
| - refs = append(refs, ref) |
| - } |
| - sort.Strings(refs) |
| - var steps []EnrollStep |
| - for _, ref := range refs { |
| - u := e.unknown[ref] |
| - b, landed := o.Roster.Boxes[ref] |
| - if !landed || b.Cloud.InstanceID != u.instance { |
| - delete(e.unknown, ref) |
| - continue |
| - } |
| - in := EnrollmentRequest{Reference: ref, AttemptID: u.attemptID} |
| - c, err := confirmMembership(ctx, e.Members, u.region, u.instance, e.Now) |
| - switch { |
| - case err == nil: |
| - e.logOutcome(in, u.instance, c.evidence(), "enrolled: the write landed, and a fresh read confirms membership") |
| - steps = append(steps, EnrollStep{Box: ref, Step: "enrolled: the write landed, and a fresh read confirms membership"}) |
| - delete(e.unknown, ref) |
| - case errors.Is(err, ErrNotMember): |
| - e.logOutcome(in, u.instance, c.evidence(), "enrolled, but a fresh read finds it no longer a member: "+err.Error()+"; paging") |
| - e.pageNotMember(ctx, ref, u, err) |
| - steps = append(steps, EnrollStep{Box: ref, Step: "enrolled, but no longer a member of its group: the operator is paged"}) |
| - delete(e.unknown, ref) |
| - default: |
| - e.logOutcome(in, u.instance, c.evidence(), "enrolled; its membership could not be read again: "+err.Error()) |
| - steps = append(steps, EnrollStep{Box: ref, Step: "enrolled; its membership could not be read again yet"}) |
| - } |
| - } |
| - return steps |
| -} |
| - |
| -// pageNotMember tells the operator, once, that an enrolment landed after |
| -// its deadline for an instance no longer in its group. |
| -func (e *AutoEnroller) pageNotMember(ctx context.Context, ref string, u unknownEnrolment, why error) { |
| - if e.Page == nil { |
| - return |
| - } |
| - subject := fmt.Sprintf("Region HA: %s was enrolled, but its group no longer lists it", ref) |
| - body := fmt.Sprintf("The console enrolled %s (instance %s) from the group %s, but the enrolling write's result came back after its deadline, and a fresh check finds the instance is no longer a current member of that group: %v. Nothing more is done automatically. A terminated instance is fenced and leaves placement on its own. One that left the group but still runs keeps its enrolment until an operator removes it.", ref, u.instance, u.region.GroupName, why) |
| - if err := e.Page(ctx, subject, body); err != nil && e.Logf != nil { |
| - e.Logf("console: automatic box enrollment: the page for %s was not sent: %v", ref, err) |
| - } |
| -} |
| - |
| // logRead records one membership read: the box, its attempt, its instance, |
| // the cloud's two request ids and the read's time, and what came of it |
| // (C2's audit: the roster has no field for them). A call that failed |
| @@ -4852,7 +5095,6 @@ import ( |
| "log" |
| "time" |
| |
| - "github.com/carlosframework/platform/internal/alerting" |
| "github.com/carlosframework/platform/internal/boxid" |
| "github.com/carlosframework/platform/internal/hostactors" |
| "github.com/carlosframework/platform/internal/hostops" |
| @@ -4878,9 +5120,6 @@ const enrollPassInterval = 10 * time.Second |
| // before it begins another (doubling with each refusal). |
| const enrollPatience = 2 * time.Minute |
| |
| -// pageEnrollment sends the pass's operator alert; tests replace it. |
| -var pageEnrollment = alerting.Page |
| - |
| // newAutoEnroller is the pass over this console's claim authority (its |
| // roster, root signer, report evidence and namespace, and the default |
| // pool) and its own membership reader. An error when either is missing. |
| @@ -4893,10 +5132,7 @@ func newAutoEnroller(a *hostops.ProductionAuthority, members hostactors.Membersh |
| return nil, err |
| } |
| return &hostactors.AutoEnroller{Directory: a.Directory, Signer: a.Signer, Evidence: a.Evidence, Members: members, Certs: certs, |
| - Namespace: a.Namespace, Pool: "default", Now: time.Now, Patience: enrollPatience, Logf: log.Printf, |
| - Page: func(ctx context.Context, subject, body string) error { |
| - return pageEnrollment(ctx, alerting.LevelWarning, subject, body) |
| - }}, nil |
| + Namespace: a.Namespace, Pool: "default", Now: time.Now, Patience: enrollPatience, Logf: log.Printf}, nil |
| } |
| |
| // runEnrollPass runs the pass until ctx ends. Each box's step is logged |
| @@ -4992,28 +5228,10 @@ func consoleMembershipReader(ctx context.Context) (hostactors.MembershipReader, |
| defer t.Stop() |
| ```` |
| |
| -This task writes rows RHB-C65 and RHB-C66 (the operator alert's subject and body). Build it only once Paul has approved them, with his text where it differs from the drafts in `autoenroll.go`, and mark them built: |
| - |
| -`approved_region_ha_2b_copy_test.go`: |
| - |
| -````diff |
| ---- a/approved_region_ha_2b_copy_test.go |
| -+++ b/approved_region_ha_2b_copy_test.go |
| -@@ -36,7 +36,7 @@ |
| - // regionHA2bCopyBuilt is every id whose text the code or docs carry. The |
| - // task that writes a row's text adds its ids here in the same commit, once |
| - // Paul has approved the rows. |
| --var regionHA2bCopyBuilt = []string{"RHB-C1", "RHB-C2", "RHB-C3", "RHB-C4", "RHB-C5", "RHB-C6", "RHB-C7", "RHB-C8", "RHB-C9", "RHB-C10", "RHB-C11", "RHB-C12", "RHB-C13", "RHB-C14", "RHB-C15", "RHB-C16", "RHB-C17", "RHB-C18", "RHB-C64", "RHB-C67", "RHB-C19", "RHB-C20", "RHB-C21", "RHB-C22", "RHB-C23", "RHB-C24", "RHB-C25", "RHB-C26", "RHB-C27", "RHB-C28", "RHB-C29", "RHB-C30", "RHB-C31", "RHB-C32", "RHB-C33", "RHB-C34", "RHB-C35", "RHB-C36", "RHB-C37", "RHB-C38", "RHB-C39"} |
| -+var regionHA2bCopyBuilt = []string{"RHB-C1", "RHB-C2", "RHB-C3", "RHB-C4", "RHB-C5", "RHB-C6", "RHB-C7", "RHB-C8", "RHB-C9", "RHB-C10", "RHB-C11", "RHB-C12", "RHB-C13", "RHB-C14", "RHB-C15", "RHB-C16", "RHB-C17", "RHB-C18", "RHB-C64", "RHB-C67", "RHB-C19", "RHB-C20", "RHB-C21", "RHB-C22", "RHB-C23", "RHB-C24", "RHB-C25", "RHB-C26", "RHB-C27", "RHB-C28", "RHB-C29", "RHB-C30", "RHB-C31", "RHB-C32", "RHB-C33", "RHB-C34", "RHB-C35", "RHB-C36", "RHB-C37", "RHB-C38", "RHB-C39", "RHB-C65", "RHB-C66"} |
| - |
| - func mustParseRegionHA2bRecord(t *testing.T) []copyRow { |
| - t.Helper() |
| -```` |
| - |
| - [ ] **Step 4: Run the tests** |
| |
| -Run: `nice -n 19 go test ./internal/hostactors/ ./internal/console/ -run 'TestAutomaticEnrollment|TestAnInstanceOutside|TestAMembershipReadThatFails|TestAnInvitationNeeds|TestASlowRead|TestAPutInFlight|TestAnEnrolmentLost|TestABoxCannotStandIn|TestARefusedAttempt|TestEnrollPass|TestEnrollAutomatic' -count=1` |
| -Expected: PASS. Then `nice -n 19 go test . -run 'RegionHA2b' -count=1`: PASS once the rows are approved; while any built row is pending, `TestRegionHA2bBuiltCopyIsApproved` fails naming each one, and nothing else fails. |
| +Run: `nice -n 19 go test ./internal/hostactors/ ./internal/console/ -run 'TestAutomaticEnrollment|TestAnInstanceOutside|TestAMembershipReadThatFails|TestAnInvitationNeeds|TestASlowRead|TestAPutInFlight|TestAStoreThatIgnores|TestAnUnknownEnrolment|TestABoxCannotStandIn|TestARefusedAttempt|TestEnrollPass|TestEnrollAutomatic' -count=1` |
| +Expected: PASS. |
| |
| - [ ] **Step 5: The provider boundary** |
| |
| @@ -5022,7 +5240,7 @@ Expected: PASS. Then `nice -n 19 go test . -run 'RegionHA2b' -count=1`: PASS onc |
| ````diff |
| --- a/region_ha_boundary_test.go |
| +++ b/region_ha_boundary_test.go |
| -@@ -35,6 +35,8 @@ |
| +@@ -37,6 +37,8 @@ |
| "internal/host/installchannel.go", |
| "internal/hostactors/membershipread.go", |
| "internal/hostactors/groupref.go", |
| @@ -5031,7 +5249,7 @@ Expected: PASS. Then `nice -n 19 go test . -run 'RegionHA2b' -count=1`: PASS onc |
| } |
| |
| // regionHAAdapterFiles are spec 2's AWS adapters. Each is named for AWS so |
| -@@ -44,6 +46,7 @@ |
| +@@ -46,6 +48,7 @@ |
| "internal/console/awsregionmetric.go", |
| "internal/hostenv/awsssm.go", |
| "internal/hostactors/awsmembership/awsmembership.go", |
| @@ -5093,20 +5311,22 @@ Expected: PASS. With the selected names added and the sites not yet appended, it |
| ````diff |
| --- a/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv |
| -@@ -2,10 +2,14 @@ |
| - # 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; owner_unix.go and owner_other.go; awsssm.go, the SSM adapter) and the SSM SDK in go.mod. One new root-read unit, runHostEnv: seed's --from file, and write's check that /etc/carlos is a directory root owns and not a symlink; write runs as root and writes /etc/carlos/host.env 0600, whole, from the parameters. 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). The legacy seal stands. |
| +@@ -2,6 +2,7 @@ |
| + # Task 2: host.env from the deployment's parameters (internal/hostenv: hostenv.go, the layout, assembly and rendering, with an allowlist of names, shared values written create-only and the group-box marker added on write; cli.go, carlos system host-env write and seed; write_linux.go, the write through directories held by descriptor, and write_other.go, its refusal off Linux; awsssm.go, the SSM adapter) and the SSM SDK in go.mod. Three new root-read units. runHostEnv: seed's --from file. Ruled authority A,J4 on conditions (no box write to the parameters; allowlisted names, safe values, atomic write, checked parent; authority-selecting values cross-checked against the roster at the agent's use). writeHostEnv and checkedDir (Linux): write's fixed path, /etc/carlos/host.env, reached only through /etc and /etc/carlos opened O_NOFOLLOW|O_DIRECTORY and checked by fstat (root's, writable by no group or other), the file written 0600 in that directory, synced, renamed and the directory synced; proposed A,J4 under runHostEnv's ruling, as the checked parent its condition asks for. The legacy seal stands. |
| # Task 3: carlos system install-channel (internal/host installchannel.go, installchannel_linux.go): a fresh box installs its signed channel release into both protected slots through verifyPointer, hostfloor.CheckImage and stageCandidate, all unchanged, and on every run hands each directory on the agent's cache path (system, system/router and the version's) to the service user through bootstrap's own descriptor-anchored open, refusing a symlinked parent. One new root-read unit, channelInstall.run, whose one read site opens root's apply lock (systemApplier's lockPath). Ruled authority F,J4. stageCandidate, accepted-legacy, is called and not changed. The legacy seal stands. |
| - # Task 4: group membership, read by the console (internal/hostactors membershipread.go: MembershipReader, the unexported confirmation and the write bound, the single enforcement point of the 60-second deadline; enrollment_confirmation.go: EnrollAutomatic and CheckConfirmed make the read themselves and write on the bound store, and the box-written confirmation object is removed; groupref.go: GroupBoxReference; private.go: a group box's report read under the reference the console derives; awsmembership, the Auto Scaling and EC2 describe adapter) and the Auto Scaling SDK in go.mod. No root-read unit is added or changed: the read is the cloud's, through the reader. Envelope-only, and the seal stands. The record-write census gains deadlineStore.Put (nonrecord, ruled). |
| -+# Task 5: the console's automatic enrollment pass (internal/hostactors autoenroll.go: AutoEnroller, with the read-back of an enrolling write the deadline left unknown; internal/console enrollpass.go, started in StartClaimRecoveryPass with region HA on, and awsmembership.go, its membership reader). One new root-read unit, AutoEnroller.inputs, whose two read sites are the signed roster (Directory.Read) and the host floor (hostfloor.Read), in the console. Ruled accepted-legacy (J4 root-input). Invite, BeginEnrollmentAttempt and checkMembership, accepted-legacy, are called and not changed. The seal moves. |
| + # Task 4: group membership, read by the console (internal/hostactors membershipread.go: MembershipReader, the unexported confirmation and the write bound, the single enforcement point of the 60-second deadline, each store call cut off at 15 seconds; enrollment_confirmation.go: EnrollAutomatic and CheckConfirmed make the read themselves and write on the bound store, and the box-written confirmation object is removed; groupref.go: GroupBoxReference; private.go: a group box's report read under the reference the console derives; awsmembership, the Auto Scaling and EC2 describe adapter) and the Auto Scaling SDK in go.mod. No root-read unit is added or changed: the read is the cloud's, through the reader. Envelope-only, and the seal stands. The record-write census gains deadlineStore.Put (nonrecord, ruled). |
| ++# Task 5: the console's automatic enrollment pass (internal/hostactors autoenroll.go: AutoEnroller: an enrolling write the deadline left unknown is settled by the next pass's roster read; internal/console enrollpass.go, started in StartClaimRecoveryPass with region HA on, and awsmembership.go, its membership reader). One new root-read unit, AutoEnroller.inputs, whose two read sites are the signed roster (Directory.Read) and the host floor (hostfloor.Read), in the console. Ruled accepted-legacy (J4 root-input). Invite, BeginEnrollmentAttempt and checkMembership, accepted-legacy, are called and not changed. The seal moves. |
| @coverage . 3678cfe22582d35fe0d8e93d3fed898cdffa6fb2ce2d81e0252c8a8da77993b3 |
| - @coverage internal/hostenv 9e4a59312727547813535b526635f612b27148f262155e48aa307e85e2499a01 |
| - 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 and a directory that is a symlink or not root's; its read sites are that check of the directory and seed's --from file, an operator's host.env whose values go only to the parameter store file:Lstat@1495,file:ReadFile@1930 b1cdca1bc9ea21f6aaf8c06700f6f50446e738c1b3025321720b219ad1c90a00 |
| + @coverage internal/hostenv a3f3274a86cdf8194afb871301c1ba3289dfbdc9b703dd547178273df151cdaa |
| + internal/hostenv/cli.go runHostEnv authority A,J4 rev3=census-ruling-region-ha-2b;basis=carlos system host-env: write runs as root at a box's start-up and writes /etc/carlos/host.env, 0600, whole (temporary file and rename), from allowlisted parameters with safe values only, refusing a missing required value; its one read site is seed's --from file, an operator's host.env whose values go only to the parameter store; write's path is fixed and reached only through writeHostEnv file:ReadFile@1500 df4470176b229b311927ff0ac4f268d354c3a2357cac4300ac0992d2ebb90899 |
| +@@ -9,5 +10,8 @@ |
| + internal/hostenv/write_linux.go writeHostEnv authority A,J4 rev3=PROPOSED-region-ha-2b-r4;basis=carlos system host-env write (root, Linux): writes /etc/carlos/host.env only through /etc and /etc/carlos held by descriptor and checked by checkedDir; the new file is created 0600, O_EXCL|O_NOFOLLOW, in that directory, synced, renamed over host.env within it, and the directory synced; its read sites open the root and the temporary file file:Open@68,file:Openat@509 1245fc0f819ba73fb45545ffdcf92d5d4941802175e73bac209ab4d4238adff1 |
| @coverage internal/host 64ee4f561f2a28d584a6f7c82e068e57db4822f6cbb130116284bca583d2d390 |
| internal/host/installchannel.go channelInstall.run authority F,J4 rev3=census-ruling-region-ha-2b;basis=carlos system install-channel (root, at a fresh box's start-up): its one read site opens root's apply lock; the install goes through verifyPointer, hostfloor.CheckImage and stageCandidate into both protected slots, and refuses a box whose guard is armed or that already runs another release file:OpenFile@196 f0cdb6dd28e83da282c3b0d7bc2012dcd80db181a8cecbf1a2db6bcc41d1d5e3 |
| --@coverage internal/hostactors c35bd4a722d8502c2b0b55838acdd7d729000287a5b5dd6e2dbc435933bcfb8d |
| -+@coverage internal/hostactors fdc4434f0ddb17f9a2fd983d7b8141c5532d148ecdaa7b47fa2015e36bf8a03d |
| +-@coverage internal/hostactors 4bb1e4b527662d1fe59845dab28fad14215f73f1e69c4701b92da02d824c8de3 |
| ++@coverage internal/hostactors bf971d3b6a02b468d80fc959cc519b05c4976a34823b595cfae228028f5a3296 |
| @coverage internal/hostactors/awsmembership a3defad7179245492a3c1dcfce5e20f0f85db2e240b474c654acf211da16df98 |
| -+@coverage internal/console c87899e3b54db02bd0c7444b21c7ff59cb56d09dcfca9f83b3605f5f57107700 |
| ++@coverage internal/console 80fa5bba8bd23325ababaedc2b8ce810b25161c93a81c6d97dd7033bcc5df103 |
| +@legacy 2f4ca2a51661d8aae1e0f38a41efdcc9df9f1dd75411ca6742d6ee3ae71f1756 internal/hostactors/autoenroll.go AutoEnroller.inputs legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input;census-ruling-region-ha-2b file:Read@102,file:Read@305 c0ae921ee9d8df9de403d488d9708c565d659a7bf880199313f50bdc7c8f8127 |
| +internal/hostactors/autoenroll.go AutoEnroller.inputs legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input;census-ruling-region-ha-2b file:Read@102,file:Read@305 c0ae921ee9d8df9de403d488d9708c565d659a7bf880199313f50bdc7c8f8127 |
| ```` |
| @@ -5133,7 +5353,7 @@ Expected: PASS. |
| - [ ] **Step 8: Commit** |
| |
| ```bash |
| -git add internal/hostactors/autoenroll.go internal/hostactors/autoenroll_test.go internal/console/enrollpass.go internal/console/enrollpass_test.go internal/console/awsmembership.go internal/console/hostclaimops.go internal/console/claim_authority_census_test.go internal/console/testdata/claim-authority-sites.txt approved_region_ha_2b_copy_test.go region_ha_boundary_test.go internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv internal/provision/plan14_root_reads_test.go |
| +git add internal/hostactors/autoenroll.go internal/hostactors/autoenroll_test.go internal/console/enrollpass.go internal/console/enrollpass_test.go internal/console/awsmembership.go internal/console/hostclaimops.go internal/console/claim_authority_census_test.go internal/console/testdata/claim-authority-sites.txt region_ha_boundary_test.go internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv internal/provision/plan14_root_reads_test.go |
| git commit -m "region HA 2b: the console's automatic enrolment pass" |
| ``` |
| |
| @@ -6299,7 +6519,7 @@ Expected: PASS. |
| ````diff |
| --- a/region_ha_boundary_test.go |
| +++ b/region_ha_boundary_test.go |
| -@@ -37,6 +37,9 @@ |
| +@@ -39,6 +39,9 @@ |
| "internal/hostactors/groupref.go", |
| "internal/hostactors/autoenroll.go", |
| "internal/console/enrollpass.go", |
| @@ -6323,20 +6543,22 @@ Expected: PASS. |
| ````diff |
| --- a/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv |
| -@@ -3,13 +3,17 @@ |
| +@@ -3,15 +3,19 @@ |
| # Task 3: carlos system install-channel (internal/host installchannel.go, installchannel_linux.go): a fresh box installs its signed channel release into both protected slots through verifyPointer, hostfloor.CheckImage and stageCandidate, all unchanged, and on every run hands each directory on the agent's cache path (system, system/router and the version's) to the service user through bootstrap's own descriptor-anchored open, refusing a symlinked parent. One new root-read unit, channelInstall.run, whose one read site opens root's apply lock (systemApplier's lockPath). Ruled authority F,J4. stageCandidate, accepted-legacy, is called and not changed. The legacy seal stands. |
| - # Task 4: group membership, read by the console (internal/hostactors membershipread.go: MembershipReader, the unexported confirmation and the write bound, the single enforcement point of the 60-second deadline; enrollment_confirmation.go: EnrollAutomatic and CheckConfirmed make the read themselves and write on the bound store, and the box-written confirmation object is removed; groupref.go: GroupBoxReference; private.go: a group box's report read under the reference the console derives; awsmembership, the Auto Scaling and EC2 describe adapter) and the Auto Scaling SDK in go.mod. No root-read unit is added or changed: the read is the cloud's, through the reader. Envelope-only, and the seal stands. The record-write census gains deadlineStore.Put (nonrecord, ruled). |
| - # Task 5: the console's automatic enrollment pass (internal/hostactors autoenroll.go: AutoEnroller, with the read-back of an enrolling write the deadline left unknown; internal/console enrollpass.go, started in StartClaimRecoveryPass with region HA on, and awsmembership.go, its membership reader). One new root-read unit, AutoEnroller.inputs, whose two read sites are the signed roster (Directory.Read) and the host floor (hostfloor.Read), in the console. Ruled accepted-legacy (J4 root-input). Invite, BeginEnrollmentAttempt and checkMembership, accepted-legacy, are called and not changed. The seal moves. |
| + # Task 4: group membership, read by the console (internal/hostactors membershipread.go: MembershipReader, the unexported confirmation and the write bound, the single enforcement point of the 60-second deadline, each store call cut off at 15 seconds; enrollment_confirmation.go: EnrollAutomatic and CheckConfirmed make the read themselves and write on the bound store, and the box-written confirmation object is removed; groupref.go: GroupBoxReference; private.go: a group box's report read under the reference the console derives; awsmembership, the Auto Scaling and EC2 describe adapter) and the Auto Scaling SDK in go.mod. No root-read unit is added or changed: the read is the cloud's, through the reader. Envelope-only, and the seal stands. The record-write census gains deadlineStore.Put (nonrecord, ruled). |
| + # Task 5: the console's automatic enrollment pass (internal/hostactors autoenroll.go: AutoEnroller: an enrolling write the deadline left unknown is settled by the next pass's roster read; internal/console enrollpass.go, started in StartClaimRecoveryPass with region HA on, and awsmembership.go, its membership reader). One new root-read unit, AutoEnroller.inputs, whose two read sites are the signed roster (Directory.Read) and the host floor (hostfloor.Read), in the console. Ruled accepted-legacy (J4 root-input). Invite, BeginEnrollmentAttempt and checkMembership, accepted-legacy, are called and not changed. The seal moves. |
| +# Task 6: mode selection and group references (internal/host joinmode.go: selectMode, productionModeFacts, relaunched and crossCheck: a group box stopped and started since its enrollment, or whose host.env disagrees with the roster, is held; boxreference.go, metrics_loop.go's metricsBoxLabel and boxrecord.go's boxRecordLoop: a group box takes its group reference; internal/status fleet.go: the status listing drops reports no box has rewritten in a day before the key cap). statusLoop, accepted-legacy, is re-sealed as ruled: its tick is groupStatusTick, which publishes a group box's report only under its group reference, resolved afresh each tick, and nothing until it resolves; every other box's tick is statusTick's, unchanged. No other root-read unit is added or changed. The seal moves. |
| @coverage . 3678cfe22582d35fe0d8e93d3fed898cdffa6fb2ce2d81e0252c8a8da77993b3 |
| - @coverage internal/hostenv 9e4a59312727547813535b526635f612b27148f262155e48aa307e85e2499a01 |
| - 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 and a directory that is a symlink or not root's; its read sites are that check of the directory and seed's --from file, an operator's host.env whose values go only to the parameter store file:Lstat@1495,file:ReadFile@1930 b1cdca1bc9ea21f6aaf8c06700f6f50446e738c1b3025321720b219ad1c90a00 |
| + @coverage internal/hostenv a3f3274a86cdf8194afb871301c1ba3289dfbdc9b703dd547178273df151cdaa |
| + internal/hostenv/cli.go runHostEnv authority A,J4 rev3=census-ruling-region-ha-2b;basis=carlos system host-env: write runs as root at a box's start-up and writes /etc/carlos/host.env, 0600, whole (temporary file and rename), from allowlisted parameters with safe values only, refusing a missing required value; its one read site is seed's --from file, an operator's host.env whose values go only to the parameter store; write's path is fixed and reached only through writeHostEnv file:ReadFile@1500 df4470176b229b311927ff0ac4f268d354c3a2357cac4300ac0992d2ebb90899 |
| + internal/hostenv/write_linux.go checkedDir authority A,J4 rev3=PROPOSED-region-ha-2b-r4;basis=carlos system host-env write (root, Linux): opens one directory under one already held, O_NOFOLLOW|O_DIRECTORY, creating it 0755 first only when asked and missing, and refuses it unless fstat shows a directory owned by this process's user (root, as write requires) that no group or other can write file:Fstat@576,file:Openat@168,file:Openat@372 012257ac0866f3385044785d8abae9c31e61134928c394e22a2aeaef39ec9f3c |
| + internal/hostenv/write_linux.go writeHostEnv authority A,J4 rev3=PROPOSED-region-ha-2b-r4;basis=carlos system host-env write (root, Linux): writes /etc/carlos/host.env only through /etc and /etc/carlos held by descriptor and checked by checkedDir; the new file is created 0600, O_EXCL|O_NOFOLLOW, in that directory, synced, renamed over host.env within it, and the directory synced; its read sites open the root and the temporary file file:Open@68,file:Openat@509 1245fc0f819ba73fb45545ffdcf92d5d4941802175e73bac209ab4d4238adff1 |
| -@coverage internal/host 64ee4f561f2a28d584a6f7c82e068e57db4822f6cbb130116284bca583d2d390 |
| +@coverage internal/host 7f17f1c86771c44641869ff808c4befe2698bbb6ed81c6acbcf1e8cfde570729 |
| internal/host/installchannel.go channelInstall.run authority F,J4 rev3=census-ruling-region-ha-2b;basis=carlos system install-channel (root, at a fresh box's start-up): its one read site opens root's apply lock; the install goes through verifyPointer, hostfloor.CheckImage and stageCandidate into both protected slots, and refuses a box whose guard is armed or that already runs another release file:OpenFile@196 f0cdb6dd28e83da282c3b0d7bc2012dcd80db181a8cecbf1a2db6bcc41d1d5e3 |
| - @coverage internal/hostactors fdc4434f0ddb17f9a2fd983d7b8141c5532d148ecdaa7b47fa2015e36bf8a03d |
| + @coverage internal/hostactors bf971d3b6a02b468d80fc959cc519b05c4976a34823b595cfae228028f5a3296 |
| @coverage internal/hostactors/awsmembership a3defad7179245492a3c1dcfce5e20f0f85db2e240b474c654acf211da16df98 |
| - @coverage internal/console c87899e3b54db02bd0c7444b21c7ff59cb56d09dcfca9f83b3605f5f57107700 |
| + @coverage internal/console 80fa5bba8bd23325ababaedc2b8ce810b25161c93a81c6d97dd7033bcc5df103 |
| @legacy 2f4ca2a51661d8aae1e0f38a41efdcc9df9f1dd75411ca6742d6ee3ae71f1756 internal/hostactors/autoenroll.go AutoEnroller.inputs legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input;census-ruling-region-ha-2b file:Read@102,file:Read@305 c0ae921ee9d8df9de403d488d9708c565d659a7bf880199313f50bdc7c8f8127 |
| internal/hostactors/autoenroll.go AutoEnroller.inputs legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input;census-ruling-region-ha-2b file:Read@102,file:Read@305 c0ae921ee9d8df9de403d488d9708c565d659a7bf880199313f50bdc7c8f8127 |
| +@coverage internal/status a1eee66fc906d6a7275017fd30c346b64aaafe4f88a501538248e99e9d95c33d |
| @@ -6891,7 +7113,7 @@ Expected: PASS. |
| ````diff |
| --- a/region_ha_boundary_test.go |
| +++ b/region_ha_boundary_test.go |
| -@@ -40,6 +40,7 @@ |
| +@@ -42,6 +42,7 @@ |
| "internal/host/joinmode.go", |
| "internal/host/boxreference.go", |
| "internal/status/fleet.go", |
| @@ -6913,12 +7135,12 @@ Add RHB-C40 to RHB-C49, RHB-C62 and RHB-C63 to `regionHA2bCopyBuilt`, in the sam |
| ````diff |
| --- a/approved_region_ha_2b_copy_test.go |
| +++ b/approved_region_ha_2b_copy_test.go |
| -@@ -36,7 +36,7 @@ |
| +@@ -38,7 +38,7 @@ |
| // regionHA2bCopyBuilt is every id whose text the code or docs carry. The |
| // task that writes a row's text adds its ids here in the same commit, once |
| // Paul has approved the rows. |
| --var regionHA2bCopyBuilt = []string{"RHB-C1", "RHB-C2", "RHB-C3", "RHB-C4", "RHB-C5", "RHB-C6", "RHB-C7", "RHB-C8", "RHB-C9", "RHB-C10", "RHB-C11", "RHB-C12", "RHB-C13", "RHB-C14", "RHB-C15", "RHB-C16", "RHB-C17", "RHB-C18", "RHB-C64", "RHB-C67", "RHB-C19", "RHB-C20", "RHB-C21", "RHB-C22", "RHB-C23", "RHB-C24", "RHB-C25", "RHB-C26", "RHB-C27", "RHB-C28", "RHB-C29", "RHB-C30", "RHB-C31", "RHB-C32", "RHB-C33", "RHB-C34", "RHB-C35", "RHB-C36", "RHB-C37", "RHB-C38", "RHB-C39", "RHB-C65", "RHB-C66"} |
| -+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-C19", "RHB-C20", "RHB-C21", "RHB-C22", "RHB-C23", "RHB-C24", "RHB-C25", "RHB-C26", "RHB-C27", "RHB-C28", "RHB-C29", "RHB-C30", "RHB-C31", "RHB-C32", "RHB-C33", "RHB-C34", "RHB-C35", "RHB-C36", "RHB-C37", "RHB-C38", "RHB-C39", "RHB-C65", "RHB-C66", "RHB-C40", "RHB-C41", "RHB-C42", "RHB-C43", "RHB-C44", "RHB-C45", "RHB-C46", "RHB-C47", "RHB-C48", "RHB-C49", "RHB-C62", "RHB-C63"} |
| +-var regionHA2bCopyBuilt = []string{"RHB-C1", "RHB-C2", "RHB-C3", "RHB-C4", "RHB-C5", "RHB-C6", "RHB-C7", "RHB-C8", "RHB-C9", "RHB-C10", "RHB-C11", "RHB-C12", "RHB-C13", "RHB-C14", "RHB-C15", "RHB-C16", "RHB-C17", "RHB-C18", "RHB-C64", "RHB-C67", "RHB-C68", "RHB-C19", "RHB-C20", "RHB-C21", "RHB-C22", "RHB-C23", "RHB-C24", "RHB-C25", "RHB-C26", "RHB-C27", "RHB-C28", "RHB-C29", "RHB-C30", "RHB-C31", "RHB-C32", "RHB-C33", "RHB-C34", "RHB-C35", "RHB-C36", "RHB-C37", "RHB-C38", "RHB-C39"} |
| ++var regionHA2bCopyBuilt = []string{"RHB-C1", "RHB-C2", "RHB-C3", "RHB-C4", "RHB-C5", "RHB-C6", "RHB-C7", "RHB-C8", "RHB-C9", "RHB-C10", "RHB-C11", "RHB-C12", "RHB-C13", "RHB-C14", "RHB-C15", "RHB-C16", "RHB-C17", "RHB-C18", "RHB-C64", "RHB-C67", "RHB-C68", "RHB-C19", "RHB-C20", "RHB-C21", "RHB-C22", "RHB-C23", "RHB-C24", "RHB-C25", "RHB-C26", "RHB-C27", "RHB-C28", "RHB-C29", "RHB-C30", "RHB-C31", "RHB-C32", "RHB-C33", "RHB-C34", "RHB-C35", "RHB-C36", "RHB-C37", "RHB-C38", "RHB-C39", "RHB-C40", "RHB-C41", "RHB-C42", "RHB-C43", "RHB-C44", "RHB-C45", "RHB-C46", "RHB-C47", "RHB-C48", "RHB-C49", "RHB-C62", "RHB-C63"} |
| |
| func mustParseRegionHA2bRecord(t *testing.T) []copyRow { |
| t.Helper() |
| @@ -6936,20 +7158,22 @@ The two unit files are new root inputs, which census ruling region-ha-2b is aske |
| ````diff |
| --- a/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv |
| -@@ -4,10 +4,11 @@ |
| - # 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; enrollment_confirmation.go: EnrollAutomatic and CheckConfirmed make the read themselves and write on the bound store, and the box-written confirmation object is removed; groupref.go: GroupBoxReference; private.go: a group box's report read under the reference the console derives; awsmembership, the Auto Scaling and EC2 describe adapter) and the Auto Scaling SDK in go.mod. No root-read unit is added or changed: the read is the cloud's, through the reader. Envelope-only, and the seal stands. The record-write census gains deadlineStore.Put (nonrecord, ruled). |
| - # Task 5: the console's automatic enrollment pass (internal/hostactors autoenroll.go: AutoEnroller, with the read-back of an enrolling write the deadline left unknown; internal/console enrollpass.go, started in StartClaimRecoveryPass with region HA on, and awsmembership.go, its membership reader). One new root-read unit, AutoEnroller.inputs, whose two read sites are the signed roster (Directory.Read) and the host floor (hostfloor.Read), in the console. Ruled accepted-legacy (J4 root-input). Invite, BeginEnrollmentAttempt and checkMembership, accepted-legacy, are called and not changed. The seal moves. |
| +@@ -4,12 +4,13 @@ |
| + # Task 4: group membership, read by the console (internal/hostactors membershipread.go: MembershipReader, the unexported confirmation and the write bound, the single enforcement point of the 60-second deadline, each store call cut off at 15 seconds; enrollment_confirmation.go: EnrollAutomatic and CheckConfirmed make the read themselves and write on the bound store, and the box-written confirmation object is removed; groupref.go: GroupBoxReference; private.go: a group box's report read under the reference the console derives; awsmembership, the Auto Scaling and EC2 describe adapter) and the Auto Scaling SDK in go.mod. No root-read unit is added or changed: the read is the cloud's, through the reader. Envelope-only, and the seal stands. The record-write census gains deadlineStore.Put (nonrecord, ruled). |
| + # Task 5: the console's automatic enrollment pass (internal/hostactors autoenroll.go: AutoEnroller: an enrolling write the deadline left unknown is settled by the next pass's roster read; internal/console enrollpass.go, started in StartClaimRecoveryPass with region HA on, and awsmembership.go, its membership reader). One new root-read unit, AutoEnroller.inputs, whose two read sites are the signed roster (Directory.Read) and the host floor (hostfloor.Read), in the console. Ruled accepted-legacy (J4 root-input). Invite, BeginEnrollmentAttempt and checkMembership, accepted-legacy, are called and not changed. The seal moves. |
| # Task 6: mode selection and group references (internal/host joinmode.go: selectMode, productionModeFacts, relaunched and crossCheck: a group box stopped and started since its enrollment, or whose host.env disagrees with the roster, is held; boxreference.go, metrics_loop.go's metricsBoxLabel and boxrecord.go's boxRecordLoop: a group box takes its group reference; internal/status fleet.go: the status listing drops reports no box has rewritten in a day before the key cap). statusLoop, accepted-legacy, is re-sealed as ruled: its tick is groupStatusTick, which publishes a group box's report only under its group reference, resolved afresh each tick, and nothing until it resolves; every other box's tick is statusTick's, unchanged. No other root-read unit is added or changed. The seal moves. |
| +# Task 7: root's half of joining (internal/host joinroot.go: carlos system join, which runs guard-arm in process; units carlos-system-join.path and .service; bootstrap's enabledUnits arms the path). joinroot.go adds no root-read unit: RunSystemGuardArm, authority F,J4, is called and not changed. The two unit files are new root inputs, ruled authority F,J4 on the condition that the path unit's file is only a trigger: root's join reads nothing from it. The legacy seal stands. |
| @coverage . 3678cfe22582d35fe0d8e93d3fed898cdffa6fb2ce2d81e0252c8a8da77993b3 |
| - @coverage internal/hostenv 9e4a59312727547813535b526635f612b27148f262155e48aa307e85e2499a01 |
| - 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 and a directory that is a symlink or not root's; its read sites are that check of the directory and seed's --from file, an operator's host.env whose values go only to the parameter store file:Lstat@1495,file:ReadFile@1930 b1cdca1bc9ea21f6aaf8c06700f6f50446e738c1b3025321720b219ad1c90a00 |
| + @coverage internal/hostenv a3f3274a86cdf8194afb871301c1ba3289dfbdc9b703dd547178273df151cdaa |
| + internal/hostenv/cli.go runHostEnv authority A,J4 rev3=census-ruling-region-ha-2b;basis=carlos system host-env: write runs as root at a box's start-up and writes /etc/carlos/host.env, 0600, whole (temporary file and rename), from allowlisted parameters with safe values only, refusing a missing required value; its one read site is seed's --from file, an operator's host.env whose values go only to the parameter store; write's path is fixed and reached only through writeHostEnv file:ReadFile@1500 df4470176b229b311927ff0ac4f268d354c3a2357cac4300ac0992d2ebb90899 |
| + internal/hostenv/write_linux.go checkedDir authority A,J4 rev3=PROPOSED-region-ha-2b-r4;basis=carlos system host-env write (root, Linux): opens one directory under one already held, O_NOFOLLOW|O_DIRECTORY, creating it 0755 first only when asked and missing, and refuses it unless fstat shows a directory owned by this process's user (root, as write requires) that no group or other can write file:Fstat@576,file:Openat@168,file:Openat@372 012257ac0866f3385044785d8abae9c31e61134928c394e22a2aeaef39ec9f3c |
| + internal/hostenv/write_linux.go writeHostEnv authority A,J4 rev3=PROPOSED-region-ha-2b-r4;basis=carlos system host-env write (root, Linux): writes /etc/carlos/host.env only through /etc and /etc/carlos held by descriptor and checked by checkedDir; the new file is created 0600, O_EXCL|O_NOFOLLOW, in that directory, synced, renamed over host.env within it, and the directory synced; its read sites open the root and the temporary file file:Open@68,file:Openat@509 1245fc0f819ba73fb45545ffdcf92d5d4941802175e73bac209ab4d4238adff1 |
| -@coverage internal/host 7f17f1c86771c44641869ff808c4befe2698bbb6ed81c6acbcf1e8cfde570729 |
| +@coverage internal/host 7650e67baecb9b440c10993539befcb35992e55c48c87c90fbdff687818599f0 |
| internal/host/installchannel.go channelInstall.run authority F,J4 rev3=census-ruling-region-ha-2b;basis=carlos system install-channel (root, at a fresh box's start-up): its one read site opens root's apply lock; the install goes through verifyPointer, hostfloor.CheckImage and stageCandidate into both protected slots, and refuses a box whose guard is armed or that already runs another release file:OpenFile@196 f0cdb6dd28e83da282c3b0d7bc2012dcd80db181a8cecbf1a2db6bcc41d1d5e3 |
| - @coverage internal/hostactors fdc4434f0ddb17f9a2fd983d7b8141c5532d148ecdaa7b47fa2015e36bf8a03d |
| + @coverage internal/hostactors bf971d3b6a02b468d80fc959cc519b05c4976a34823b595cfae228028f5a3296 |
| @coverage internal/hostactors/awsmembership a3defad7179245492a3c1dcfce5e20f0f85db2e240b474c654acf211da16df98 |
| -@@ -17,3 +18,6 @@ |
| +@@ -19,3 +20,6 @@ |
| @coverage internal/status a1eee66fc906d6a7275017fd30c346b64aaafe4f88a501538248e99e9d95c33d |
| @legacy 30a3ee6b3365c202bf93b26087888a582c6b8d6df1a9f1fc6ba12e639ddd8684 internal/host/agent.go statusLoop legacy accepted-legacy accepted-legacy;rev3=census-ruling-region-ha-2b;basis=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos): main's body with its tick through groupStatusTick, so a box whose host.env carries the group marker (CARLOS_BOX_REFERENCE=group) publishes its status report only under its group reference, resolved afresh each tick, and nothing until it resolves, never under status.Label; every other box, fleet boxes included, gets statusTick with today's label and key; read sites unchanged whole:Route@6154,whole:Route@6181,whole:Route@7423,whole:Route@9437 666ddbc4779442db4c868253cf5062c690e20ad643197688305e0742f6de9283 |
| internal/host/agent.go statusLoop legacy accepted-legacy accepted-legacy;rev3=census-ruling-region-ha-2b;basis=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos): main's body with its tick through groupStatusTick, so a box whose host.env carries the group marker (CARLOS_BOX_REFERENCE=group) publishes its status report only under its group reference, resolved afresh each tick, and nothing until it resolves, never under status.Label; every other box, fleet boxes included, gets statusTick with today's label and key; read sites unchanged whole:Route@6154,whole:Route@6181,whole:Route@7423,whole:Route@9437 666ddbc4779442db4c868253cf5062c690e20ad643197688305e0742f6de9283 |
| @@ -7574,7 +7798,7 @@ Expected: PASS. The joining test listens on a loopback port; in a sandbox with n |
| ````diff |
| --- a/region_ha_boundary_test.go |
| +++ b/region_ha_boundary_test.go |
| -@@ -41,6 +41,7 @@ |
| +@@ -43,6 +43,7 @@ |
| "internal/host/boxreference.go", |
| "internal/status/fleet.go", |
| "internal/host/joinroot.go", |
| @@ -7596,18 +7820,20 @@ No root-read unit changed or was added: `runAgent` is untouched, `RunAgent` and |
| ````diff |
| --- a/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv |
| -@@ -5,10 +5,11 @@ |
| - # Task 5: the console's automatic enrollment pass (internal/hostactors autoenroll.go: AutoEnroller, with the read-back of an enrolling write the deadline left unknown; internal/console enrollpass.go, started in StartClaimRecoveryPass with region HA on, and awsmembership.go, its membership reader). One new root-read unit, AutoEnroller.inputs, whose two read sites are the signed roster (Directory.Read) and the host floor (hostfloor.Read), in the console. Ruled accepted-legacy (J4 root-input). Invite, BeginEnrollmentAttempt and checkMembership, accepted-legacy, are called and not changed. The seal moves. |
| +@@ -5,12 +5,13 @@ |
| + # Task 5: the console's automatic enrollment pass (internal/hostactors autoenroll.go: AutoEnroller: an enrolling write the deadline left unknown is settled by the next pass's roster read; internal/console enrollpass.go, started in StartClaimRecoveryPass with region HA on, and awsmembership.go, its membership reader). One new root-read unit, AutoEnroller.inputs, whose two read sites are the signed roster (Directory.Read) and the host floor (hostfloor.Read), in the console. Ruled accepted-legacy (J4 root-input). Invite, BeginEnrollmentAttempt and checkMembership, accepted-legacy, are called and not changed. The seal moves. |
| # Task 6: mode selection and group references (internal/host joinmode.go: selectMode, productionModeFacts, relaunched and crossCheck: a group box stopped and started since its enrollment, or whose host.env disagrees with the roster, is held; boxreference.go, metrics_loop.go's metricsBoxLabel and boxrecord.go's boxRecordLoop: a group box takes its group reference; internal/status fleet.go: the status listing drops reports no box has rewritten in a day before the key cap). statusLoop, accepted-legacy, is re-sealed as ruled: its tick is groupStatusTick, which publishes a group box's report only under its group reference, resolved afresh each tick, and nothing until it resolves; every other box's tick is statusTick's, unchanged. No other root-read unit is added or changed. The seal moves. |
| # Task 7: root's half of joining (internal/host joinroot.go: carlos system join, which runs guard-arm in process; units carlos-system-join.path and .service; bootstrap's enabledUnits arms the path). joinroot.go adds no root-read unit: RunSystemGuardArm, authority F,J4, is called and not changed. The two unit files are new root inputs, ruled authority F,J4 on the condition that the path unit's file is only a trigger: root's join reads nothing from it. The legacy seal stands. |
| +# Task 8: joining mode and the gate (internal/host joining.go; RunAgent calls hostingGate before runAgent; ClaimWiring.startRegional sets the health answer's hosting condition from the gate's decision and clears it when the worker ends). runAgent, authority B,C,D,F,J4, is not changed. No root-read unit changed or was added. Envelope-only, and the seal stands. |
| @coverage . 3678cfe22582d35fe0d8e93d3fed898cdffa6fb2ce2d81e0252c8a8da77993b3 |
| - @coverage internal/hostenv 9e4a59312727547813535b526635f612b27148f262155e48aa307e85e2499a01 |
| - 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 and a directory that is a symlink or not root's; its read sites are that check of the directory and seed's --from file, an operator's host.env whose values go only to the parameter store file:Lstat@1495,file:ReadFile@1930 b1cdca1bc9ea21f6aaf8c06700f6f50446e738c1b3025321720b219ad1c90a00 |
| + @coverage internal/hostenv a3f3274a86cdf8194afb871301c1ba3289dfbdc9b703dd547178273df151cdaa |
| + internal/hostenv/cli.go runHostEnv authority A,J4 rev3=census-ruling-region-ha-2b;basis=carlos system host-env: write runs as root at a box's start-up and writes /etc/carlos/host.env, 0600, whole (temporary file and rename), from allowlisted parameters with safe values only, refusing a missing required value; its one read site is seed's --from file, an operator's host.env whose values go only to the parameter store; write's path is fixed and reached only through writeHostEnv file:ReadFile@1500 df4470176b229b311927ff0ac4f268d354c3a2357cac4300ac0992d2ebb90899 |
| + internal/hostenv/write_linux.go checkedDir authority A,J4 rev3=PROPOSED-region-ha-2b-r4;basis=carlos system host-env write (root, Linux): opens one directory under one already held, O_NOFOLLOW|O_DIRECTORY, creating it 0755 first only when asked and missing, and refuses it unless fstat shows a directory owned by this process's user (root, as write requires) that no group or other can write file:Fstat@576,file:Openat@168,file:Openat@372 012257ac0866f3385044785d8abae9c31e61134928c394e22a2aeaef39ec9f3c |
| + internal/hostenv/write_linux.go writeHostEnv authority A,J4 rev3=PROPOSED-region-ha-2b-r4;basis=carlos system host-env write (root, Linux): writes /etc/carlos/host.env only through /etc and /etc/carlos held by descriptor and checked by checkedDir; the new file is created 0600, O_EXCL|O_NOFOLLOW, in that directory, synced, renamed over host.env within it, and the directory synced; its read sites open the root and the temporary file file:Open@68,file:Openat@509 1245fc0f819ba73fb45545ffdcf92d5d4941802175e73bac209ab4d4238adff1 |
| -@coverage internal/host 7650e67baecb9b440c10993539befcb35992e55c48c87c90fbdff687818599f0 |
| +@coverage internal/host 43a8c2fe1403991e94c9254c9c08b3fd71dc16e5dc8225356d96042765e42022 |
| internal/host/installchannel.go channelInstall.run authority F,J4 rev3=census-ruling-region-ha-2b;basis=carlos system install-channel (root, at a fresh box's start-up): its one read site opens root's apply lock; the install goes through verifyPointer, hostfloor.CheckImage and stageCandidate into both protected slots, and refuses a box whose guard is armed or that already runs another release file:OpenFile@196 f0cdb6dd28e83da282c3b0d7bc2012dcd80db181a8cecbf1a2db6bcc41d1d5e3 |
| - @coverage internal/hostactors fdc4434f0ddb17f9a2fd983d7b8141c5532d148ecdaa7b47fa2015e36bf8a03d |
| + @coverage internal/hostactors bf971d3b6a02b468d80fc959cc519b05c4976a34823b595cfae228028f5a3296 |
| @coverage internal/hostactors/awsmembership a3defad7179245492a3c1dcfce5e20f0f85db2e240b474c654acf211da16df98 |
| ```` |
| |
| @@ -7779,12 +8005,12 @@ Add RHB-C50 to RHB-C61 to `regionHA2bCopyBuilt`, in the same commit as their tex |
| ````diff |
| --- a/approved_region_ha_2b_copy_test.go |
| +++ b/approved_region_ha_2b_copy_test.go |
| -@@ -36,7 +36,7 @@ |
| +@@ -38,7 +38,7 @@ |
| // regionHA2bCopyBuilt is every id whose text the code or docs carry. The |
| // task that writes a row's text adds its ids here in the same commit, once |
| // Paul has approved the rows. |
| --var regionHA2bCopyBuilt = []string{"RHB-C1", "RHB-C2", "RHB-C3", "RHB-C4", "RHB-C5", "RHB-C6", "RHB-C7", "RHB-C8", "RHB-C9", "RHB-C10", "RHB-C11", "RHB-C12", "RHB-C13", "RHB-C14", "RHB-C15", "RHB-C16", "RHB-C17", "RHB-C18", "RHB-C64", "RHB-C67", "RHB-C19", "RHB-C20", "RHB-C21", "RHB-C22", "RHB-C23", "RHB-C24", "RHB-C25", "RHB-C26", "RHB-C27", "RHB-C28", "RHB-C29", "RHB-C30", "RHB-C31", "RHB-C32", "RHB-C33", "RHB-C34", "RHB-C35", "RHB-C36", "RHB-C37", "RHB-C38", "RHB-C39", "RHB-C65", "RHB-C66", "RHB-C40", "RHB-C41", "RHB-C42", "RHB-C43", "RHB-C44", "RHB-C45", "RHB-C46", "RHB-C47", "RHB-C48", "RHB-C49", "RHB-C62", "RHB-C63"} |
| -+var regionHA2bCopyBuilt = []string{"RHB-C1", "RHB-C2", "RHB-C3", "RHB-C4", "RHB-C5", "RHB-C6", "RHB-C7", "RHB-C8", "RHB-C9", "RHB-C10", "RHB-C11", "RHB-C12", "RHB-C13", "RHB-C14", "RHB-C15", "RHB-C16", "RHB-C17", "RHB-C18", "RHB-C64", "RHB-C67", "RHB-C19", "RHB-C20", "RHB-C21", "RHB-C22", "RHB-C23", "RHB-C24", "RHB-C25", "RHB-C26", "RHB-C27", "RHB-C28", "RHB-C29", "RHB-C30", "RHB-C31", "RHB-C32", "RHB-C33", "RHB-C34", "RHB-C35", "RHB-C36", "RHB-C37", "RHB-C38", "RHB-C39", "RHB-C65", "RHB-C66", "RHB-C40", "RHB-C41", "RHB-C42", "RHB-C43", "RHB-C44", "RHB-C45", "RHB-C46", "RHB-C47", "RHB-C48", "RHB-C49", "RHB-C62", "RHB-C63", "RHB-C50", "RHB-C51", "RHB-C52", "RHB-C53", "RHB-C54", "RHB-C55", "RHB-C56", "RHB-C57", "RHB-C58", "RHB-C59", "RHB-C60", "RHB-C61"} |
| +-var regionHA2bCopyBuilt = []string{"RHB-C1", "RHB-C2", "RHB-C3", "RHB-C4", "RHB-C5", "RHB-C6", "RHB-C7", "RHB-C8", "RHB-C9", "RHB-C10", "RHB-C11", "RHB-C12", "RHB-C13", "RHB-C14", "RHB-C15", "RHB-C16", "RHB-C17", "RHB-C18", "RHB-C64", "RHB-C67", "RHB-C68", "RHB-C19", "RHB-C20", "RHB-C21", "RHB-C22", "RHB-C23", "RHB-C24", "RHB-C25", "RHB-C26", "RHB-C27", "RHB-C28", "RHB-C29", "RHB-C30", "RHB-C31", "RHB-C32", "RHB-C33", "RHB-C34", "RHB-C35", "RHB-C36", "RHB-C37", "RHB-C38", "RHB-C39", "RHB-C40", "RHB-C41", "RHB-C42", "RHB-C43", "RHB-C44", "RHB-C45", "RHB-C46", "RHB-C47", "RHB-C48", "RHB-C49", "RHB-C62", "RHB-C63"} |
| ++var regionHA2bCopyBuilt = []string{"RHB-C1", "RHB-C2", "RHB-C3", "RHB-C4", "RHB-C5", "RHB-C6", "RHB-C7", "RHB-C8", "RHB-C9", "RHB-C10", "RHB-C11", "RHB-C12", "RHB-C13", "RHB-C14", "RHB-C15", "RHB-C16", "RHB-C17", "RHB-C18", "RHB-C64", "RHB-C67", "RHB-C68", "RHB-C19", "RHB-C20", "RHB-C21", "RHB-C22", "RHB-C23", "RHB-C24", "RHB-C25", "RHB-C26", "RHB-C27", "RHB-C28", "RHB-C29", "RHB-C30", "RHB-C31", "RHB-C32", "RHB-C33", "RHB-C34", "RHB-C35", "RHB-C36", "RHB-C37", "RHB-C38", "RHB-C39", "RHB-C40", "RHB-C41", "RHB-C42", "RHB-C43", "RHB-C44", "RHB-C45", "RHB-C46", "RHB-C47", "RHB-C48", "RHB-C49", "RHB-C62", "RHB-C63", "RHB-C50", "RHB-C51", "RHB-C52", "RHB-C53", "RHB-C54", "RHB-C55", "RHB-C56", "RHB-C57", "RHB-C58", "RHB-C59", "RHB-C60", "RHB-C61"} |
| |
| func mustParseRegionHA2bRecord(t *testing.T) []copyRow { |
| t.Helper() |
| @@ -7809,10 +8035,10 @@ Expected: PASS once the rows are approved. While any built row is pending, `Test |
| -@coverage . 3678cfe22582d35fe0d8e93d3fed898cdffa6fb2ce2d81e0252c8a8da77993b3 |
| +# Task 9 (system.go): the install-channel, join and host-env verbs join systemVerb's list and runSystem's dispatch, and the usage gains their lines. The two seed-overlay authority declarations are re-digested, class, task, basis and (empty) kinds unchanged: no root read is added. |
| +@coverage . 374a07016556167813b331508ee60f696872c43a09ba10904b6f3d949e0b70a6 |
| - @coverage internal/hostenv 9e4a59312727547813535b526635f612b27148f262155e48aa307e85e2499a01 |
| - 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 and a directory that is a symlink or not root's; its read sites are that check of the directory and seed's --from file, an operator's host.env whose values go only to the parameter store file:Lstat@1495,file:ReadFile@1930 b1cdca1bc9ea21f6aaf8c06700f6f50446e738c1b3025321720b219ad1c90a00 |
| - @coverage internal/host 43a8c2fe1403991e94c9254c9c08b3fd71dc16e5dc8225356d96042765e42022 |
| -@@ -22,3 +23,5 @@ |
| + @coverage internal/hostenv a3f3274a86cdf8194afb871301c1ba3289dfbdc9b703dd547178273df151cdaa |
| + internal/hostenv/cli.go runHostEnv authority A,J4 rev3=census-ruling-region-ha-2b;basis=carlos system host-env: write runs as root at a box's start-up and writes /etc/carlos/host.env, 0600, whole (temporary file and rename), from allowlisted parameters with safe values only, refusing a missing required value; its one read site is seed's --from file, an operator's host.env whose values go only to the parameter store; write's path is fixed and reached only through writeHostEnv file:ReadFile@1500 df4470176b229b311927ff0ac4f268d354c3a2357cac4300ac0992d2ebb90899 |
| + internal/hostenv/write_linux.go checkedDir authority A,J4 rev3=PROPOSED-region-ha-2b-r4;basis=carlos system host-env write (root, Linux): opens one directory under one already held, O_NOFOLLOW|O_DIRECTORY, creating it 0755 first only when asked and missing, and refuses it unless fstat shows a directory owned by this process's user (root, as write requires) that no group or other can write file:Fstat@576,file:Openat@168,file:Openat@372 012257ac0866f3385044785d8abae9c31e61134928c394e22a2aeaef39ec9f3c |
| +@@ -24,3 +25,5 @@ |
| @coverage internal/host/units 18a7bb1fe57b2044517e7acd3f97780beae35fd55e695c593f87a4ae7f3af27d |
| internal/host/units/carlos-system-join.path external authority F,J4 rev3=census-ruling-region-ha-2b;basis=systemd (PID 1) starts carlos-system-join.service when the joining agent touches /data/system-join-requested; the file is only a trigger: root reads nothing from it and takes no parameter from it file:external@0 8af1bea945a0ec5a475b766e6b55d7168bf6eff1bf963bd5c7d46f90130f4c5c |
| internal/host/units/carlos-system-join.service external authority F,J4 rev3=census-ruling-region-ha-2b;basis=systemd (PID 1) runs carlos system join as root: it verifies the signed invitation and this box's identity record itself, then runs guard-arm with the identity install-channel kept file:external@0 98c405ad69c7fd3e265ddfcaf176b0a5b98f0e61e35c4ae29a553211f63d230b |
| @@ -7843,7 +8069,7 @@ for os in linux darwin; do GOOS=$os nice -n 19 go vet . ./internal/host/ ./inter |
| ```` |
| |
| - [ ] Run once, on the final tree: `nice -n 19 go test ./internal/hostenv/ ./internal/hostactors/... -count=1`, the console's enrolment, capacity and authority-census tests with a `-run` filter, and the targeted `internal/host` tests of Tasks 3, 6, 7 and 8. Never the whole `internal/host` suite; CI's `make test` is that run. |
| -- [ ] Put the census overlay's rows and the three claim-authority sites in front of platform-1e as census ruling region-ha-2b (the table above), and record the ruling in the overlay's first line, as 2a's overlay does. |
| +- [ ] Put the census overlay's rows and the three claim-authority sites in front of platform-1e as census ruling region-ha-2b (the table above), recomputed on the build base; get its ruling on the two rows marked `PROPOSED-region-ha-2b-r4` (`writeHostEnv`, `checkedDir`), and record it in the overlay's first line, as 2a's overlay does. |
| - [ ] Delete `$TMPDIR/census` by exact path. |
| |
| ## 2c's seams |
| @@ -7877,9 +8103,10 @@ What 2c builds, and exactly what 2b needs from it: |
| |
| 1. **Who writes a region's group into the roster?** Answered (2026-10-16): a `region-group` phase of `carlos system claims run`, in a later, separate plan built on `feat/claim-activation-tools` once that branch merges. It takes the group's name and ARN from tofu's outputs (2c) and writes them, with the region's account, AWS region, namespaces and pools, in one signed roster write. 2b does not write it. Until it lands, no region has a group, the pass begins no attempt, and automatic enrolment stays dormant even with region HA on. |
| 2. **Namespace and pool.** The pass enrolls a group box into the console's own namespace and the `default` pool, and refuses a region that does not serve both. Is that right for every carloku.com region, or does a region's group need its own pool? |
| -3. **Census classes.** The proposed classes in the census table (A,J4 for `runHostEnv`, F,J4 for `channelInstall.run` and the join units, accepted-legacy for `AutoEnroller.inputs`, the re-sealed `statusLoop` row, nonrecord for `deadlineStore.Put`, reviewed sites for the claim-authority census) follow the nearest precedents; platform-1e may class them otherwise. In particular `runHostEnv` has no close precedent: it writes the root-owned host.env every unit reads. |
| +3. **Census classes.** Ruled (census-ruling-region-ha-2b), but for round 4's two new units: A,J4 is proposed for `writeHostEnv` and `checkedDir`, under `runHostEnv`'s ruling. The classes in the census table (A,J4 for `runHostEnv`, F,J4 for `channelInstall.run` and the join units, accepted-legacy for `AutoEnroller.inputs`, the re-sealed `statusLoop` row, nonrecord for `deadlineStore.Put`, reviewed sites for the claim-authority census) follow the nearest precedents; platform-1e may class them otherwise. In particular `runHostEnv` has no close precedent: it writes the root-owned host.env every unit reads. |
| 4. **Box references outside the label space.** Answered and built (round 2). platform-1e approved the `statusLoop` change round 1 stopped at, as a re-sealed `@legacy` row under census ruling region-ha-2b, on four conditions, all met: only group boxes take the override; the reference is `g-` and 20 hex digits of `sha256("carlos-box-ref-v1|" + account + "|" + instance)`, a pseudonym in the public feed; the console derives the key itself from the pinned instance (`PrivateBoxReport`, the pass, `checkConfirmed`) and never trusts the box's; and the four tests (Review Focus 13). See "Global Constraints: Box references" and Task 6. Automatic enrolment stays off for a region until a release with it is rolled there ("Activation gates", 1). |
| 5. **`DescribeAutoScalingGroups`.** Not needed: the console's read binds to the group name and ARN the signed roster names. Spec rev 16 drops it from §4. |
| 6. **The invitation's confirmation.** Resolved by H28: the console reads membership itself immediately before the invitation and again before the enrolment, so the same attempt serves both and no fresh attempt is begun after the guard-ready report. Spec rev 16's §5 says so. |
| 7. **`pendingTime` on a start: an activation gate.** The held-after-restart rule (C3) rests on EC2 setting the identity document's `pendingTime` at every start and never at a reboot. AWS documents the field as the launch time, but not that exact invariant (Astra round 2). Verify on dev before the first region: stop and start a group box (expect held, 503, replaced) and reboot one (expect hosting). Until then automatic enrolment stays off ("Activation gates", 2). |
| 8. **The allowlist's completeness.** `hostenv.Optional` was drawn from README's host.env surface and the variables the agent reads. A deployment whose boxes carry another variable will find `seed` refusing it by name; adding it is a code change and a review, which is the point. Check carloku.com's regions' host.env files against the list before the first seed. |
| +9. **Detached but running instances: spec 3's requirement (U1, platform-1e).** 2b removes nothing from the roster and pages no one: an instance detached from its group that keeps running keeps its enrolment until an operator removes it. Spec 3's requirements list carries it, and spec 2's hand-off to spec 3 (§12) says so: **the regional worker reconciles roster membership against Auto Scaling group membership, including detached-but-running instances.** |