carlos / platform Public

spec: region HA spec 1 rev 5.1, Astra rev 5 round 1 (B1-1 to B1-9)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev caaf5a04e6084e9c37fce2d99f108fcd96e86967 parent 5acb6ce
1 file changed, +309 −106
  • docs/superpowers/specs/2026-10-04-region-ha-box-placement-design.md +309 −106
diff --git a/docs/superpowers/specs/2026-10-04-region-ha-box-placement-design.md b/docs/superpowers/specs/2026-10-04-region-ha-box-placement-design.md
index 46fac1483..ec21ab17d 100644
--- a/docs/superpowers/specs/2026-10-04-region-ha-box-placement-design.md
+++ b/docs/superpowers/specs/2026-10-04-region-ha-box-placement-design.md
@@ -1,8 +1,24 @@
# Region HA, spec 1: placing an instance on a box
-Design, 2026-10-06, **rev 5**. DRAFT. Plan 1a is built and merged
+Design, 2026-10-06, **rev 5.1**. DRAFT. Plan 1a is built and merged
(aadb860); nothing else here is built.
+**Rev 5.1** answers Astra's first review of rev 5
+(`CODEX-FINDINGS-REGION-HA-REV5-1.md`, cited **B1-n**): five P1, three P2
+and one P3, all accepted; §20 maps each. A restarted probe now restores
+from its real predecessor, and `released` coverage is bound to one
+session (B1-1). A replacement target must make its own local install
+before probing (B1-2). An operator's volume change first suspends
+automatic disk evidence, and a volume that leaves a running box keeps it
+suspended until accounted for (B1-3). The volume guard protects its own
+tag, needs an attested Organizations policy, and without one there is no
+automatic fence (B1-4). A lost or unreadable execution-lease state keeps
+updates out (B1-5). A source lost in `starting` turns the drain into a
+failover (B1-6). A console bound to a fenced box leaves the census
+(B1-7). Code citations are refreshed (B1-9). Two points need Paul (§11):
+how the first box of an emptied region is enrolled (B1-8), and whether a
+live console with a stale report may hold up a failover (B1-7).
+
**Rev 5** restates the design against the host lifecycle claim as it now
stands, rev 6.25 (413b12cb on `design-replica-ownership`), with its
library on main (`internal/hostclaim`, `internal/hostguard`,
@@ -116,10 +132,10 @@ Standing rulings this spec keeps:
## 1. Why
Today a region is one box. Placement is per region: a bucket record has
-`Region` and `Pool` and no box (`internal/provision/provision.go:39`).
+`Region` and `Pool` and no box (`internal/provision/provision.go:49`).
Every box whose `CARLOS_REGION` matches mints the route
-(`internal/provision/reconcile.go:335`, called at `:799`). Nothing
-enforces "exactly one box": `reconcile.go:179` states it as intent only.
+(`inScope`, `internal/provision/reconcile.go:348`, called at `:820`). Nothing
+enforces "exactly one box": `reconcile.go:192` states it as intent only.
A second box in a region would break three things:
@@ -132,12 +148,13 @@ A second box in a region would break three things:
winning. Plan 1a settles it by installation stamp (§5.4).
3. **A box can serve a stale local copy.** The activator trusts a local
database as the warm cache because "on one box … the local copy can
- only be what we ourselves checkpointed" (`internal/activator/activator.go:1127`).
+ only be what we ourselves checkpointed" (`internal/activator/activator.go:1164-1168`).
With two boxes taking turns, that is false. Claim R6 removes the
warm cache altogether (H13).
And when a box dies, its instances stay down. EC2 auto-recover brings
-back the same machine, if it can (`infra/modules/region-host/main.tf:497`).
+back the same machine, if it can (`host_auto_recover`,
+`infra/modules/region-host/main.tf:521`).
## 2. Words
@@ -230,10 +247,10 @@ region (A1-1, H11), and every EBS volume it had attached was
- A region with one box behaves exactly as today, and every new rule
holds trivially there.
- Static sites and public addr routes still run on every box
- (`everywhereClass`, `reconcile.go:202`). Only relocatable hosts gain an
+ (`everywhereClass`, `reconcile.go:215`). Only relocatable hosts gain an
executor that can change; unit- and socket-backed routes keep theirs.
- Customer fleets keep their binding stamps (`internal/host/fleetbind.go`)
- and per-request fallback (`internal/edge/edge.go:2432`).
+ and per-request fallback (`fleetChannelFor`, `internal/edge/edge.go:2466`).
- The ingress model: any edge serves any app and backhauls home-run
hosts to their box (multi-region ingress). This spec changes which box
is "home", not how backhaul works.
@@ -294,7 +311,9 @@ read-only `autoscaling:DescribeAutoScalingInstances` showing the instance
a current member (`Pending` or `InService`) of that region's group, with
the group name from the deployment's configuration. Anything else, a
fixed box included, is enrolled only with an operator's approval. Spec 4
-builds this; the claim's D7 needs to allow it (L19).
+builds this; the claim's D7 needs to allow it (L19). A region with no
+active worker left has no confirmer; how its first box is enrolled is
+§11 question 1 (B1-8).
A box whose entry is not `active` with a proven `cloud` is never
eligible, and a claim naming one is never relocated automatically: an
@@ -423,7 +442,7 @@ deleted. Claim R6 replaces them: every admitted writer purges any local
database with the error-returning purge and restores from the certified
remote (claim §6.4a, "The baseline is always the certified remote").
Under P6-D4 every admission is cold. The activator's one-box comment at
-`activator.go:1127` is replaced by a reference to claim R6, not to HA3.
+`activator.go:1164-1168` is replaced by a reference to claim R6, not to HA3.
Plan 1a's `activator.WarmCopyTrusted` is not used, and nothing reads the
install marker to skip a restore (§19). A1-5's concern, that authority is
@@ -551,7 +570,7 @@ an always-continuous entry in the manifest whenever any of it changes:
- its timer rows (active rows and their bookkeeping, moves §9.4's
canonical digest), on a registration, a delivery or a rearm;
- the route's `NextDue`, the app's own next wake
- (`activator.go:2019-2030`), whenever the activator sets it;
+ (`activator.go:2204`), whenever the activator sets it;
- the route's `Replication` mode.
The new executor sets `NextDue` and `Replication` from it, as a move's
@@ -603,12 +622,12 @@ replication.
| Phase | Who acts | `op.exec` | `holder.stamp` | Rows (§5.3) | Admits |
|---|---|---|---|---|---|
-| `starting` | a worker writes the intent; `from` (if alive) and `to` write execution leases and `exec_ack` (D6). The barrier needs only `to` when the intent cites a fence | `from` | `stamp_from` | `from` ready; `to` held | `from`, as for `serving` |
+| `starting` | a worker writes the intent; `from` (if alive) and `to` write execution leases and `exec_ack` (D6). The barrier needs only `to` when the intent cites a fence, or once a source-loss CAS (below) has removed `from` | `from`, or `none` after a source-loss CAS | `stamp_from` | `from` ready; `to` held | `from`, as for `serving` |
| `releasing` | `from` runs the **release sequence** (below) and publishes its stop receipt; for failover the fence is already in the intent and this phase is skipped | `from` until evidence | unchanged | `from` ready until its admission fence closes, then held; `to` held | `from`, until its admission fence closes |
| `pinning` | a worker CAS citing the receipt or the fence: `op.exec: none`, a non-installing transition that keeps the stamp (claim R6-4, D5's form). Then it selects and journals the **pinned recovery set** (below) | `none` | unchanged | `from` held (if alive); `to` held | nobody |
-| `installing` | a worker CAS that journals the pin and a fresh prepared-generation id `P`, hands `op.exec` to `to`, and writes `holder.stamp` = this CAS's `gen` (R6-4: a new installation), adding `to` to `touched.boxes`. `to` activates that stamp under the host guard (claim §6.4), writes its inventory entry, and **builds `P`** (below) | `to` | `S'` | `to` held; `from` held | nobody: `to` writes local state under the guard, starts no app and admits no writer |
-| `installed` | a worker CAS once `P`'s install manifest is complete and verified, journaling `P`'s prepared authority (claim P6-D3) | `to` | `S'` | as `installing` | nobody |
-| `probing` | `to` enters it by CAS, then each probe attempt is one cold `AdmitWriter` (below), then the probe (moves §7.7, same region), within the probe deadline | `to` | `S'`, kept by the first admission | as `installing` | `to`, writer, probe only |
+| `installing` | a worker CAS that journals the pin and a fresh prepared-generation id `P`, hands `op.exec` to `to`, and writes `holder.stamp` = this CAS's `gen` (R6-4: a new installation), adding `to` to `touched.boxes`. `to` activates that stamp under the host guard (claim §6.4), writes its inventory entry, **builds `P`** and makes its **local install** (below) | `to` | `S'` | `to` held; `from` held | nobody: `to` writes local state under the guard, starts no app and admits no writer |
+| `installed` | a worker CAS once `P`'s install manifest is complete and verified, journaling `P`'s prepared authority (claim P6-D3). The executor then journals its **local install receipt** by CAS, if this CAS did not already carry it (a replacement makes its own, below) | `to` | `S'` | as `installing` | nobody: the executor may make its local install, nothing else |
+| `probing` | `to` enters it by CAS, which requires a local install receipt for exactly `op.exec`, its enrollment and `holder.stamp`; then each probe attempt is one cold `AdmitWriter` (below), then the probe (moves §7.7, same region), within the probe deadline | `to` | `S'`, kept by the first admission | as `installing` | `to`, writer, probe only |
| `probe-failed` | `to` runs the release sequence for its own session | `to` until evidence | unchanged | as `installing` | nobody |
| `serving` | a worker CAS back to `serving` with `participants: [to]`; `from` gets a `park-local` chore unless it is discharged under H10 (§5.9) | `to` | `S'` | `to` ready; `from` held until its chore retires the row | `to` |
@@ -689,43 +708,72 @@ in place of a staged replica:
from the snapshot then skips every later write (moves R5-1);
- write `P`'s files manifest, create-only, as the pinned manifest's
entries (objects named where they are, §5.6), with the files, env and
- wake state;
-- apply the files and env to the local directory, `ReplaceTimers` the
- wake state, set `NextDue` and `Replication`, `RenderHeld` and
- `MaterializeHeld` with current inputs, recorded as the relocation's
- inputs (P6-D3);
-- journal the local install, last.
-
-`installed` is written only after a fresh read verifies all three
-objects. `generation.json` is not written here: the probe's admission
-publishes it (moves §7.14).
+ wake state.
+
+That is the **remote build**: immutable, built once, and complete when
+a fresh read verifies all three objects. `installed` records it.
+`generation.json` is not written here: the probe's admission publishes
+it (moves §7.14).
+
+**The local install (B1-2)** is separate, because it belongs to one box.
+The box that is `op.exec`, under the host guard with the stamp it
+activated:
+
+- applies the files and env from `P`'s files manifest to the local
+ directory, `ReplaceTimers` the wake state, and sets `NextDue` and
+ `Replication`;
+- runs `RenderHeld` and `MaterializeHeld` with current inputs, recorded
+ as the relocation's inputs (P6-D3);
+- journals each step locally, and then, last, records its **local
+ install receipt** in the op journal by CAS:
+ `local_install: {box, enrollment, stamp, files_manifest_version,
+ journal_sha256}`.
+
+A receipt counts only for the box, enrollment and stamp it names, so a
+replacement executor (new stamp, below) never inherits its
+predecessor's. Remaking the local install is not a reinstall: `P` is not
+touched.
**The probe's admission.** Each probe attempt is one `AdmitWriter`
(claim §6.4a), made by `to` as `op.exec`, bound to the observed `op.id`
and phase `probing`:
- **mode** cold, always (P6-D4, H13);
-- **generation:** the first attempt opens `P`, which no admission has
- named (P6-D3's opened-once evidence). A restarted attempt is cold into
- a fresh generation restored from `P`, never `P` again (claim §6.4a item
- 3);
+- **generation and baseline (B1-1):** the first attempt opens `P`,
+ which no admission has named (P6-D3's opened-once evidence), and
+ restores it from its pinned snapshot and anchor
+ (`genselect.SelectPrepared`). Every later attempt is cold into a fresh
+ generation whose baseline is chosen as for any cold admission:
+ `genselect.Select` from the predecessor, `holder.generation` as
+ observed, through the unfinished run (claim §6.4a item 3; moves §7.14's
+ restore row). So attempt three restores attempt two's generation with
+ everything it replicated, not `P`. Prepared authority is used once;
- **`KeepStamp`** on the first attempt, because no writer has been
admitted since the `installing` CAS issued `S'` (`holder.stamp >
holder.session`). Later attempts take a new stamp;
-- **coverage:** for a drain, a rebalance or a probe failure the pin
- reaches the released box's stopped-database position, so nothing was
- lost. Rev 5 requests a coverage value for that, `released` (L9). Until
- L9 is accepted, relocation by drain is not enabled (§8), because
- coverage `""` would declare a false loss on every drain. For a
- failover, coverage is `""`: the admission records `writes_lost` for
- `from`'s last session with `remote_max_txid` = the pin's `N`, and `to`
- raises the alert. That is H3's written-down loss, declared, never
- silent;
-- then, as for every cold admission: purge, restore `P` from its pinned
- snapshot and anchor (`genselect.SelectPrepared`), start, readiness,
- `generation.json`, then `PublishGeneration`, which queues the
- `park-generation` chore that retires `generation_from` in this region
- (claim §6.4a item 5);
+- **coverage, per admission (B1-1):** decided for the session the
+ admission's predecessor actually is, the last entry of
+ `holder.admissions`, never for the operation as a whole:
+ - `released` (L9) only on the admission that opens `P`, and only when
+ that predecessor is exactly the `{box, session}` whose release
+ receipt this op journaled and whose `final_txid` the pin reached.
+ That is a drain, a rebalance, or the nested relocation after a
+ `probe-failed`. Until L9 is accepted, relocation by drain is not
+ enabled (§8), because coverage `""` would declare a false loss on
+ every drain;
+ - `""` everywhere else: a failover (the admission records
+ `writes_lost` for `from`'s last session, `remote_max_txid` the pin's
+ `N`, H3's written-down loss), and every later probe attempt, whose
+ predecessor is the previous attempt's uncertified session. `to`
+ raises one alert per declaration;
+- then, as for every cold admission: purge, restore the chosen baseline,
+ start, readiness, `generation.json`, then `PublishGeneration`, which
+ queues `park-generation` for the dead generations of this region in
+ the chain (`generation_from` at the first publication; an earlier
+ attempt's generation at a later one) (claim §6.4a item 5). A queued
+ park never touches a generation a later attempt still needs: the
+ baseline is always the newest generation, protected as the unfinished
+ run until its successor publishes (claim §6.2a);
- **limits:** at most three attempts within the probe deadline, as a
landing's (claim §6.4a item 7). A probe never marks the host failed;
running out of attempts is `probe-failed`.
@@ -746,9 +794,10 @@ admitted reads again, and which publication retires.
**The no-reinstall boundary (A1-3).** Once the `installed` CAS succeeds:
- nobody builds `P` again: replay from `installed` or `probing` goes
- forward (probe, then `serving`), never back to `installing`;
+ forward (local install if the executor has no receipt, probe, then
+ `serving`), never back to `installing`;
- `P` is opened at most once. After its first admission, every restart
- takes a fresh generation from it;
+ takes a fresh generation from its predecessor;
- a worker or `to` that paused and resumes revalidates the phase under
the host guard, with the stamp it captured, and stops.
@@ -774,12 +823,22 @@ that phase.
| terminated in `starting` or `releasing` | `to`'s termination fence | the **same** phase. The source's release requirements are unchanged: a drain still needs `from`'s stop evidence |
| terminated in `pinning` | `to`'s termination fence | `pinning` |
| terminated in `installing` | `to`'s termination fence | `installing`, with `op.exec: to'`, a new stamp and a fresh prepared id `P'`, from the journaled pin. The old `P` was never opened; the at-rest sweep parks it |
-| terminated at `installed`, or in `probing` before its first `AdmitWriter` succeeded | `to`'s termination fence | `installed`, with `op.exec: to'` and a new stamp. `P` was never opened, so `to'` opens it |
+| terminated at `installed`, or in `probing` before its first `AdmitWriter` succeeded (no admission entry names `P`) | `to`'s termination fence | `installed`, with `op.exec: to'`, a new stamp, and no local install receipt. `to'` activates the stamp, makes its own local install from `P`'s files manifest and records its receipt (B1-2), then enters `probing`. `P` was never opened, so `to'` opens it |
| terminated after its first `AdmitWriter` | `to`'s termination fence | a nested failover from `pinning`, pinning from `holder.generation` as observed (`P` or a later fresh generation, with `to`'s replicated writes). Coverage `""` declares `to`'s sessions |
| alive, but its probe fails or misses the **probe deadline** (10 minutes) | `to`'s release sequence, in `probe-failed` | a nested relocation from `pinning`, pinned to at least `to`'s journaled `final_txid` and `manifest_seq`, so writes made during the probe are kept. `to` is excluded from the choice for this host |
-- `from` terminated during a drain's `releasing` turns it into a
- failover, with the termination fence as evidence.
+**Losing the source (B1-6).** A drain or rebalance whose `from` is
+terminated before its stop receipt becomes a failover, in one CAS
+conditional on `op.id` and the phase, citing `from`'s fence:
+
+| `from` terminated | The CAS | The op resumes at |
+|---|---|---|
+| in `starting`, acknowledged or not | journals `failover_fence` (key, SHA-256) and `reason: failover`, keeping the intent and every acknowledgement; removes `from` from `participants` and from the barrier; sets `op.exec: none`, keeping the stamp (claim D5's non-installing form); adds `from` to `lost` and discharges its `park-local` chores (§5.9) | `starting`, waiting only for `to`'s acknowledgement; the next CAS goes to `pinning` with the fence as its evidence, skipping `releasing` |
+| in `releasing`, before its receipt | the same | `pinning`, with the fence as evidence |
+| after its receipt (`pinning` on) | `from` only goes on `lost` and its chores are discharged; nothing else changes | the same phase |
+
+The pin for a converted op follows the failover rule (fence plus 30
+seconds), and the first admission's coverage is `""`.
- **The bound.** After two replacements of `to` in one op, the placer
stops at the current phase's safe point (never between `installed`
and a probe) and marks the op `stuck`. It alerts, and the op waits for
@@ -810,12 +869,16 @@ only when all three of these hold:
1. **A volume record.** The newest **volume record** taken while the
instance was `running` (below) is no more than **2 hours** older than
the terminated observation, names the instance in the roster entry's
- `cloud`, and lists at least one EBS volume. It, and every record
- written after it, shows every attached EBS volume
- `DeleteOnTermination: true`.
-2. **The IAM guard is in place** (§6): nobody but an operator role can
- change a platform box's block device mapping or attach a volume to it,
- so the record cannot go stale behind the platform's back.
+ `cloud`, shows the guard's tag on it, and lists at least one EBS
+ volume. It, and every record written after it, shows every attached
+ EBS volume `DeleteOnTermination: true`. And **disk evidence is not
+ suspended** for the enrollment (below, B1-3).
+2. **The IAM guard is in place and attested** (§6, B1-4): an Organizations
+ policy, recorded in a valid guard attestation, denies every principal
+ but the one volume-change role any change to a platform box's block
+ device mapping, any attach or detach, and any change to the guard's
+ tag, so the record cannot go stale behind the platform's back. With
+ no valid attestation, nothing is fenced automatically.
3. **A terminated read.** A regional worker, with its own read-only
rights in the box's AWS region, observes state exactly `terminated`
for the instance id, account (`OwnerId`) and region in the roster
@@ -859,6 +922,38 @@ record is never more than about an hour old:
never itself termination evidence.
- A record showing a `false` volume on a running box alerts at once:
that box can never be fenced automatically until the volume is fixed.
+- The record also lists the instance's tags as described, so the fence
+ can check the guard's tag (§6).
+
+**Suspending disk evidence (B1-3).** A record's age bound cannot cover an
+operator's change made just before a termination, and a detached volume
+is not in any later record at all. So disk evidence is suspended
+**before** any exempt change, and resumes only after the change is
+accounted for:
+
+- **Before.** The only principal the guard exempts is the volume-change
+ role (§6). `carlos ops box volumes change <box>` first writes a
+ create-only hold, `control/box-volumes/<enrollment>/hold-<id>.json`
+ (`{id, operator, reason, at}`), and only then assumes that role with
+ the session tag `carlos:volume-hold = <id>`, which the role's trust
+ policy requires. A box with a hold that has no later release is never
+ fenced automatically: its termination goes to the operator path.
+- **Removed volumes.** Whenever a worker's describe of a running box
+ shows a volume that the previous record listed and this one does not,
+ the new record carries `removed_volumes`, and that also suspends disk
+ evidence, hold or no hold: the volume may survive with the box's data.
+- **After.** `carlos ops box volumes release <box> <hold id>` resumes it
+ only when a worker's running record written after the hold shows every
+ attached volume `true`, and, for every volume removed since the
+ enrollment's last release, the operator attests (as in claim D2) that
+ it was destroyed or never held host artifacts. The CLI writes
+ `release-<id>.json` create-only, naming the record and each
+ attestation. The fence then needs a running record newer than the
+ release.
+
+A hold or removal therefore never leaves a window: from the hold's write
+until a release, the box cannot be fenced automatically, whatever its
+records say.
**What never counts.**
@@ -952,6 +1047,16 @@ alerting, and owns the loop that reads the queue and history.
to `retired`, citing the record, when it next runs. Until then the
record alone excludes the box from the census for lifecycle starts
(L5), so failover never waits for a console.
+- **A console that ran on the box leaves the census too (B1-7).** Claim
+ D7 requires a fresh report from every console able to mutate the
+ namespace. A console enrollment gains `compute: {box, enrollment}`,
+ the platform box its process runs on, set at console enrollment and
+ checked against every console report, which that box's trusted writer
+ publishes with its own `boot_id`. A console whose `compute` box has a
+ valid fence record is excluded from the census exactly as the box is
+ (L5). A console with no `compute` binding is never excluded this way.
+ A console whose box is alive but whose report is stale still blocks a
+ relocation start; whether it should is §11 question 2.
- **The loss is recorded twice.** The admission that takes over records
`writes_lost` (§5.8), and the console copies the fence into claim D4's
durable loss record when it next runs, with timer knowledge `partial`:
@@ -993,10 +1098,12 @@ acted on), under root's existing system-update lock:
1. writes an **update-begun** marker, durably, beside the update journal
(`/opt/carlos/system/guard/update-begun.json`, naming the update);
-2. then scans `<DataDir>/.hostexec/` for any host whose state is
- `pending` or `active`. If any is found, the box is a participant in a
- running claim operation: root removes its marker, refuses the update,
- and leaves the console's activation reservation held, to retry later;
+2. then scans `<DataDir>/.hostexec/` completely. **Any** controller
+ record there, in any state (`pending`, `active` or `lost`), and any
+ record root cannot read or parse, or a directory it cannot list, means
+ the box may still be a participant: root removes its marker, refuses
+ the update, and leaves the console's activation reservation held, to
+ retry later (B1-5);
3. otherwise it proceeds. The marker stays until the update finishes or
is recovered.
@@ -1004,6 +1111,15 @@ Boot recovery of an update already begun before any participation needs
no new check: no participant acknowledged meanwhile, because step 2 of
the participant's side saw the marker.
+**When exclusion ends (B1-5).** A controller record is removed only by
+claim D6's clearing: a fresh claim read showing `serving`, `free`, or
+that the box no longer participates, **and** any cleanup the record
+requires complete. A `lost` record whose cleanup-only termination failed
+is never cleared: claim D6 keeps that deny, and root's refusal stays
+with it. A box whose processes may still run under an operation never
+takes an executable update. An operator who needs to update such a box
+first resolves the host (`carlos ops host resolve`).
+
**The race.** If the console reserves an update for `to` after the
worker's roster read, the start proceeds, and the reservation is
harmless by itself. Whichever of `to`'s acknowledgement and root's
@@ -1011,8 +1127,10 @@ begin writes its marker first, the other sees it and refuses: either
the update waits until the operation returns to `serving` and D6 clears
the controller's state, or the acknowledgement waits until the update
finishes. Root's refusal is what closes the race (H14). A box stays a
-participant from its `pending` state until D6 clears it, so an update
-never runs under a relocation's `installing`, `probing` or release.
+participant from its `pending` state until D6 clears its record, through
+`active` and `lost` alike, so an update never runs under a relocation's
+`installing`, `probing` or release, nor while a lost controller's
+cleanup is unfinished.
This holds for every claim operation a box takes part in, not only
relocations. L6 asks the claim to make it D7's rule for box-started
@@ -1031,22 +1149,39 @@ operations.
own grants: `s3:ListBucketVersions`, `s3:GetObjectVersion` and
`s3:DeleteObjectVersion` on its region's data bucket (claim §6.2a),
which `genselect` and the parker use.
-- **The volume guard (H15).** An Organizations service control policy on
- the deployment's account denies `ec2:ModifyInstanceAttribute`,
- `ec2:AttachVolume` and `ec2:DetachVolume` on instances tagged as
- platform boxes (`aws:ResourceTag/carlos:role = platform-box`) to every
- principal whose ARN is not one of the deployment's operator roles.
- `DetachVolume` is there because a volume detached from a running box
- survives termination with the box's data, which is exactly what the
- fence must rule out. Where the account
- has no Organizations, tofu puts the same Deny in every role and user it
- manages; that is weaker, since it cannot bind principals tofu does not
- know, and `carlos system` says so. The launch template and the
- `region-host` module create every volume with `DeleteOnTermination`.
- An operator who attaches or changes a volume on a platform box must
- refresh its volume record before leaving it (`carlos ops box volumes
- refresh <box>`); until the next record, the box's fence rests on the
- previous one, which is why §5.9 bounds its age.
+- **The volume guard (H15, B1-4).** An Organizations service control
+ policy on the deployment's account denies, to every principal whose
+ ARN is not the deployment's **volume-change role**:
+ - `ec2:ModifyInstanceAttribute`, `ec2:AttachVolume` and
+ `ec2:DetachVolume` on instances tagged
+ `aws:ResourceTag/carlos:role = platform-box`. `DetachVolume` is there
+ because a volume detached from a running box survives termination
+ with the box's data, which is exactly what the fence must rule out;
+ - `ec2:CreateTags` and `ec2:DeleteTags` on any instance when
+ `aws:TagKeys` includes `carlos:role`, except `CreateTags` with
+ `ec2:CreateAction = RunInstances`, so a launch can tag a new box but
+ nobody can strip, add or change the tag on a running one.
+
+ The volume-change role is assumable only by operators, and its trust
+ policy requires the session tag `carlos:volume-hold` (§5.9's hold).
+ Every other operator role is denied these actions like everyone else.
+
+ **Coverage is attested, not assumed.** An SCP does not bind the
+ Organizations management account or service-linked roles. The
+ operator workflow therefore verifies, with operator credentials, that
+ the deployment's account is a member account, that the policy is
+ attached with exactly these statements, and that no service-linked
+ role in the account can modify, attach or detach volumes on running
+ instances, and writes a root-signed **guard attestation**,
+ `control/volume-guard.json` (`{account, policy_id, policy_sha256,
+ checked_at, operator}`). The fence requires a valid one for the box's
+ account (§5.9). An account with no Organizations, or none attested,
+ has **no automatic fence**: every termination there goes to the
+ operator path. This is H15's condition (2) unmet, not a weaker
+ variant of it. The launch template and the `region-host` module create
+ every volume with `DeleteOnTermination` and tag every box
+ `carlos:role = platform-box`; enrollment refuses a box without the
+ tag.
- **Auto Scaling enrollment (H16, spec 4).** The box role gains
`autoscaling:DescribeAutoScalingInstances`, conditioned on
`aws:RequestedRegion`, read-only.
@@ -1054,8 +1189,8 @@ operations.
- **S3 (A2-6).** Two kinds of write, kept apart:
- **Create-only** (`If-None-Match: *`): content objects,
`instances/<host>/*/files/*/objects/`, in the region's data bucket;
- box identity records, `control/boxes/`, volume records,
- `control/box-volumes/`, membership confirmations,
+ box identity records, `control/boxes/`, volume records, holds and
+ releases, `control/box-volumes/`, membership confirmations,
`control/box-enrollment/`, and fence records, `control/box-fences/`,
in the control bucket.
- **Conditional replace** (`If-Match`, or `If-None-Match: *` for the
@@ -1089,12 +1224,16 @@ operations.
| What happens | Result |
|---|---|
| Executor box dies, region has another box | spec 3 relocates its hosts after the termination fence. Until then: requests 503 with Retry-After. |
-| Executor dies, region had one box | the Auto Scaling group launches a replacement; once enrolled, it is a regional worker, reads the termination event from the queue, describes the instance, writes the fence and relocates every host to itself. |
+| Executor dies, region had one box | the Auto Scaling group launches a replacement, but no regional worker is left to confirm its group membership (H16), so it cannot be enrolled automatically; until §11 question 1 is ruled, an operator approves its enrollment (B1-8). Once enrolled it is a regional worker: it reads the termination event, writes the fence and relocates every host to itself. |
| The console's box is the one that died | failover does not need it: the fence, the census exclusion and every relocation CAS are a regional worker's (§5.9, L5, L6). |
| Box partitioned from the control bucket | it keeps serving what it serves (no execution lease while `serving`, claim §7.1). It can't take new work. The group's health check decides whether it is terminated. |
| Box alive but failing its health check | the group terminates it, then as above. Instances that were healthy lose up to H3's window, declared by `writes_lost`. |
| A terminated box had a volume without `DeleteOnTermination` | no automatic fence: `awaiting-evidence`, an alert, and the operator's `--declare-lost` (§5.9). The running box's record already alerted. |
| A terminated box's newest volume record is missing or over 2 hours old | no automatic fence: the operator path (§5.9, H15). |
+| An operator changes a box's volumes, or a volume vanishes from a running box's mapping | disk evidence is suspended from the hold (or the record showing the removal) until a release; a termination meanwhile goes to the operator (§5.9, B1-3). |
+| The account has no attested volume guard | no automatic fence anywhere in it (§6, B1-4). |
+| A participant's execution lease is lost and its cleanup fails | its controller record stays; root refuses executable updates on that box until the host is resolved (§5.10, B1-5). |
+| The source dies while a drain is in `starting` | the op becomes a failover in place (§5.8, B1-6). |
| An update is reserved for `to` while a relocation starts | root refuses to begin it while `to` is a participant, or `to` refuses to acknowledge while it runs; never both running (§5.10). |
| A forged or confused edges file names a terminated instance | ignored: identity comes only from the roster's `cloud`, proven at enrollment (HA7). |
| Placer dies mid-relocation | another regional worker replays from the claim (§5.8). |
@@ -1124,7 +1263,9 @@ operations.
`cloud`;
- the region's termination queues exist, every box has a current
volume record showing every attached EBS volume
- `DeleteOnTermination`, and the volume guard (§6) is in place.
+ `DeleteOnTermination` and the guard's tag, no box has an unreleased
+ hold or removal, and the account has a valid guard attestation
+ (§6).
The HA floor joins the protocol floor (claim §10, D1): every path that
restores an executable (system apply, rollback, boot recovery from
@@ -1147,8 +1288,10 @@ operations.
(`InvalidVolume.NotFound`), and the `false` volume survived as
`available`. The fence therefore reads volumes from the running-time
record (§5.9). The rehearsal repeats this with the record and the
- volume guard in place, and checks that an attach by a non-operator
- role is refused.
+ volume guard in place, and checks that an attach, a detach, a flag
+ change and a removal of the `carlos:role` tag by a non-operator role
+ and by an ordinary operator role are refused, and that the
+ volume-change role cannot be assumed without a hold tag.
6. Then specs 2, 3, 4.
## 9. Changes requested of the claim and moves specs
@@ -1161,15 +1304,15 @@ the text.
| # | Amends | Change | Status |
|---|---|---|---|
-| L1 | claim §4, §8, §6.4a (which kinds and phases admit), §7.3 D6 | New op kind `relocating` (§5.8): same region, stateful, from `serving`; its phase table with `op.exec`, stamp, rows and admission per phase; the `installed` no-reinstall boundary; the replacement table; two replacements then `stuck`; the release sequence. Recovery owner `region:<r>`, any regional worker (P6-D5). The D6 barrier needs only `to` when the start cites a termination fence. Excludes moves, placements and pushes. | Ruled in direction (H10; #236: "a new op kind, or an equivalent that the claim prefers") |
-| L2 | claim §4, C5, §7.3 D2 | A termination fence (§5.9) is a lost-box fence, written by a regional worker with no person in the loop: `terminated` only (by event, describe or, later, the group's history), bound to the roster entry's `cloud`, with a running-time volume record no more than 2 hours old showing every EBS volume `DeleteOnTermination`, under the IAM volume guard (no `ModifyInstanceAttribute`, `AttachVolume` or `DetachVolume` on platform boxes except by operator roles). A `shutting-down` read is never evidence. Stopped, unclear and vanished boxes keep D2's operator path unchanged. | Ruled (H10, H15) |
+| L1 | claim §4, §8, §6.4a (which kinds and phases admit), §7.3 D5 and D6 | New op kind `relocating` (§5.8): same region, stateful, from `serving`; its phase table with `op.exec`, stamp, rows and admission per phase; the `installed` no-reinstall boundary for the remote build, with a per-box local install receipt required before `probing`; probe attempts after the first restoring from their predecessor; the replacement table; the source-loss conversion to failover from `starting` or `releasing`; two replacements then `stuck`; the release sequence. Recovery owner `region:<r>`, any regional worker (P6-D5). The D6 barrier needs only `to` when the start cites a termination fence or after a source-loss CAS. Excludes moves, placements and pushes. | Ruled in direction (H10; #236: "a new op kind, or an equivalent that the claim prefers") |
+| L2 | claim §4, C5, §7.3 D2 | A termination fence (§5.9) is a lost-box fence, written by a regional worker with no person in the loop: `terminated` only (by event, describe or, later, the group's history), bound to the roster entry's `cloud`, with a running-time volume record no more than 2 hours old showing every EBS volume `DeleteOnTermination` and the guard's tag, no unreleased hold or removal, and an attested volume guard (no `ModifyInstanceAttribute`, `AttachVolume`, `DetachVolume` or `carlos:role` tag change on platform boxes except by the hold-gated volume-change role). A `shutting-down` read is never evidence. Stopped, unclear and vanished boxes keep D2's operator path unchanged. | Ruled (H10, H15) |
| L3 | claim §7.3 D5 | A fenced terminated box's `park-local` chores are discharged in the fence CAS: H10's volume check is D5's destroyed-local proof. Regional chores move to `region:<r>` as D5 says. | Ruled (H10) |
| L4 | claim §7.3 D3 and D4, §5.9 P5-D3 | The fence record `control/box-fences/<enrollment>.json`, create-only, box-written, cited by `evidence_refs`. A terminated box goes on `lost` with that reference and no quarantine revision; it needs no readmission. The console later copies it into a D4 loss record and retires the roster entry. | Ruled in direction (H10, H1); record format for the claim session |
-| L5 | claim §10.1 D7 (required actors) | An enrolled box with a valid fence record is excluded from the census without waiting for the console to retire it. | Ruled (H10; #236: failover works when the console's box died) |
-| L6 | claim §10.1 D7 (lifecycle reservations, `actors_hold`) | For operations a box starts, replace the console reservation with §5.10's box-side check: the worker reads (never writes) the roster and refuses if an update is reserved for `from` or `to`; a participant writes its `pending` controller state, then refuses to acknowledge while root has an update begun; root writes its update-begun marker, then refuses an update while any host's controller state is `pending` or `active`. | Ruled (H14) |
+| L5 | claim §10.1 D7 (required actors, console enrollments) | An enrolled box with a valid fence record is excluded from the census without waiting for the console to retire it. A console enrollment gains `compute: {box, enrollment}`, checked against its reports; a console whose `compute` box is fenced is excluded the same way (B1-7). Whether a live console with a stale report may be passed over is §11 question 2. | Ruled (H10, H14; #236: failover works when the console's box died); the stale-console part open |
+| L6 | claim §10.1 D7 (lifecycle reservations, `actors_hold`), §7.3 D6 | For operations a box starts, replace the console reservation with §5.10's box-side check: the worker reads (never writes) the roster and refuses if an update is reserved for `from` or `to`; a participant writes its `pending` controller state, then refuses to acknowledge while root has an update begun; root writes its update-begun marker, then refuses an update while any controller record exists in any state, `lost` included, or cannot be read. A record is removed only by D6's clearing with its cleanup complete (B1-5). | Ruled (H14) |
| L7 | claim §10.1 D7, §7.3 D2, §5.9 P5-D2, §6.6 P6-D2 | Enrollment of a platform box requires `internal/boxid` evidence; it records `cloud` from it, and checks the tofu `instance_id` for a reference already in claims or edges files. No other roster, and no `control/boxes` authority. | Ruled (H11) |
| L8 | claim §5.1 steps 2 and 4, §5.9 P5-D2 and P5-D6 | The executor in the create intent is `choose()` (§5.2), with P5-D2's SHA-256 as the final tie-break; chosen once, never substituted; a create whose box can't complete is cancelled. | Ruled (H12) |
-| L9 | claim §6.4a item 2, `hostclaim.AdmitWriter` | A coverage value `released`: a prepared generation built from a pin that reaches the `final_txid` a released box recorded from its stopped database, under that box's stop receipt. It declares nothing. Without it every drain declares a false loss. | Requested |
+| L9 | claim §6.4a item 2, `hostclaim.AdmitWriter` | A coverage value `released`, legal only on the admission that opens a prepared generation built from a pin reaching the `final_txid` a released box recorded from its stopped database, and only when the admission's predecessor (the last `holder.admissions` entry) is exactly that box and session, under its stop receipt. It declares nothing. Without it every drain declares a false loss (B1-1). | Requested |
| L10 | claim §6.1, §6.6 P6-D1, `internal/stopevidence` | A writers-stopped check for release step 1: cgroup empty, no member processes, and database holders limited to the agent's own identified replicator descriptors. `Collect` stays unchanged for step 4. | Requested |
| L11 | claim §4 (CC11), `hostclaim.StartGuard` | A `park-generation` chore in the region does not block a `relocating` start there: relocation writes only its own new prepared generation, never one being retired. Under P6-D4 nearly every recently woken host has one, which would hold failover up to the park horizon. | Requested |
| L12 | claim C4, §8 (CC10), §6.6 P6-D2; moves §9.6 | Reconcile's row cases for `relocating` (§5.3): held participant rows; a former executor's row kept held until its `park-local` retires it; the 10-minute sweep as a stated exception to C4 for kept rows (H9). | Requested |
@@ -1179,7 +1322,7 @@ the text.
| L16 | moves §5.1, §7.6, P6-D3's provider | Relocation's install reuses the landing's `building` and `published` phases and the prepared-generation authority, with a pinned set of remote versions as the source in place of a staged replica. One shared routine, so both get the same anchor and manifest rules. | Requested |
| L17 | moves §6.1, §8, §9.4 | Every installation, a move's landing included, writes a files manifest in its new generation (§5.6), so unchanged files stay recoverable after the next failure; the archive at the cut stays. Timer freezing and draining apply to `relocating` holds. | Requested |
| L18 | joint model, moves §14.2 | `internal/sim/hostlife` extends to two boxes per region: create spread, relocation by drain, termination mid-serve and in every relocation phase, nested failover, a stale edges file, an unenrolled reference, a volume without `DeleteOnTermination`, a crash mid-install, late file uploads and late PUTs after termination. | Requested |
-| L19 | claim §10.1 D7 (enrollment), with P3 | An Auto Scaling box is enrolled by the console with no operator when its `boxid` evidence verifies (deployment account, the region's AWS region) and a regional worker's create-only confirmation shows it a current member of the region's group (§5.1). Every other enrollment stays operator-approved. Spec 4 builds it. | Ruled (H16) |
+| L19 | claim §10.1 D7 (enrollment), with P3 | An Auto Scaling box is enrolled by the console with no operator when its `boxid` evidence verifies (deployment account, the region's AWS region) and a regional worker's create-only confirmation shows it a current member of the region's group (§5.1). Every other enrollment stays operator-approved. Spec 4 builds it. The confirmer for a region with no active worker left is §11 question 1 (B1-8). | Ruled (H16); the empty-region confirmer open |
**Rev 4.2's requests, mapped.** K1 is L8. K2 is L1, with L9 to L11. K3
is L2 to L5. K7 is L14. K8 is L16 and L17. K9 is L18. K10 is L15. K11 is
@@ -1212,9 +1355,11 @@ rev 4.2's table; this mapping says where each now lives.
record with a `false` volume, a missing record, a record just inside
and just past the 2-hour bound, a record for another instance, a
`shutting-down` read offered as evidence, a `shutting-down` read that
- refreshes the record with a `false` volume); the volume recorder
- (writes on change, at least hourly, never on an unchanged sweep
- otherwise); continuous files with a
+ refreshes the record with a `false` volume, a record without the
+ guard's tag, no guard attestation, an unreleased hold, a removed
+ volume with and without a release, a release with no later running
+ record); the volume recorder (writes on change, at least hourly,
+ `removed_volumes` when a volume leaves the mapping); continuous files with a
file held open (published at the deadline), inotify overflow, a file
that changes during its read; the create-only and replace split in
`hostfiles.Publish`.
@@ -1227,14 +1372,21 @@ rev 4.2's table; this mapping says where each now lives.
passes with only its descriptors, step 4 passes only after `DB.Close`,
any other holder fails step 1); a failover admission declaring
`writes_lost` for `from`'s session and a drain declaring nothing; the
- start refused when the roster reserves an update for `from` or `to`.
+ start refused when the roster reserves an update for `from` or `to`;
+ probe attempts one, two and three, with attempt three restoring
+ attempt two's replicated writes and declaring attempt two's session
+ (B1-1); a target terminated at `installed` replaced by one that makes
+ its own local install before probing, and a `probing` CAS refused with
+ another box's receipt (B1-2); `from` terminated in `starting` before
+ and after acknowledging (B1-6).
- **Starts and updates (§5.10):** root's begin and a participant's
acknowledgement interleaved at every point, with a crash between each
marker and its check: never an update and an acknowledged
participation at once, and a crash leaves at most a refusal, cleared
- by retry.
-- **IAM:** the volume guard refuses `AttachVolume`, `DetachVolume` and a
- block device mapping change by the box role and a non-operator role, and allows an
+ by retry; a lease lost during `probing` with cleanup failing, and an
+ unreadable controller record, both refusing the update (B1-5).
+- **IAM:** the volume guard refuses `AttachVolume`, `DetachVolume`, a
+ `carlos:role` tag change and a block device mapping change by the box role and a non-operator role, and allows an
operator role (rehearsed on dev, §8 step 5).
- **Integration:** two agents against one fake region, one control
bucket: create lands on the chosen box only; the other box mints no
@@ -1247,17 +1399,45 @@ rev 4.2's table; this mapping says where each now lives.
## 11. Open questions
-None. Rev 4.2's three were ruled as H7, H8 and H9 (2026-10-04). The
-three rev 5 first left open were ruled on 2026-10-06:
-
-1. **Who reserves the actors for a relocation a box starts (L6).**
- Ruled as H14: the box-side check, §5.10.
-2. **A terminated instance's describe has no block devices.** Tested on
- dev and ruled as H15: a running-time volume record kept true by IAM,
- §5.9 and §6.
-3. **How an Auto Scaling box is enrolled.** Ruled as H16: automatically,
- on `boxid` evidence plus a worker's group-membership confirmation,
- §5.1, L19. Spec 4 builds it.
+Rev 4.2's three were ruled as H7, H8 and H9 (2026-10-04). Rev 5's first
+three were ruled on 2026-10-06 as H14 (the box-side reservation check,
+§5.10), H15 (the running-time volume record, §5.9 and §6) and H16
+(automatic enrollment, §5.1, L19).
+
+Astra's rev 5 review raised two that only Paul can settle:
+
+1. **Enrolling the first box of a region whose last box died (B1-8).**
+ H16 has a regional worker confirm a new box's group membership. In a
+ one-box region, H5's default, the box that died was the only worker,
+ so its replacement cannot be confirmed, cannot be enrolled
+ automatically, and cannot fence its predecessor or recover its hosts.
+ Rev 5.1 says so (§7) instead of claiming automatic recovery. Options:
+ - **(a)** A confirmer from another region: any active enrolled box,
+ given read-only `autoscaling:DescribeAutoScalingInstances` (and
+ `ec2:DescribeInstances`) for every region of the deployment,
+ confirms membership for a region with no active worker. Only the
+ confirmation crosses regions; the fence and relocation stay with
+ the new box once it is enrolled. *Recommended.* It keeps H16's
+ evidence and the console's lack of AWS rights.
+ - **(b)** Operator approval for a region's first box. One-box regions
+ then have no automatic recovery, which H5's default of 1 makes the
+ common case.
+ - **(c)** Keep at least two boxes in every region that wants automatic
+ recovery (H5's minimum raised to 2 for those regions).
+2. **A live console with a stale report (B1-7).** Rev 5.1 removes a
+ console from the census when its box is fenced. A console whose box
+ is alive but whose report has gone stale (hung, partitioned) still
+ fails claim D7's census, so a box-started relocation, a failover
+ included, waits for it. Options:
+ - **(a)** Keep D7 as written: the relocation waits and alerts. Safe
+ against a console that is about to write the claim with an old
+ protocol, but a hung console can hold up failover indefinitely.
+ - **(b)** For box-started relocations only, accept a console's newest
+ report, however old, when it met the floor and its `compute` box's
+ root guard is armed (claim D1): that guard already stops any binary
+ below the floor from running, even offline. *Recommended.*
+ - **(c)** As (b), but only for a stated time (for example an hour),
+ then (a).
## 12. Out of scope, for the later specs
@@ -1438,3 +1618,26 @@ follows §5.3's revised table. Item 4 is dropped. Item 6 becomes
publishing the enrollment evidence at start. Item 7 is §5.8 as rev 5
states it, and waits for L1 and L9 to L11. Item 8 adds the roster and
volume checks to the gate.
+
+Rev 5.1 adds to plan 1b: the hold, release and removed-volume rules and
+their CLI (§5.9), the guard attestation and the tag statements (§6), the
+local install receipt (§5.8), and root's refusal on any controller
+record (§5.10).
+
+## 20. Astra rev 5 round 1 dispositions
+
+Reviewed rev 5 at 2a9165d4 (`CODEX-FINDINGS-REGION-HA-REV5-1.md`). Each
+citation was checked against claim 98b45332, moves a4711728 and main
+before acceptance.
+
+| Finding | Disposition |
+|---|---|
+| B1-1 P1 probe retries restore `P` again and lose replicated writes; `released` suppresses loss too widely | accepted: claim §6.4a item 3 and moves §7.14's restore row choose a fresh admission's baseline from its predecessor. Only the first attempt opens `P`; later attempts restore through `genselect.Select`; coverage is decided per admission, `released` only on `P`'s opening with a predecessor matching the journaled release (§5.8, L9) |
+| B1-2 P1 a replacement at `installed` never makes its local install | accepted: the remote build and the per-box local install are separate; `probing` requires a local install receipt bound to `op.exec`, its enrollment and `holder.stamp`; a replacement makes its own from `P`'s files manifest, and `P` is still built once and opened once (§5.8, L1) |
+| B1-3 P1 an operator's volume change before a refresh, or a detach, allows a false destroyed-disk fence | accepted: a hold written before the change suspends disk evidence; a volume leaving a running box's mapping suspends it too; release needs a newer all-true record and an operator's attestation for every removed volume (§5.9, §6, §7) |
+| B1-4 P1 the guard rests on an unprotected tag; the non-Organizations fallback still fences | accepted: the SCP also denies changing `carlos:role` except at launch; exemption narrowed to one hold-gated role; a root-signed guard attestation is required, and without one there is no automatic fence. Stricter than H15, never weaker (§5.9, §6, §8) |
+| B1-5 P1 a `lost` controller stops excluding updates | accepted: claim D6 does define `lost` and keeps its deny on failed cleanup. Root now refuses on any controller record in any state or any unreadable one, until D6's clearing with cleanup complete (§5.10, L6) |
+| B1-6 P2 the source lost in `starting` has no transition | accepted: a source-loss CAS converts the op to failover from `starting` or `releasing`, keeping the intent and acknowledgements, removing `from` from the barrier (§5.8, L1) |
+| B1-7 P2 a dead console stays in the D7 census | accepted: claim D7 does require every console. Console enrollments gain a `compute` binding; a console on a fenced box is excluded. A live console with a stale report is §11 question 2 (§5.9, L5) |
+| B1-8 P2 automatic enrollment cannot bootstrap an emptied region | accepted: §2 makes a confirmer an active enrolled box in the region, so none is left. §7 no longer claims automatic recovery for one-box regions; the confirmer is §11 question 1 (§5.1, §7, L19) |
+| B1-9 P3 stale code citations | accepted, checked on main: `provision.go:49`, `reconcile.go:348`/`:820`, `:192`, `:215`, `activator.go:1164-1168`, `:2204`, `edge.go:2466`, `main.tf:521`. Disposition tables §13 to §17 keep their historical anchors |