spec: region HA spec 1 rev 5, restated against claim rev 6.25 and Paul's rulings H10-H13
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 file changed,
+772
−411
docs/superpowers/specs/2026-10-04-region-ha-box-placement-design.md+772 −411
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 18587034b..17ece865c 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,14 +1,38 @@ |
| # Region HA, spec 1: placing an instance on a box |
| -Design, 2026-10-04, **rev 4.2**. DRAFT. Nothing here is built. |
| - |
| -Rev 4.2 answers Astra round 5 (`CODEX-FINDINGS-REGION-HA-5.md`, A5-1). |
| -Rev 4.1 answers Astra round 4 (`CODEX-FINDINGS-REGION-HA-4.md`, |
| +Design, 2026-10-06, **rev 5**. DRAFT. Plan 1a is built and merged |
| +(aadb860); nothing else here is built. |
| + |
| +**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`, |
| +`internal/stopevidence`, `internal/park`, `internal/genselect`), and |
| +records Paul's rulings H10 to H13 of 2026-10-06. Rev 4.2 was written |
| +against claim rev 6.3 and asked for changes K1 to K11. Those sessions |
| +never saw the request, and the claim moved on: box references are |
| +opaque, `control/host-actors.json` is the one roster, every writer is |
| +admitted by `AdmitWriter`, every admission is cold until certificates |
| +work, a prepared generation is opened once, and regional work is done |
| +by any enrolled box in the region. So rev 5 drops spec 1's own box |
| +records as authority (H11), the trusted local copy (H13), and its own |
| +`holder.generation` install CAS. Relocation (§5.8) keeps every property |
| +it had, and installs through the claim's prepared-generation and |
| +`AdmitWriter` rules instead. The termination fence stays automatic, now |
| +also requiring every EBS volume to be deleted with the instance (H10). |
| +§9's K-table is replaced by requests L1 to L18 against claim rev 6.25 |
| +and moves rev 7.17. §19 says what of plan 1a is superseded. §11 lists |
| +three points the claim's current text leaves for Paul. |
| + |
| +Rev 4.2 answered Astra round 5 (`CODEX-FINDINGS-REGION-HA-5.md`, A5-1), |
| +and Astra round 6 (`CODEX-FINDINGS-REGION-HA-6.md`) found it ready for |
| +plan. Rev 4.1 answers Astra round 4 (`CODEX-FINDINGS-REGION-HA-4.md`, |
| **A4-n**). Rev 4 answers Astra round 3 (`CODEX-FINDINGS-REGION-HA-3.md`, **A3-n**, |
| plus the A2-5, A2-7 and A1-1 residuals). Rev 3 answered Astra round 2 (`CODEX-FINDINGS-REGION-HA-2.md`, cited |
| **A2-n**, plus the round 1 findings it marked partly resolved). Rev 2 |
| answered round 1 (`CODEX-FINDINGS-REGION-HA-1.md`, **A1-n**). Every |
| -finding is accepted; §13 maps each to its section. |
| +finding is accepted; §13 maps each to its section. Where rev 5 changes a |
| +mechanism a disposition names, the disposition's property still holds, |
| +and the section it points to says how. |
| This is the first of four specs that give each region high availability |
| and autoscaling: |
| @@ -25,20 +49,20 @@ and autoscaling: |
| It builds on two specs still in review, and asks them for changes (§9): |
| -- the host lifecycle claim, rev 6.3 (`design-replica-ownership`, |
| - 8faa89a4), cited as **claim §n**; |
| -- move-any-app (`design-moves-any-region`), cited as **moves §n**. Rev |
| - 7.5 (cdc98845) is the last committed revision. **Rev 7.6**, which adds |
| - the level-0 anchor that relocation's install relies on (A2-1), is an |
| - uncommitted draft in the moves worktree as of 2026-10-04; its model |
| - half is committed at 29245f69. Citations of rev 7.6 are to that draft |
| - until it is committed, and the plan pins the commit (A4-3). |
| +- the host lifecycle claim, **rev 6.25** (`design-replica-ownership`, |
| + 413b12cb), cited as **claim §n**. Its sections marked PROPOSED (§5.9 |
| + P5-D1 to P5-D6, §6.6 P6-D1 to P6-D8, §7.3 D2 to D6, §10.1 D1 and D7) |
| + are cited as proposals, not as settled text. Rev 6.26 (98b45332) |
| + changes only the staging scope of a delete's park (R17-2) and touches |
| + nothing here; |
| +- move-any-app (`design-moves-any-region`), **rev 7.17** (39904c84), |
| + cited as **moves §n**. Rev 7.18 (a4711728) changes no rule. |
| It also builds on nearest-region (on main since 3f4a9bfd), whose box |
| -evidence in the edges file is how a console knows which boxes a region |
| -has. |
| +evidence in the edges file is how a console knows which enrolled boxes |
| +are alive, and on #212 option B for box references. |
| -## 0. Rulings (Paul, 2026-10-04) |
| +## 0. Rulings (Paul, 2026-10-04 and 2026-10-06) |
| | # | Ruling | |
| |---|---| |
| @@ -51,6 +75,14 @@ has. |
| | H7 | **Ticks are at-least-once.** A delivery that ran on a dead box but whose bookkeeping did not reach S3 may run again. Documented; apps that need exactly-once guard it themselves. | |
| | H8 | **`files_durability` defaults to `snapshot`** for apps with no setting. | |
| | H9 | **The reconcile sweep is 10 minutes** (§5.3). | |
| +| H10 | **(2026-10-06) Termination fences stay automatic, and the disk must die with the box.** H1 stands against claim §7.3's operator-only declare-lost. A box AWS reports `terminated` is fenced automatically by a region placer: a box with read-only EC2 rights. The console holds none. The fence additionally requires every EBS volume the instance had attached to be `DeleteOnTermination: true`, read from the same `DescribeInstances` reply's `BlockDeviceMappings`. Otherwise the operator path applies. Stopped, unclear or vanished boxes stay operator-only (claim §7.3 D2 as written). Absence is never evidence. | |
| +| H11 | **(2026-10-06) The roster is the claim's `control/host-actors.json`** (claim §10.1 D7). Region HA drops its own `control/boxes/<id>.json` authority. `internal/boxid`'s AWS-signed IMDS identity verification becomes the evidence required to enroll a box. Enrollment records the proven account, region and instance id in the roster entry's `cloud` field, and the termination fence binds to that entry. Box references are opaque (claim §2, rev 6.16) and follow #212 option B: the label is the key, the instance id is private. | |
| +| H12 | **(2026-10-06) Executor choice at create** is spec 1's `choose()`: prefer a box running no instance of the app; then the most headroom (until spec 2, the fewest claimed hosts); then the claim's P5-D2 SHA-256 of `host + "\x00" + box reference` as the final tie-break. It is recorded once, in the create intent. Recovery never substitutes. | |
| +| H13 | **(2026-10-06) Claim ruling R6 supersedes HA3 and §5.5.** A box never reuses its local database. Every admission is cold until claim P6-D4 lifts. HA3 and §5.5 are deleted, and the activator half of plan 1a's install marker is not used. | |
| + |
| +H10 to H13 supersede only what they name: H10 adds the volume condition |
| +to H1, H11 replaces §5.1's box records as authority, H13 replaces HA3 |
| +and §5.5. Everything else in H1 to H9 stands. |
| Standing rulings this spec keeps: |
| @@ -59,12 +91,17 @@ Standing rulings this spec keeps: |
| - Placement targets a region and pool, never a named box (fleets D5). |
| An operator or a customer still cannot pin an instance to a box; only |
| the placer chooses. |
| -- The console is the single control-plane writer and holds no EC2 rights |
| - (claim §7.2). |
| +- The console is the single control-plane writer for operator and |
| + customer requests, and holds no EC2 rights (claim §7.2). Boxes write |
| + claims too, as the claim allows them: writer admission, publication, |
| + their own chores and, under this spec, relocation. |
| - A box must hold the deployment's secrets key to receive instances (R9), |
| and every box reconcile would admit in a region must qualify |
| (nearest-region F2). |
| - Unreplicated state on a dead box is lost, and we say so. |
| +- **Claim R6:** a box never reuses its local database; every writer start |
| + restores from the certified remote (claim §6.4a). Under P6-D4, every |
| + admission is cold. |
| ## 1. Why |
| @@ -79,14 +116,15 @@ A second box in a region would break three things: |
| 1. **Both boxes run the instance's route.** The S3 lease (`lock.json`, |
| 15-minute TTL, `internal/lease/lease.go:37`) stops them running at |
| once, but whichever box a request reaches wins. |
| -2. **Both boxes claim the host to the edges.** The home map breaks the |
| +2. **Both boxes claim the host to the edges.** The home map broke the |
| tie by sorted box name, with no liveness (`foldHomes`, |
| - `internal/edge/homemap.go:538`, collision at `:582`), |
| - so a dead box that sorts first keeps winning. |
| + `internal/edge/homemap.go`), so a dead box that sorts first kept |
| + 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`). |
| - With two boxes taking turns, that is false. |
| + 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`). |
| @@ -95,25 +133,44 @@ back the same machine, if it can (`infra/modules/region-host/main.tf:497`). |
| - **Box**: one platform host machine. Not a customer fleet box; fleets |
| keep their own mechanism and are out of scope. |
| -- **Box id**: the name the claim uses for a box. For a box launched by |
| - an Auto Scaling group it is its EC2 instance id |
| - (`status.InstanceID()`, `internal/status/instanceid.go:37`), which AWS |
| - never reuses. A box that predates this spec keeps its label |
| - (`status.Label(machine-id)`). Either way the id is bound to one AWS |
| - instance by a **box record** (§5.1), never by an edges file. |
| +- **Box reference**: the name the claim uses for a box in `op.exec`, |
| + `participants`, `touched.boxes`, `inventory`, `lost`, owners and |
| + execution-lease keys. It is opaque and compared by exact equality |
| + (claim §2, rev 6.16). Under #212 option B it is the box's label key. |
| + The instance id is private: it lives in the box's roster entry, under |
| + `cloud` (H11). Spec 1 never parses a reference. The edges file keeps |
| + its field name `box_id` for it; plan 1a already publishes the label |
| + there. |
| +- **Roster**: `control/host-actors.json` (claim §10.1 D7, proposed, |
| + accepted for region HA by H11). Console-authored, root-signed, |
| + conditionally replaced. Each box entry binds a reference to its |
| + `enrollment`, `region`, `pool`, `state` (`active`, `quarantined`, |
| + `retired`) and `cloud: {provider, account, region, instance_id}`. |
| - **Executor**: the one box the claim's `op.exec` names for a host. |
| - **Relocatable host**: an exec-backed instance or a sidecar, whose whole |
| start state is in S3 or rendered from its record (HA5). Unit- and |
| socket-backed routes (the console, platform services) are **not** |
| relocatable: their unit definitions live outside the record. They stay |
| on their box, as today (A1-8). |
| -- **Eligible box**: a box with a verified box record (§5.1) that is fresh |
| - (nearest-region F1), reachable, hosting, serves the instance's pool, |
| - accepts inbound, holds the secrets key (R9), publishes the HA |
| - capability (§8), is not draining, and is not on the claim's `lost` list. |
| -- **Region placer**: one box per region, chosen by the existing lease |
| - election, that runs relocation recovery, failover (spec 3) and drain |
| - (spec 4). Its election lease only decides who acts, never what is true. |
| +- **Eligible box**: a box whose roster entry is `active`, whose `cloud` |
| + was proven by `internal/boxid` at enrollment (§5.1), and whose enrolled |
| + region and `cloud.region` are the placement's; whose edges file is fresh |
| + (nearest-region F1) and names the same reference; that is reachable, |
| + hosting, serves the instance's pool, accepts inbound, holds the secrets |
| + key (R9), publishes the HA capability (§8), and is not draining. For a |
| + given host it must also not be on that claim's `lost` list and not owe |
| + that host a `park-local` chore. |
| +- **Region placer**: a **regional worker** in claim P6-D5's sense: any |
| + active enrolled box in the region with access to the region's data |
| + bucket. It runs relocation recovery, failover (spec 3) and drain (spec |
| + 4). There is no separate leader roster. Correctness never depends on |
| + which worker acts: every step is a claim CAS conditional on `op.id`, |
| + the phase and the ETag (claim C2). Spec 3 may still use a lease to |
| + decide who **starts** relocations, for efficiency only. |
| +- **Prepared generation**: a generation built before any writer, from a |
| + sealed or pinned source, with its snapshot, level-0 anchor and install |
| + manifest, and opened at most once by a cold `AdmitWriter` (claim §6.4a |
| + item 3, P6-D3; moves §7.6). |
| - **Relocation**: changing a host's executor to another box **in the |
| same region** (§5.8). A move changes region; relocation does not. |
| @@ -130,11 +187,9 @@ participant in the phase that permits it (§5.8). Being in the right |
| region is necessary, never sufficient (claim C4 says this already; |
| today's code does not do it). |
| -**HA3. A local copy is trusted only if it is the current installation, |
| -completely installed.** Authority (`active_stamp`, claim §6.4) and |
| -evidence (an installation-complete marker, §5.5) are separate. A box |
| -skips restore only when both match the claim's `holder.stamp` and |
| -`holder.generation`. |
| +**HA3. Withdrawn (H13).** Claim R6 governs instead: no box skips restore. |
| +Every admitted writer purges its local database and restores from the |
| +certified remote (claim §6.4a), and under P6-D4 every admission is cold. |
| **HA4. Edges follow the newest installation.** When two edges files claim |
| one host, the edge believes the one carrying the higher `holder.stamp`. |
| @@ -149,13 +204,16 @@ lives only on the box running it. |
| **HA6. The executor changes only by a claim operation.** Create, a move, |
| or a relocation (§5.8). Each changes `op.exec` in a CAS that cites stop |
| -evidence (claim §6.1) or a termination fence (§5.9), and makes a fully |
| -populated new generation current with a new stamp. |
| +evidence (claim §6.1) or a lost-box fence, the termination fence |
| +included (§5.9), and writes `holder.stamp` in the same CAS (claim R6-4). |
| +A relocated host's data becomes current only through a cold |
| +`AdmitWriter` into a fully built prepared generation (claim §6.4a). |
| -**HA7. Termination evidence is bound to the executor.** A fence counts |
| -only if the terminated instance is the one the executor's box record |
| -names, in the deployment's own account and the region's own AWS region |
| -(A1-1). |
| +**HA7. Termination evidence is bound to the roster.** A fence counts only |
| +if the terminated instance is the one the box's roster entry records |
| +under `cloud`, in the deployment's own account and the region's own AWS |
| +region (A1-1, H11), and every EBS volume it had attached was |
| +`DeleteOnTermination: true` (H10). |
| ## 4. What stays the same |
| @@ -173,100 +231,106 @@ names, in the deployment's own account and the region's own AWS region |
| ## 5. Design |
| -### 5.1 Box identity, box records and evidence |
| +### 5.1 Box identity, the roster and evidence |
| + |
| +**Rev 5 (H11).** Spec 1's own `control/boxes/<id>.json` is no longer an |
| +authority. The roster is. What plan 1a built in `internal/boxid` becomes |
| +the evidence a box must present to be **enrolled**. |
| -**The box record (A1-1).** Before a box may be named by any claim in a |
| -multi-box region, it writes, create-only: |
| +**Enrollment.** A box asks to be enrolled by publishing its identity |
| +evidence, the record plan 1a defined: |
| - control/boxes/<box id>.json |
| + control/boxes/<box reference>.json |
| ```json |
| { |
| "v": 1, |
| - "box": "i-0abc…", |
| + "box": "<box reference>", |
| "identity_document": "<base64 IMDS instance identity document>", |
| - "identity_signature": "<base64 PKCS7 signature from IMDS>", |
| + "identity_signature": "<base64 RSA-SHA256 signature from IMDS>", |
| "written_at": "<RFC3339>" |
| } |
| ``` |
| -- The identity document is AWS-signed and names `accountId`, `region` |
| - and `instanceId`. Every reader verifies the signature against AWS's |
| - published certificate for the region, and checks that `accountId` is |
| - the deployment's account and `region` is the box's region. A record |
| - that fails is ignored and alerts. |
| -- For an Auto Scaling box, `box id == instanceId`, checked. Nothing |
| - else is needed: the signed document authenticates the whole id. |
| -- **A legacy box's label is not authenticated by AWS (A1-1 residual).** |
| - The signed document proves which instance wrote the record, not that |
| - the writer is entitled to the label. So a legacy record is admitted |
| - only with an operator's binding, `carlos ops box bind <label>`, run |
| - with the operator's own AWS credentials. It checks that: |
| - - the record's signed `instanceId` equals the instance id that the |
| - region's tofu state records for its `region-host` module (the |
| - module's `instance_id` output); |
| - - the label is the one already named in that region's claims |
| - (`touched.boxes`, `inventory`) and edges file. |
| - |
| - The command writes a second object, `control/boxes/<label>.bind.json`: |
| - `{"record_version_id": "…", "record_sha256": "…", "instance_id": "…", |
| - "tofu_instance_id": "…", "operator": "…", "at": "…"}`. It names the |
| - exact box record version it vouches for, so it can't be reused for a |
| - different record. |
| -- **Who may write what is enforced by S3, not by convention (A3-3).** |
| - The control bucket's policy (§6): |
| - - denies `PutObject` on `control/boxes/*.bind.json` to every principal |
| - except the deployment's operator roles; |
| - - denies `PutObject` on `control/boxes/*` without |
| - `s3:if-none-match: *`, so neither a record nor a bind can be |
| - replaced; |
| - - denies `DeleteObject` and `DeleteObjectVersion` on |
| - `control/boxes/*` to every principal, so neither can be removed to |
| - make room for a rebinding. |
| - |
| - A box can therefore write its own record once, and never a bind. A |
| - legacy record without a bind that names its exact version is not |
| - verified. All of this happens before any second box joins its region |
| - (§8). |
| -- The record is never rewritten. A box id whose record does not verify |
| - is never eligible, and a claim naming one is never relocated |
| - automatically: an operator resolves it. |
| +The object keeps plan 1a's storage rules: create-only, never deleted |
| +(§6). It grants nothing by itself. It is the evidence enrollment reads. |
| + |
| +Enrollment is the claim's (D7, L7). For region HA it must: |
| + |
| +- verify the record with `boxid.Verifier`: the signature under the |
| + embedded certificate for the document's region, `accountId` equal to |
| + the deployment's account, and the document's region equal to the AWS |
| + region the box is being enrolled into; |
| +- record the proven `accountId`, `region` and `instanceId` in the entry's |
| + `cloud: {provider: "aws", account, region, instance_id}`, and the |
| + record's SHA-256 beside it; |
| +- refuse a reference already enrolled to a different instance (#212's |
| + refusal to publish under a label another machine holds); |
| +- for a reference that already appears in claims (`touched.boxes`, |
| + `inventory`) or edges files, as a box predating region HA does, also |
| + check that the signed `instanceId` equals the region's tofu |
| + `region-host` module `instance_id` output. This is the check rev 4's |
| + operator bind made (A1-1 residual, A3-3). It now happens once, inside |
| + the operator-approved enrollment (claim §10.1 P3), so the separate bind |
| + object is not used. |
| + |
| +A box whose entry is not `active` with a proven `cloud` is never |
| +eligible, and a claim naming one is never relocated automatically: an |
| +operator resolves it. |
| **Edges file fields.** Each box's edges file |
| -(`internal/host/edgesfile.go:157`) gains: |
| +(`internal/host/edgesfile.go`) carries, as plan 1a built: |
| ```json |
| { |
| - "box_id": "i-0abc…", |
| + "box_id": "<box reference>", |
| "capabilities": ["region-ha-1"], |
| "draining": false, |
| "headroom": { "mem_free_mib": 2210, "disk_free_mib": 31200, "awake": 14 } |
| } |
| ``` |
| -- `box_id` must equal the box record's id; a mismatch makes the box |
| - ineligible. The edges file is evidence of liveness and configuration, |
| - never of identity. |
| +- `box_id` must equal an `active` roster reference; a file naming an |
| + unknown or inactive reference makes nothing eligible. The edges file is |
| + evidence of liveness and configuration, never of identity or |
| + membership (claim D7: heartbeats describe members, they do not create |
| + them). |
| - `capabilities` gates multi-box admission (§8). |
| - `draining` is set through the box by the placer (spec 4). |
| - `headroom` is the input to spec 2's choice. Spec 1 publishes it only. |
| -The console already reads these files with freshness |
| -(`internal/console/instancesplacement.go:93`). Nearest-region's |
| -`regioncandidates.go` keeps deciding which **regions** are eligible. |
| -This spec adds `eligibleBoxes(snapshot, boxRecords, region, pool)`, |
| -which returns the boxes, applying the predicate one box at a time |
| -(nearest-region `TestEligibilityNeverCombinesBoxes`). |
| +`eligibleBoxes(snapshot, roster, region, pool)` returns the boxes, |
| +applying the predicate one box at a time (nearest-region |
| +`TestEligibilityNeverCombinesBoxes`). The expected AWS region comes from |
| +the roster entry's enrolled region and `cloud.region`, never from a vote |
| +of edges files (§18's second correction, now answered). |
| ### 5.2 Create names a box |
| -Claim §5.1 step 4 becomes: CAS `op` to `serving` and set `exec` to |
| -`choose(eligibleBoxes(region, pool), app)` (K1). |
| - |
| -- Until spec 2 lands, `choose` is: the eligible box running the fewest |
| - claimed hosts, preferring one that runs no other instance of the app, |
| - ties broken by box id. It is a pure function over the snapshot and the |
| - claims the console already holds, and it is unit-tested alone. |
| +Claim §5.1's create intent names its executor (P5-D6 |
| +`selected_executor`, `executor_enrollment`), written in the step 2 CAS |
| +before any effect. For a relocatable host it is |
| +`choose(eligibleBoxes(region, pool), app)` (H12, L8). Step 4 |
| +(`CompleteCreate`) sets `op.exec` to exactly that box. |
| + |
| +`choose`, until spec 2 (H12): |
| + |
| +1. prefer a box that runs no instance of the app (H4's soft spread: it |
| + never refuses); |
| +2. then the most headroom; until spec 2 that is the fewest claimed hosts, |
| + counted from the console's claim-recovery listing (claim §5.1 step 5). |
| + A stale count only affects balance, never safety; |
| +3. then the greatest hexadecimal SHA-256 of `host + "\x00" + box |
| + reference` (claim P5-D2), and the smallest reference if two hashes |
| + are equal. |
| + |
| +It is a pure function over the roster, the edges snapshot and the |
| +counts, and it is unit-tested alone. |
| + |
| +- The choice is made once. Recovery never substitutes a newly available |
| + box (P5-D2). If the chosen box cannot complete the create (fenced, |
| + quarantined, or it never acknowledges), recovery cancels the create |
| + (claim §5.1 step 5, P5-D6), and the caller may create again. |
| - No eligible box: the create fails with today's "region has no box |
| that can take this" refusal (nearest-region). Spec 2 adds the |
| `unplaced` state for re-placement, where refusing is not possible. |
| @@ -274,81 +338,75 @@ Claim §5.1 step 4 becomes: CAS `op` to `serving` and set `exec` to |
| and gains no box field, so a record still describes residency, not |
| placement. |
| - A move into a multi-box region chooses its target box the same way, |
| - in its start CAS (K10). |
| + in its start CAS (L15). |
| ### 5.3 Reconcile: executing rows, held rows, former rows |
| -Reconcile distinguishes three cases for a relocatable host in its region |
| +Reconcile distinguishes these cases for a relocatable host in its region |
| (A1-6). Each is decided by a fresh claim read. |
| | The claim names this box as | Row | |
| |---|---| |
| | `op.exec`, op `serving` | mint or keep `ready` | |
| -| a participant whose phase's row column (§5.8 table; moves §9.6) says `ready` | keep `ready`. This is the relocating source in `starting`, and in `releasing` until its admission fence closes (A2-3) | |
| -| a participant whose phase's row column says held | mint or keep held, never `ready` | |
| -| neither, and no open obligation of its own for the host | **drop the registry row**, and nothing else | |
| - |
| -A box that still owes the host a `park-local` obligation keeps its row |
| -held until that obligation is done, so the guard and inventory it needs |
| -stay addressable. |
| - |
| -- Dropping a former executor's row is a registry change only. It never |
| - touches local files: that is the operation's `park-local` obligation |
| - (§5.8 step 6) under the host guard. This is a new rule (K11), separate |
| - from the stale-hold rule (claim §8, CC10), which only reopens a hold on |
| - the current executor. |
| +| `from` of a `relocating` op in `starting`, or in `releasing` until its admission fence closes (A2-3) | keep `ready` | |
| +| any other participant role of a running operation (§5.8 table; moves §9.6) | mint or keep held, with `hold_op` = the op id (moves §5.2), never `ready` | |
| +| the owner of an open `park-local` chore for the host | keep held until the chore's retirement removes the row | |
| +| none of these | mint nothing. An existing row is held, never served, and alerted as unexplained | |
| + |
| +- **Former executors (rev 5).** Rev 4.2 had reconcile drop a former |
| + executor's row as a registry change. Under claim P6-D2 a host's route |
| + and timer rows are typed inventory entries, and `park-local`'s |
| + retirement journal archives and removes them, conditional on their |
| + recorded ownership. So reconcile never deletes a relocatable host's |
| + row. It holds it, and the chore retires it under the host guard. A row |
| + is never removed by hostname alone, which could remove a successor's. |
| +- The stale-hold rule (claim §8, CC10) is unchanged: it reopens a hold |
| + only on the current executor of a `serving` host. |
| - **When reconcile reads claims.** The safety gate is the activator's |
| fresh claim read at every spawn (claim C4), not reconcile. So |
| reconcile reads a host's claim when it would mint a row, and re-checks |
| - the rows it keeps on a slow sweep, every 10 minutes. This is a stated |
| - exception to C4's "reconcile's decision to keep a row" (K11): a kept |
| - row that is stale for up to 10 minutes can route a request to this box, |
| - where the activator refuses to spawn. Reading every claim on every |
| - pass would cost about $2.30 a day per box at 2,000 hosts on 30-second |
| - passes. |
| + the rows it keeps on a slow sweep, every 10 minutes (H9). This is a |
| + stated exception to C4's "reconcile's decision to keep a row" (L12): a |
| + kept row that is stale for up to 10 minutes can route a request to |
| + this box, where the activator refuses to spawn. Reading every claim on |
| + every pass would cost about $2.30 a day per box at 2,000 hosts on |
| + 30-second passes. |
| - **Prompt changes.** The placer bumps the region's control serial after |
| - each executor CAS, so both boxes reconcile at once and re-read the |
| - claims of the hosts they hold or would mint. |
| + each executor CAS, so the region's boxes reconcile at once and re-read |
| + the claims of the hosts they hold or would mint. |
| - A claim read error keeps an existing row and mints none (claim C6). |
| ### 5.4 Edges and the home map |
| -- The edges-file pass (`edgesFilePass`, `edgesfile.go:272`) publishes a |
| - host as `home` (`:326`) only when this box is its executor, and adds |
| - the installation's stamp: |
| +- The edges-file pass (`edgesFilePass`, `edgesfile.go`) publishes a host |
| + as `home` only when this box is its executor **and** its row is |
| + `ready`, and adds the installation's stamp: |
| `"hosts": {"shop.bab.oncarlos.com": {"role": "home", "stamp": 41}}`. |
| -- The home map (`foldHomes`, `homemap.go:538`) resolves a host claimed by two files to |
| - the higher stamp (HA4). |
| + A relocation's `to` therefore publishes nothing while it installs and |
| + probes, and becomes home at `serving`. |
| +- The home map (`foldHomes`, `homemap.go`) resolves a host claimed by two |
| + files to the higher stamp (HA4). Plan 1a built this. |
| - **Unstamped entries.** Before §8's gate opens, every region has one |
| box, so there is no conflict to resolve. After it opens, every box |
| - publishes stamps, and an unstamped entry for a host that some file |
| - carries stamped is ignored. Two unstamped entries keep today's sorted |
| - rule and alert, because they mean the gate was bypassed (A1-9). |
| + publishes stamps for relocatable hosts, and an unstamped entry for a |
| + host that some file carries stamped is ignored. §18's correction |
| + stands: two unstamped entries are normal for everywhere-class routes, |
| + so the edge reports only two boxes at the same positive stamp. |
| - The edge never reads claims on the request path. |
| -### 5.5 The activator's local copy |
| - |
| -**The installation-complete marker (A1-5).** After a box has made an |
| -installation's data durable (database restored and fsynced, files |
| -applied to the manifest's sequence, timers imported), and only then, it |
| -writes and fsyncs: |
| +### 5.5 The activator's local copy: withdrawn (H13) |
| - <DataDir>/.installed/<host>.json |
| - {"incarnation": "…", "generation": "…", "stamp": 41, |
| - "db_marker": "<the moves generation marker>", "files_seq": 1882} |
| +Rev 4.2's installation-complete marker and its warm-cache check (HA3) are |
| +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. |
| -The warm-cache path (`activator.go:1127`, `backend_exec.go:265`) skips |
| -restore only when, under the host guard and against a fresh claim read: |
| - |
| -- the local stamp record's `active_stamp == holder.stamp` (authority); |
| -- the marker's `incarnation`, `generation` and `stamp` match the claim, |
| - and its `db_marker` matches the database's generation marker |
| - (evidence). |
| - |
| -Anything else, including a missing marker, restores from S3. A box that |
| -crashes mid-install therefore has authority and no evidence, and |
| -restores. Retirement (claim §6.3) parks the marker with the rest of the |
| -host's local state. The comment's one-box argument is replaced with 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 |
| +not evidence of a complete install, is answered more strongly: no local |
| +copy is ever trusted, complete or not. |
| ### 5.6 Host files reach S3, in order |
| @@ -393,11 +451,13 @@ Carloku maps its plans to this (H6). Apps with no setting get |
| **Always continuous, whatever the setting:** `instance.key`, |
| `report.token`, `<host>.env`, `<host>-sidecar.env`, and the host's wake |
| -state (§5.7: timer rows, the route's `NextDue` and `Replication`). They are small, and losing them is what kept kpmg down for |
| -1h54m (moves §1.2). |
| +state (§5.7: timer rows, the route's `NextDue` and `Replication`). They |
| +are small, and losing them is what kept kpmg down for 1h54m (moves §1.2). |
| **Ordering (A1-4).** Object versions do not say which update wins, so |
| -nothing is ever overwritten and one manifest decides. |
| +nothing is ever overwritten and one manifest decides. §18 records how |
| +plan 1a built it: content is identified by SHA-256, and `snapshot` is a |
| +periodic full publish with no `base`. |
| - **Objects are immutable.** Each upload goes to a new key, |
| `<holder.location>/files/<generation>/objects/<seq>-<sha256>`, written |
| @@ -406,46 +466,63 @@ nothing is ever overwritten and one manifest decides. |
| - **One sequence per host.** The executor numbers every local mutation |
| (write, rename, delete) with a monotonically increasing `seq`, kept |
| under the host guard and recovered from the manifest on restart. |
| -- **The manifest** is one object, |
| +- **The manifest** is one object per generation, |
| `<holder.location>/files/<generation>/manifest.json`, replaced only |
| - with `If-Match`: |
| - `{"seq": 1882, "base": "snapshots/0004.tar", "base_seq": 1700, |
| - "entries": {"<path>": {"object": "objects/1881-…", "seq": 1881} | |
| - {"deleted": true, "seq": 1874}}}`. |
| - A delete is a tombstone entry, never an S3 delete. The manifest is |
| - published only after every object it names is durable, and its `seq` |
| - only increases, so a late manifest write fails its `If-Match`. |
| -- **Snapshots** are sealed archives taken at a mutation boundary: the |
| - executor records `base_seq` before reading the directory, and |
| - mutations after `base_seq` go in `entries`. A snapshot becomes the |
| - base only through a manifest CAS. Older snapshots and unnamed objects |
| - are reaped after a day. |
| -- **Restore** reads the manifest once, then fetches its base and entries |
| - by the exact versions it lists (claim C3). Nothing else in the prefix |
| - is read. |
| + with `If-Match`. A delete is a tombstone entry, never an S3 delete. The |
| + manifest is published only after every object it names is durable, and |
| + its `seq` only increases, so a late manifest write fails its |
| + `If-Match`. |
| +- **Restore** reads the manifest once, then fetches the objects it names |
| + and checks each hash (§18). Nothing else in the prefix is read. |
| - **Release flush.** A box giving up a host publishes every pending |
| mutation, **whatever the app's setting**, before its stop evidence |
| (§5.8's release sequence). |
| +**Under the claim (rev 5).** Three rules follow from the claim as it now |
| +stands. |
| + |
| +- **The publisher is a generation-bound writer.** It belongs to the |
| + writer session, like the replicator: it is started for the `{location, |
| + generation, session}` the admission named, refuses any key outside |
| + `<location>/files/<generation>/`, and stops with the session. It is a |
| + producer for stop evidence (claim P6-D1) and is joined before the |
| + receipt. Its PUTs are a second ordinary-replication exception to claim |
| + C3 (L13). |
| +- **Each cold admission starts a new manifest.** Under P6-D4 every wake |
| + is a cold admission into a new generation. Before the app starts, the |
| + admitting box writes `files/<new generation>/manifest.json` |
| + create-only, as a copy of the predecessor generation's manifest read by |
| + exact version from the same baseline it restores. Its entries keep |
| + naming the predecessor's objects where they are, so an unchanged file |
| + is never uploaded again. A late write from the predecessor's publisher |
| + lands in the predecessor's prefix, which nothing admitted reads again. |
| +- **Old objects are retired by the claim, never reaped.** Rev 4.2's "reaped |
| + after a day" is withdrawn (plan 1a never built it). A dead generation's |
| + `files/<generation>/` prefix is retired with that generation, by the |
| + `park-generation` chore `PublishGeneration` queues, except objects that |
| + a manifest in the live set still names (L13). The at-rest sweep treats |
| + `files/` as part of the location, never as residue to classify |
| + blindly (claim P6-D3). |
| + |
| A `snapshot` app's files can be up to the interval older than its |
| database after a failover, and the console says so on the app's |
| Overview. |
| **Moves.** A move keeps its sealed archive at the cut (moves §8), and |
| -installs a fresh manifest in its new generation (K8). |
| +installs a fresh manifest in its new generation (L17). |
| ### 5.7 What a new executor rebuilds |
| | State | How the new box gets it | |
| |---|---| |
| -| Database | restored from the pinned recovery set (§5.8) into the new generation | |
| -| Files directory, env files | the manifest (§5.6), pinned | |
| +| Database | built from the pinned recovery set into a prepared generation, then restored from it by the probe's cold `AdmitWriter` (§5.8) | |
| +| Files directory, env files | the pinned manifest (§5.6), copied into the prepared generation | |
| | Wake state | the host's timer rows, as `Registry.ReplaceTimers` rows (moves §9.4), and the route's `NextDue` and `Replication` (moves §6.2), published continuously as one entry under the manifest (A1-7) | |
| -| `<host>.config.env` | rendered for the held row (moves §9.1), needs the secrets key (R9) | |
| -| Release | materialized for the held row (moves §9.2) | |
| +| `<host>.config.env` | rendered for the held row (`RenderHeld`, claim §6.1 CC13, P6-D3), needs the secrets key (R9) | |
| +| Release | materialized for the held row (`MaterializeHeld`) | |
| | Certificates | the cert mirror's boot refresh (on main) | |
| -| Registry row | reconcile, once the claim names this box (§5.3) | |
| -| Inventory | written by the new box before it first materializes state (claim §6.3) | |
| +| Registry row | reconcile, held while it installs, `ready` at `serving` (§5.3) | |
| +| Inventory | written by the new box before it first materializes state (claim §6.3, P6-D2) | |
| **Wake state (A1-7).** The executor publishes the host's wake state as |
| an always-continuous entry in the manifest whenever any of it changes: |
| @@ -460,7 +537,8 @@ landing does (moves §7.6). A once-timer the app registered is |
| therefore in S3 within about a second of the app being told it |
| succeeded. On install the new box imports the timer rows with |
| `ReplaceTimers`, preserving `next_at`, `due_at`, `attempts` and |
| -`registration`. |
| +`registration`. Timers on held rows stay frozen (moves §9.4, claim |
| +P6-D8) until the row is `ready`. |
| - An occurrence that fell due while the host had no executor fires once |
| when the new executor imports it: the converger catches up from the |
| @@ -470,29 +548,51 @@ succeeded. On install the new box imports the timer rows with |
| ### 5.8 Relocation |
| -A new claim op kind, `relocating` (K2), from `serving` only, for a |
| -relocatable host, within one region. Its intent names |
| -`{from, to, reason, generation_from}`, where reason is `drain`, |
| -`rebalance` or `failover`. |
| - |
| -**Phases (A1-3).** Each is a claim CAS, conditional on `op.id` and the |
| -ETag. The recovery owner for every phase is the region placer, which |
| -replays from the claim alone (claim C2). |
| - |
| -| Phase | Who acts | `op.exec` | Rows (§5.3) | May spawn | |
| -|---|---|---|---|---| |
| -| `starting` | placer writes intent; `from` (if alive) and `to` write execution leases | `from` | `from` ready; `to` held | `from` only | |
| -| `releasing` | `from` runs the **release sequence** (below). Or, for failover, the placer writes the termination fence (§5.9) | `from` until evidence | `from` ready until its admission fence closes, then held; `to` held | `from`, until its admission fence closes | |
| -| `pinning` | placer lists the recovery set in `generation_from` and journals exact versions (below) | `none` | `from` held (if alive); `to` held | nobody | |
| -| `installing` | `to` restores the pinned set into a new generation `G'`, applies the pinned manifest, imports wake state, and writes `G'`'s replication baseline (below) | `none` | `from` held (if alive); `to` held | nobody | |
| -| `installed` | placer CAS: `exec: to`, `holder.generation: G'`, `holder.stamp`: this CAS's `gen` | `to` | `from` held until its obligation; `to` held | `to` (probe only) | |
| -| `probing` | `to` probes (moves §7.7, same region), within the probe deadline | `to` | as `installed` | `to` (probe only) | |
| -| `probe-failed` | `to` runs the release sequence for `G'` (below) | `none` | as `installed` | nobody | |
| -| `serving` | placer CAS back to `serving`; `from` gets a `park-local` obligation unless terminated | `to` | `to` ready; `from` held until its obligation is done, then dropped | `to` | |
| +A new claim op kind, `relocating` (L1), from `serving` only, for a |
| +relocatable host, within one region. It is the stateful, same-region |
| +executor change the claim's `placing` refuses (P5-D5). |
| + |
| +**The start CAS** writes `op.kind`, `op.id` and the intent before any |
| +other effect (claim C2): |
| + |
| + {from, to, to_enrollment, region, reason, stamp_from, |
| + generation_from, fence?} |
| + |
| +`reason` is `drain`, `rebalance` or `failover`. For failover, `fence` |
| +names the termination fence record (§5.9) by key and SHA-256. The start |
| +is refused by the claim's start guard (claim §4, CC11, as amended by |
| +L11), and when `to` is not eligible for the host (§2). Participants are |
| +`from` and `to`, or `to` alone for a failover. |
| + |
| +**Who acts.** Every phase is a claim CAS conditional on `op.id`, the |
| +phase and the ETag. The recovery owner for every phase is the region's |
| +regional workers (§2, claim P6-D5), which replay from the claim alone |
| +(claim C2). Each worker first checks its own roster entry is `active` in |
| +this region. Two workers racing make the same CAS, and one fails. `from` |
| +and `to` hold execution leases (claim §7.1, D6) for as long as they are |
| +participants. |
| + |
| +**Phases (A1-3).** In the "admits" column, "writer" means a writer |
| +admission (`AdmitWriter`) is legal for that box; nothing else may open |
| +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` | |
| +| `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 | |
| +| `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` | |
| **The release sequence (A3-2).** Whenever a live box gives up a host, |
| the source in `releasing` or a failed target in `probe-failed`, it does |
| -these in order, journaling each: |
| +these in order, journaling each. It runs inside claim P6-D1's stop |
| +collection: root's collector has recorded its durable fence, and the |
| +agent does steps 1 to 3 before it closes the producers and returns its |
| +drain acknowledgement. |
| 1. **Stop every writer (A4-1).** Close admission, drain spawn attempts, |
| drain in-flight timer deliveries (moves §9.4), then stop the host's |
| @@ -502,91 +602,156 @@ these in order, journaling each: |
| this host, identified by the agent's pid and the replicator's own |
| descriptors (A5-1). Any other holder fails the step. From here nothing |
| but the replicator can touch the database, and nothing at all can |
| - change files or wake state. |
| + change files or wake state. `internal/stopevidence` has no check of |
| + this form today: `Collect` requires the replicators closed. Rev 5 |
| + requests a writers-stopped check that allows exactly these |
| + descriptors (L10). |
| 2. **Flush the database:** final checkpoint and `SyncAndWait`, then |
| - record the **final TXID** `N`, the `MaxTXID` of the newest level-0 |
| - file (moves rev 7.6 §5.1 step 1). |
| + record the **final TXID** `N`: the stopped database's own position. |
| + The step passes only once a listing shows the newest level-0 file's |
| + `MaxTXID` equal to `N` (moves §5.1 step 1). A sync error is not |
| + swallowed (claim P6-D4's warning about `Replicator.Close`); it retries, |
| + and a flush that never reaches `N` goes to `stuck`. |
| 3. **Publish every pending file and wake-state mutation**, whatever the |
| app's `files_durability`, and record the manifest's final `seq`. |
| 4. **Finish stop evidence:** close the replicator (`DB.Close`) and the |
| - file publisher, then run claim §6.1's **unrestricted** check: no |
| - holder at all of the database, WAL or SHM files in `/proc/*/fd`, and |
| - every other condition. Only now is the release complete. |
| + file publisher, then root completes claim §6.1's **unrestricted** |
| + check (`stopevidence.Collect`, P6-D1): no holder at all of the |
| + database, WAL or SHM files in `/proc/*/fd`, and every other |
| + condition. The receipt is published (P5-D3) with its `evidence_at`. |
| + Only now is the release complete. |
| Because step 1 empties the process tree first, the positions recorded in |
| -steps 2 and 3 really are final. |
| +steps 2 and 3 really are final. The session ends uncertified, as every |
| +session does under P6-D4; coverage (below) is what keeps its writes. |
| The journal entry is `{"released_by": "<box>", "generation": "<G>", |
| -"final_txid": N, "manifest_seq": S}`, and `pinning` refuses to pin less |
| -than it. |
| +"session": S, "final_txid": N, "manifest_seq": M, "receipt": "<key>", |
| +"receipt_sha256": "…"}`, and `pinning` refuses to pin less than it. |
| -**The pinned recovery set (A1-2).** In `pinning`, the placer lists |
| -`generation_from` and journals the exact object versions that make up the |
| -database (latest snapshot and the LTX files after it) and the manifest |
| -version, files and timers included. |
| +**The pinned recovery set (A1-2).** In `pinning`, a worker runs |
| +`genselect.Select` against the claim observation of its `pinning` CAS: |
| +`holder.generation`, the unfinished run in `holder.admissions` and the |
| +marker, all in this region's bucket (claim §6.2a, §6.4a item 3). It |
| +journals the plan's exact object versions (the snapshot and every LTX |
| +file after it, contiguous) and the exact version of the files manifest |
| +of the same generation, files and timers included. |
| - **After a release sequence** (drain, rebalance, probe failure): the set |
| must reach the journaled `final_txid` and `manifest_seq`. If the listing |
| has not caught up, `pinning` waits; it never pins less than what the |
| releasing box flushed. |
| -- **Failover:** the placer pins after the fence plus 30 seconds, so |
| - requests S3 had already accepted from the terminated box have |
| - completed. Whatever did not land is the H3 loss window. |
| - |
| -**Populating the new generation (A1-2, A2-1).** `installing` restores |
| -from the pinned versions, never from "the current generation", and |
| -writes `G'`'s **replication baseline** before `installed`, exactly as a |
| -move's install does (moves rev 7.6 §5.1 steps 2 and 7): |
| - |
| -- a full snapshot of the restored database at its final TXID `N`; |
| -- the **level-0 anchor**, one level-0 LTX file `[N, N]`. The pinned |
| - Litestream derives a box's replication position from the newest remote |
| - level-0 file. A generation holding a snapshot and nothing at level 0 |
| - restarts at TXID 1, and a cold restore from the snapshot then skips |
| - every later write (moves rev 7.6 R5-1); |
| -- a manifest naming the files, env and wake state; |
| -- verification of all three by a fresh read before the `installed` CAS. |
| +- **Failover:** the worker pins after the fence's `observed_at` plus 30 |
| + seconds, so requests S3 had already accepted from the terminated box |
| + have completed. Whatever did not land is the H3 loss window. This is a |
| + loss bound, not a safety bound: the pin names exact versions, and a |
| + later PUT can only add a version nothing reads. |
| + |
| +**Building the prepared generation (A1-2, A2-1).** In `installing`, `to` |
| +builds `P` exactly as a move's landing builds its generation (moves §7.6 |
| +`building` and `published`, L16), with the pinned versions as its source |
| +in place of a staged replica: |
| + |
| +- under the replica lease on `holder.location` (claim C3), restore the |
| + pinned versions with the planner, verifying each file's checksum, |
| + contiguity, the final position `N` and `PRAGMA integrity_check`; |
| +- write the build intent, then the full **snapshot** `[1, N]`, then the |
| + **level-0 anchor** `[N, N]`, each acknowledged, then the completed |
| + **install manifest** with each `versionId`, `N`, checksums and the |
| + database's SHA-256. The pinned Litestream derives a box's replication |
| + position from the newest remote level-0 file. A generation holding a |
| + snapshot and nothing at level 0 restarts at TXID 1, and a cold restore |
| + 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). |
| + |
| +**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); |
| +- **`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); |
| +- **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`. |
| + |
| +**A host with nothing replicated.** If the pin's `N` is 0 (the app never |
| +committed a transaction), there is no prepared generation: claim |
| +P6-D3 forbids a `[1, 0]` snapshot or a `[0, 0]` anchor. `installing` |
| +then builds only the files manifest and local state, and the first |
| +probe attempt is an ordinary cold admission into a fresh, empty |
| +generation (claim §6.4a, "Empty bootstrap"). |
| The test is the moves one, run for relocation: relocate, write, |
| replicate, terminate the new executor, cold-restore, and check that every |
| -replicated write is present (`internal/sim/litestreamfacts`). Moves §5.1 forbids restoring another |
| -generation **as** the installed one; relocation instead builds `G'` from |
| -a pinned set and only then makes `G'` installed, which is the move |
| -landing's shape (moves §7.6) within one bucket. Late writes from the old |
| -installation land in `generation_from`, which nothing admitted reads |
| -again (claim C3a within one incarnation, moves §5.1). |
| +replicated write is present (`internal/sim/litestreamfacts`). Late writes |
| +from the old installation land in `generation_from`, which nothing |
| +admitted reads again, and which publication retires. |
| **The no-reinstall boundary (A1-3).** Once the `installed` CAS succeeds: |
| -- nobody restores `G'` again: replay from `installed` or `probing` goes |
| +- nobody builds `P` again: replay from `installed` or `probing` goes |
| forward (probe, then `serving`), never back to `installing`; |
| -- `to` writes its installation-complete marker (§5.5) before its probe |
| - spawns; |
| -- a worker that paused before `installed` and resumes revalidates the |
| - phase under the host guard and stops. |
| +- `P` is opened at most once. After its first admission, every restart |
| + takes a fresh generation from it; |
| +- a worker or `to` that paused and resumes revalidates the phase under |
| + the host guard, with the stamp it captured, and stops. |
| -Replay from `installing` (before `installed`) discards `to`'s partial |
| -local state (retire-artifacts, claim §6.4) and installs again from the |
| -same pinned set. Nothing has served from `G'` yet, so nothing is lost. |
| +Replay from `installing` (before `installed`) parks `to`'s partial local |
| +state with `retire-artifacts` (claim §6.4), and rebuilds `P` from the |
| +same pin. The build intent makes the bytes identical (moves §7.6). |
| +Nothing has opened `P` yet, so nothing is lost. |
| -**Exclusion.** Only `to` installs, holding the replica lease on `G'`; |
| -the placer is the only claim writer for this op, under its election |
| -lease, and every CAS is conditional on `op.id` and the phase. |
| +**Exclusion.** Only `op.exec` installs or admits, and only `to` is |
| +`op.exec` from `installing` on. Every CAS is conditional on `op.id` and |
| +the phase. `to` holds the replica lease while it builds. |
| **Replacing a participant (A2-2, A2-7).** The claim allows a new |
| -operation only from `serving`, so relocation defines its own exits |
| -(K2). Each is one CAS, conditional on `op.id` and the phase, that cites |
| -its evidence and names the replacement `to'`. Every rule that applied to |
| -the phase still applies, and `to'` writes its execution lease before |
| -the op leaves that phase. |
| +operation only from `serving`, so relocation defines its own exits (L1). |
| +Each is one CAS, conditional on `op.id` and the phase, that cites its |
| +evidence and names the replacement `to'`. A CAS that moves `op.exec` |
| +writes the stamp under R6-4. Every rule that applied to the phase still |
| +applies, and `to'` acknowledges its execution lease before the op leaves |
| +that phase. |
| | `to` stops being usable | Evidence | The op resumes at | |
| |---|---|---| |
| | 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` before the pin is journaled | `to`'s termination fence | `pinning` | |
| -| terminated after the pin is journaled, before `installed` | `to`'s termination fence | `installing` from the journaled pin, into a fresh generation `G''`. The old `to`'s partial `G'` objects are abandoned | |
| -| terminated at or after `installed` | `to`'s termination fence | a nested failover: `generation_from` becomes `G'` (which holds `to`'s baseline and anything it replicated), from `pinning` | |
| -| alive, but its probe fails or misses the **probe deadline** (10 minutes) | `to`'s release sequence, in `probe-failed` | a nested relocation from `G'`, 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 | |
| +| 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 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. |
| @@ -597,229 +762,367 @@ the op leaves that phase. |
| says why. |
| - A `probe-failed` target that cannot finish its release sequence (its |
| cgroup won't drain, claim §6.1, or its flush fails) also goes to |
| - `stuck`. Nothing moves away from |
| - a live box without its stop evidence. |
| + `stuck`. Nothing moves away from a live box without its stop evidence. |
| + |
| +**What a relocation leaves behind.** `generation_from` and the |
| +predecessor run are retired by `PublishGeneration`'s `park-generation` |
| +chores in this region. An abandoned `P` is parked by the at-rest sweep. |
| +`from`'s local state is retired by its `park-local` chore, or discharged |
| +with the fence (§5.9). Relocation installs into the region's host |
| +namespace, so it waits for `quiet_after[region]` like a push (claim |
| +§6.2a); for a live location that time has normally passed. |
| **Cost of a drain:** one cut of a few seconds, like a move's (M2), with |
| -no cross-region copy. Failover's gap is detection plus restore (spec 3). |
| +no cross-region copy, plus one snapshot and anchor of the database in |
| +the region (moves §7.3's T11 arithmetic, same region). Failover's gap is |
| +detection plus restore (spec 3). |
| ### 5.9 The termination fence |
| -A termination fence is written into the operation's journal: |
| +**What counts (A1-1, A1-11, H10).** A box is fenced by termination only |
| +when one fresh `DescribeInstances` reply, made by a regional worker in |
| +the box's AWS region with its own read-only rights, shows all of: |
| + |
| +- the instance id, account (`OwnerId`) and region equal to the box's |
| + roster entry's `cloud` (H11, HA7); |
| +- state exactly `terminated`; |
| +- every EBS volume in `BlockDeviceMappings` with `DeleteOnTermination: |
| + true`, and at least one mapping present. |
| + |
| +If any volume was not `DeleteOnTermination`, or the reply carries no |
| +mappings, the box is not fenced automatically; the operator path |
| +applies (§11 question 2 is about the second case). |
| + |
| +- `stopped` and `stopping` do not count, because a stopped box can start |
| + again with its disk. A missing instance, `InvalidInstanceID.NotFound`, |
| + an API error, a partial reply, or a lookup in another account or |
| + region is **never** evidence. AWS keeps a terminated instance visible |
| + in `DescribeInstances` for typically under an hour, so absence proves |
| + nothing. All of those go to the operator: `carlos ops host resolve |
| + --declare-lost`, with claim §7.3 D2's binding, attestation and |
| + challenge as written (H10). |
| +- The worker reads the state itself. It never trusts a box's own report, |
| + an edges file, or anything a box publishes about itself. |
| + |
| +**The fence record.** The worker writes one record per terminated |
| +enrollment, create-only (L4): |
| + |
| + control/box-fences/<enrollment>.json |
| ```json |
| -{"box": "i-0abc…", "instance_id": "i-0abc…", "account": "123…", |
| - "aws_region": "eu-west-1", "state": "terminated", |
| - "observed_at": "<RFC3339>", "via": "describe" | "event", |
| - "event_id": "<EventBridge event id, when via=event>"} |
| +{"v": 1, "box": "<box reference>", "enrollment": "<enrollment>", |
| + "cloud": {"provider": "aws", "account": "123…", |
| + "region": "eu-west-1", "instance_id": "i-0abc…"}, |
| + "state": "terminated", |
| + "block_devices": [{"device": "/dev/xvda", "volume_id": "vol-…", |
| + "delete_on_termination": true}], |
| + "observed_at": "<RFC3339>", "request_id": "<EC2 request id>", |
| + "trigger": "describe" | "event" | "activity", |
| + "event_id": "<EventBridge event id, when trigger=event>", |
| + "written_by": {"box": "<reference>", "enrollment": "<enrollment>"}} |
| ``` |
| -**What counts (A1-1, A1-11).** |
| - |
| -- `instance_id`, `account` and `aws_region` must equal the executor's |
| - verified box record (§5.1, HA7). |
| -- Only `terminated`. `stopped` and `stopping` do not, because a stopped |
| - box can start again with its disk; those need the operator's |
| - `carlos ops host resolve --declare-lost` (claim §7.2). |
| -- A missing instance, `InvalidInstanceID.NotFound`, an API error, or a |
| - lookup in another account or region is **never** evidence. AWS keeps a |
| - terminated instance visible in `DescribeInstances` for typically under |
| - an hour, so absence proves nothing. |
| - |
| -**Three ways to observe it.** |
| - |
| -1. **The durable event.** An EventBridge rule in each region sends EC2 |
| - "Instance State-change Notification" events with `state: terminated` |
| - for the deployment's account to an SQS queue (14-day retention). The |
| - queue's policy accepts messages only from that rule's ARN. The placer |
| - reads the queue; an event whose `account`, `region` and instance id |
| - match a box record is evidence, `via: "event"`. |
| -2. **A direct read.** `DescribeInstances` for the instance id, in the |
| - region, returning `terminated` with the deployment's `OwnerId`, |
| - `via: "describe"`. |
| +It is not AWS-signed: like claim D2's operator observation, it is the |
| +worker's authenticated report of a cloud read, made with rights the |
| +console does not hold. Every relocation and claim CAS that relies on it |
| +cites its key and SHA-256 (`evidence_refs`, claim P5-D3). |
| + |
| +**Three triggers, one proof.** The record always comes from a describe, |
| +because only a describe carries `BlockDeviceMappings`. The other two |
| +sources say when to look: |
| + |
| +1. **The durable event.** An EventBridge rule sends EC2 "Instance |
| + State-change Notification" events with `state: terminated` for the |
| + deployment's account to an SQS queue (14-day retention). Plan 1a |
| + built one queue per box. The queue's policy accepts messages only from |
| + that rule's ARN. An event whose account, region and instance id match |
| + a roster entry makes the worker describe the instance at once. |
| +2. **A direct read.** A worker describes the region's enrolled instances |
| + on its sweep. |
| 3. **The group's own history.** For a box launched by an Auto Scaling |
| - group, `DescribeScalingActivities` for the region's group: an |
| - activity terminating that instance id with status `Successful`, |
| - `via: "activity"`. AWS keeps activity history for six weeks. Spec 4 |
| - verifies on dev that `Successful` is recorded only once the instance |
| - has reached `terminated`; until it is verified, this method is off. |
| + group, a `DescribeScalingActivities` activity terminating that |
| + instance id with status `Successful` makes the worker describe it. |
| + Spec 4 verifies this source on dev; until then it is off. |
| **What is not guaranteed (A2-4).** EC2 delivers state-change events to |
| -EventBridge on a best-effort basis, so the queue holds only events that |
| -arrived. When all three methods come back empty, there is **no |
| -automatic recovery**. The placer marks the box's hosts |
| -`awaiting-evidence`, alerts at once, and keeps retrying the three reads. |
| -The only way out is claim §7.2 unchanged: an operator's |
| -`carlos ops host resolve --declare-lost`, which needs the instance's |
| -terminal state (`stopped` or `terminated`) read through the cloud API |
| -with the operator's own credentials. A CloudTrail `TerminateInstances` |
| -record is **not** evidence (A3-1): it proves a request was made, and an |
| -instance can stay `shutting-down` after a successful one. The console |
| -may show it to help an operator find the instance, nothing more. If no |
| -terminal state can be read, the hosts stay `awaiting-evidence`. That is |
| -a liveness gap, accepted: it needs a lost event, a gap of more than an |
| -hour before anyone looks, and a group whose history doesn't show the |
| -termination. Absence is never evidence, at any point. Spec 3 sets how long the placer retries |
| -before alerting, and owns the loop that reads the queue and history. |
| - |
| -- The placer reads the state itself. It never trusts a box's own |
| - report, an edges file, or a box record's claims about liveness. |
| -- A terminated instance cannot run again, so a terminated box needs no |
| - quarantine or `--readmit`. Its id still goes on `lost`, so any later |
| - file naming it is refused. |
| -- The root volume must have `DeleteOnTermination`, so no disk outlives the |
| - box (§6). |
| +EventBridge on a best-effort basis, and a terminated instance leaves |
| +`DescribeInstances` after about an hour. When no describe returns the |
| +proof, there is **no automatic recovery**. The placer marks the box's |
| +hosts `awaiting-evidence`, alerts at once, and keeps retrying. The only |
| +way out is the operator's `--declare-lost` (claim §7.3 D2). A CloudTrail |
| +`TerminateInstances` record is **not** evidence (A3-1): it proves a |
| +request was made, and an instance can stay `shutting-down` after a |
| +successful one. The console may show it to help an operator find the |
| +instance, nothing more. If no terminal state can be read, the hosts stay |
| +`awaiting-evidence`. That is a liveness gap, accepted. Absence is never |
| +evidence, at any point. Spec 3 sets how long the placer retries before |
| +alerting, and owns the loop that reads the queue and history. |
| + |
| +**What the fence does (L2 to L5).** |
| + |
| +- **It is a lost-box fence** (claim C5, §4) for every host whose claim |
| + names the box. A relocation started on it may move `op.exec` off the |
| + box. |
| +- **The box goes on each affected claim's `lost` list**, with the fence's |
| + reference. A terminated instance cannot run again, so it needs no |
| + quarantine and no `--readmit` (H1). Its reference never names a |
| + working box again. |
| +- **Its local chores are discharged.** Every volume was deleted with the |
| + instance (H10), so its `park-local` chores are discharged in the same |
| + CAS, by `CompleteObligation` semantics, without retirement. That is |
| + claim D5's destroyed-local exception, now proven (L3). Regional chores |
| + are reassigned to `region:<r>`, keeping their ids, operations and |
| + scopes (D5). Remote scopes it justifies take the fence's `observed_at` |
| + as their original evidence time (claim P5-D3, D5). |
| +- **The roster follows later.** The console moves the box's roster entry |
| + 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. |
| +- **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`: |
| + wake state was published continuously, up to H3's window. |
| ## 6. Infrastructure and IAM (A1-12) |
| -- **Box role** gains `ec2:DescribeInstances` with a condition on |
| - `ec2:Region`, and `autoscaling:DescribeScalingActivities` with a |
| - condition on `aws:RequestedRegion`, both equal to the box's region, plus |
| - SQS `ReceiveMessage`/`DeleteMessage` on its region's termination |
| - queue. Describe actions do not support resource-tag conditions, so |
| - deployment membership is **not** an IAM property: the verifier checks |
| - `OwnerId`, the region and the box record (§5.9). No `ec2:Stop*`, |
| - `Terminate*` or `Modify*`, and no Auto Scaling write rights. |
| +- **Box role** keeps plan 1a's `ec2:DescribeInstances` with a condition |
| + on `ec2:Region`, and `autoscaling:DescribeScalingActivities` with a |
| + condition on `aws:RequestedRegion`, both equal to the box's region, |
| + plus SQS `ReceiveMessage`/`DeleteMessage` on its termination queue. |
| + Describe actions do not support resource-tag conditions, so deployment |
| + membership is **not** an IAM property: the verifier checks `OwnerId`, |
| + the region and the roster entry (§5.9). No `ec2:Stop*`, `Terminate*` or |
| + `Modify*`, and no Auto Scaling write rights. It also needs the claim's |
| + own grants: `s3:ListBucketVersions`, `s3:GetObjectVersion` and |
| + `s3:DeleteObjectVersion` on its region's data bucket (claim §6.2a), |
| + which `genselect` and the parker use. |
| - **Console role:** unchanged, no EC2 rights. |
| - **S3 (A2-6).** Two kinds of write, kept apart: |
| - - **Create-only** (`If-None-Match: *`): content objects and snapshots, |
| - `instances/<host>/*/files/*/objects/` and `…/snapshots/`, in the |
| - region's data bucket; box records and binds, `control/boxes/`, in the |
| - control bucket. |
| + - **Create-only** (`If-None-Match: *`): content objects, |
| + `instances/<host>/*/files/*/objects/`, in the region's data bucket; |
| + box identity records, `control/boxes/`, and fence records, |
| + `control/box-fences/`, in the control bucket. |
| - **Conditional replace** (`If-Match`, or `If-None-Match: *` for the |
| first write): `instances/<host>/*/files/*/manifest.json` only. |
| S3 enforces both in policy (A3-3): the data bucket denies `PutObject` |
| - on `…/objects/*` and `…/snapshots/*` without `s3:if-none-match: *`, and |
| - on `…/manifest.json` without either `s3:if-match` or |
| + on `…/objects/*` without `s3:if-none-match: *`, and on |
| + `…/manifest.json` without either `s3:if-match` or |
| `s3:if-none-match: *`. Each Deny carries |
| `s3:ObjectCreationOperation: true`, so it applies to `PutObject`, |
| `CopyObject` and `CompleteMultipartUpload`, never to |
| `CreateMultipartUpload` or `UploadPart`, which can't carry the headers |
| - (A4-2). A large snapshot is uploaded in parts and completed with |
| - `If-None-Match: *`; the `control/boxes/` rules in §5.1 carry the same |
| - qualifier. The one writer, `hostfiles.Publish`, sends the |
| - headers and is tested against both refusals, and for a multipart |
| - snapshot that completes with the header and one refused without it. The control bucket's |
| - `control/boxes/` rules are in §5.1. Each Deny is scoped to its prefix |
| - only: a broad Deny on this bucket once broke every lease release. Plus |
| - `s3:GetObjectVersion` on the files prefix, and the claim CAS rights the |
| - claim spec lists. |
| -- **Tofu:** the `region-host` module gains `delete_on_termination = true` |
| - on the root volume, the EventBridge rule and the queue. Moving to an |
| - Auto Scaling group is spec 4; spec 1 runs on today's fixed boxes and on |
| - a second fixed box added by hand. |
| + (A4-2). The one writer, `hostfiles.Publish`, sends the headers and is |
| + tested against both refusals. The control bucket denies `PutObject` |
| + without `s3:if-none-match: *`, and `DeleteObject` and |
| + `DeleteObjectVersion`, on `control/boxes/*` (plan 1a's policy) and on |
| + `control/box-fences/*`. Each Deny is scoped to its prefix only: a |
| + broad Deny on this bucket once broke every lease release. The |
| + `*.bind.json` statement plan 1a added is no longer needed (§19). Data |
| + retirement under `files/` is by exact version, through the parker, |
| + like every other retirement (claim C3). |
| +- **Tofu:** the `region-host` module has `delete_on_termination = true` |
| + on the root volume (plan 1a), and must have it on every other EBS |
| + volume a box attaches, since H10 refuses to fence a box with one that |
| + survives. Moving to an Auto Scaling group is spec 4; spec 1 runs on |
| + today's fixed boxes and on a second fixed box added by hand. |
| ## 7. Failure modes |
| | 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; it wins the placer election, reads the termination event from the queue, and relocates every host to itself. | |
| +| 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. | |
| +| 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. | |
| -| A forged or confused edges file names a terminated instance | ignored: identity comes only from the AWS-signed box record (HA7). | |
| -| Placer dies mid-relocation | the next elected placer replays from the claim (§5.8). | |
| -| Target dies after installing | a nested failover from `G'` (§5.8). | |
| +| 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). | |
| +| 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). | |
| +| Target dies after its probe admitted | a nested failover from `holder.generation` (§5.8). | |
| | Target alive but its probe keeps failing | stop evidence, then a nested relocation to another box; after two replacements, `stuck` and an alert (§5.8). | |
| | No termination evidence can be found | `awaiting-evidence`, an alert, and the operator's `--declare-lost` (§5.9). | |
| -| Two files claim a host | the higher stamp wins (HA4). Two unstamped entries alert. | |
| +| Two files claim a host | the higher stamp wins (HA4). Two at the same stamp are reported (§18). | |
| | Claim read fails on reconcile | keep existing rows, mint none (C6). | |
| | `snapshot` app fails over | files lose up to the interval; the database loses about a second. Stated on Overview. | |
| | A unit- or socket-backed route's box dies | not relocated (A1-8). It comes back with its box, or by the HA ladder's restore runbook, as today. | |
| ## 8. Rollout (A1-9) |
| -1. **Prerequisite:** the claim and its floor are live (claim §10), and |
| - every host in the region has a claim (adopted under claim §9). |
| +1. **Prerequisite:** the claim and its floor are live (claim §10), the |
| + roster exists with every box enrolled from `internal/boxid` evidence |
| + (§5.1), and every host in the region has a claim (adopted under claim |
| + §9 and P5-D2, which requires exactly one proven installation owner). |
| 2. Ship §5 dormant behind `CARLOS_REGION_HA`, with capability |
| `region-ha-1` published in each box's edges file once enabled. With one |
| box per region, every rule is a no-op; ship it to all four regions and |
| watch. |
| 3. **The multi-box gate.** A second box may join a region only when: |
| - - every relocatable host there has a claim (K5); |
| + - every relocatable host there has a claim; |
| - every box in the region, and every edge in the deployment, publishes |
| `region-ha-1` and runs at or above the HA floor; |
| - - every box in the region has a verified box record; |
| - - the region's termination queue exists. |
| + - every box in the region is `active` in the roster with a proven |
| + `cloud`; |
| + - the region's termination queues exist, and every attached EBS volume |
| + has `DeleteOnTermination`. |
| - The HA floor joins the protocol floor (claim §10): every path that |
| + The HA floor joins the protocol floor (claim §10, D1): every path that |
| restores an executable (system apply, rollback, boot recovery from |
| `carlos.prev`) refuses a binary below it once a region has two boxes. |
| The gate is checked in `carlos system`, in the console's box list, and |
| by each box at start: a box that finds a second box in its region |
| without the gate satisfied refuses to mint relocatable rows and |
| alerts. |
| -4. Rehearse on dev with two fixed boxes: create spreads across both; |
| +4. **Relocation by drain** stays off until the claim accepts L9; failover |
| + relocation needs L1 to L6. |
| +5. Rehearse on dev with two fixed boxes: create spreads across both; |
| drain one (§5.8) with `moveprobe` shaped like ips (moves §14.4), |
| including timers and a `continuous` files app; terminate one by hand |
| - and run the spec 3 placer manually. |
| -5. Then specs 2, 3, 4. |
| + and run the spec 3 placer manually. On the same dev box, record what |
| + `DescribeInstances` returns for `BlockDeviceMappings` once an instance |
| + is `terminated` (§11 question 2). |
| +6. Then specs 2, 3, 4. |
| ## 9. Changes requested of the claim and moves specs |
| -For platform-1e (claim) and platform-d8 (moves). Each is needed before |
| -those specs finalise a one-box-per-region assumption. |
| - |
| -| # | Spec | Change | |
| -|---|---|---| |
| -| K1 | claim §5.1 step 4 | `exec` is the box `choose()` picks among the region's eligible boxes, not "the record region's box". | |
| -| K2 | claim §4, §8 | New op kind `relocating` (§5.8): same region, from `serving`, with its phase table, admission and rows per phase, the `installed` no-reinstall boundary, the phase-specific replacement table (termination and probe failure), the two-replacement bound and `stuck`, and the release sequence (stop every application writer first, allowing only the box's own replicator handles; then journal the final TXID and manifest `seq`; then close the replicator and run the unrestricted holder check; for the source and for a failed target). Recovery owner: the region placer. Excludes moves and placements, as `placing` does. | |
| -| K3 | claim §7.2, CC5 | A **termination fence** (§5.9), bound to a verified box record, is a lost-box fence, written automatically by the region placer. `terminated` only; `stopped` stays operator-only. No `--readmit` for a terminated box; it still goes on `lost`. The operator path keeps §7.2's terminal-state rule: a CloudTrail request record never substitutes for it. | |
| -| K4 | claim §8 | Region placers (boxes) need claim CAS rights for `relocating` and for removing a terminated box's obligations. | |
| -| K5 | claim §9 | Adoption names "the record region's serving box". With several boxes that is ambiguous, so adoption must finish while the region has one box (§8 step 3). | |
| -| K6 | claim §2 | Box names in `op.exec`, `participants`, `touched.boxes`, `inventory` and `lost` are box ids bound by `control/boxes/<id>.json` (§5.1). A legacy label is valid only with a verified record. | |
| -| K7 | moves §7.10 | A move whose participant is terminated mid-move takes the termination fence as M16's evidence automatically, under the same before-commit rules. Moves decides what the placer may do there; the placer only supplies the fence. | |
| -| K8 | moves §5.1, §6.1, §6.2, §9.4 | Relocation's install uses moves rev 7.6's replication baseline (snapshot plus level-0 anchor). A box releasing a host flushes all file mutations whatever the plan. The restore contract includes the files manifest and wake state (§5.6, §5.7). Every installation, a move's landing included, publishes a manifest in its new generation so unchanged files stay recoverable after the next failure. The archive at the cut stays. | |
| -| K9 | joint model | `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, a forged box id, a stale warm copy, a crash mid-install, late file uploads and late PUTs after termination. | |
| -| K10 | moves §7.1, §7.3 | A move into a multi-box region names its target **box** in the start CAS, chosen by `choose()`. Readiness, evidence, installation and recovery are bound to that box; replacing it is an explicit fenced transition (A1-10). | |
| -| K11 | claim C4, §8 | Reconcile's three row cases (§5.3): held participant rows, dropping a former executor's registry row without touching local state, and the 10-minute sweep as a stated exception to C4 for kept rows. | |
| +For platform-1e (claim rev 6.25, 413b12cb) and platform-d8 (moves rev |
| +7.17, 39904c84). They replace rev 4.2's K1 to K11, which those sessions |
| +never received. "Ruled" means Paul's rulings of 2026-10-06 (H10 to H13, |
| +and amadan #236) decide the direction; the owning session still writes |
| +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, bound to the roster entry's `cloud`, every EBS volume `DeleteOnTermination`, from a describe. Stopped, unclear and vanished boxes keep D2's operator path unchanged. | Ruled (H10) | |
| +| 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`) | A relocation started by a regional worker cannot get a console-created reservation if the console is down. The claim needs a box path. | **Open: needs Paul** (§11 question 1) | |
| +| 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 | |
| +| 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 | |
| +| L13 | claim C3, §6.2a, §6.6 P6-D3 | The files publisher is a second ordinary-replication exception, bound to `{location, generation, session}`. `files/<generation>/` belongs to that generation for the classifier; `park-generation` retires it except objects a live manifest names. | Requested | |
| +| L14 | moves §7.10 | A target terminated mid-move may be taken off the move by the termination fence, as M16's evidence, automatically, under the same before-commit rules. Moves decides what the move does then; the placer only supplies the fence. | Ruled (H10) | |
| +| L15 | moves §7.1, §7.3, §19.1 | A move into a multi-box region names its target **box** in the start CAS, chosen by `choose()`. Readiness, evidence, install and recovery bind to that box; replacing it is an explicit fenced transition (A1-10). | Ruled (H12) | |
| +| 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 | |
| + |
| +**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 |
| +L12. Three are dropped because the claim now satisfies them: |
| + |
| +- **K4** (claim CAS rights for placers): boxes already write claims |
| + (`AdmitWriter`, `PublishGeneration`, chores), and P6-D5 gives |
| + `region:<r>` work to any enrolled worker. L1 names the owner. |
| +- **K5** (adoption only while a region has one box): P5-D2 requires |
| + exactly one proven installation owner and refuses zero or several, |
| + which is the property K5 wanted, whatever the box count. |
| +- **K6** (box names bound by `control/boxes`): claim §2 rev 6.16 makes |
| + references opaque, and H11 makes the roster the binding (L7). |
| + |
| +The disposition tables (§13 to §17) still cite K numbers. They refer to |
| +rev 4.2's table; this mapping says where each now lives. |
| ## 10. Testing |
| -- **Unit:** box record verification (signature, account, region, |
| - `box id == instanceId`, legacy binding); `eligibleBoxes` (one box at a |
| - time, staleness, draining, `lost`, capability); `choose` (spread, |
| - ties); home-map stamp resolution and unstamped entries; HA3's two |
| - checks; the files manifest (immutable objects, tombstones, late |
| - uploads, snapshot boundary, `If-Match` races); timer catch-up; |
| +- **Unit:** enrollment evidence (signature, account, region, the tofu |
| + check for an existing reference, a reference held by another |
| + instance); `eligibleBoxes` (one box at a time, staleness, draining, |
| + roster state, `lost`, an owed `park-local`, capability, the expected |
| + region from the roster); `choose` (spread, fewest hosts, the SHA-256 |
| + tie-break); home-map stamp resolution; the files manifest (immutable |
| + objects, tombstones, late uploads, `If-Match` races, a cold admission's |
| + copied manifest naming the predecessor's objects); timer catch-up; |
| termination-fence acceptance (NotFound, wrong account, wrong region, |
| - stopped, event, describe, activity, all three empty); the legacy bind |
| - (tofu id mismatch, label not in claims); 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 |
| + stopped, a volume without `DeleteOnTermination`, an empty mapping list, |
| + an event with no describe, all sources empty); 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`. |
| - **Relocation:** every row of the replacement table, the |
| - two-replacement bound, `probe-failed` without stop evidence, the |
| - source's rows through every phase, and relocate → write → replicate → |
| - terminate → cold restore with the level-0 anchor; a release with a |
| - live embedded replicator (step 1 passes with only its descriptors, |
| - step 4 passes only after `DB.Close`, any other holder fails step 1). |
| + two-replacement bound, `probe-failed` without stop evidence, both |
| + participants' rows through every phase, the first probe opening `P` |
| + with `KeepStamp` and a restarted probe taking a fresh generation, and |
| + relocate → write → replicate → terminate → cold restore with the |
| + level-0 anchor; a release with a live embedded replicator (step 1 |
| + 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. |
| - **Integration:** two agents against one fake region, one control |
| bucket: create lands on the chosen box only; the other box mints no |
| row; the edge backhauls to the executor; relocate by drain through |
| - every phase with a crash injected at each; a stale local copy on the |
| - old box is not served after it becomes executor again. |
| -- **Joint model (K9)** decides readiness, as it does for the claim and |
| + every phase with a crash injected at each; the old box's local copy is |
| + never served after it becomes executor again (claim R6). |
| +- **Joint model (L18)** decides readiness, as it does for the claim and |
| moves. |
| -- **Rehearsal** (§8 step 4). |
| +- **Rehearsal** (§8 step 5). |
| ## 11. Open questions |
| -None. Rev 4.2's three were ruled as H7, H8 and H9 (2026-10-04). |
| +Rev 4.2's three were ruled as H7, H8 and H9 (2026-10-04). The claim's |
| +current text leaves three for Paul. |
| + |
| +1. **Who reserves the actors for a relocation a box starts (L6).** Claim |
| + D7 has every protected start record a lifecycle reservation, created |
| + by the console with a CAS on the root-signed roster, so a start cannot |
| + race an executable change. A regional worker cannot write the roster, |
| + and #236 wants failover to work when the console's box is the one |
| + that died. Options: |
| + - **(a)** A box path: a relocation's start CAS requires the roster to |
| + show no executable activation reservation for `from` or `to`, and |
| + root's update path refuses to begin an activation on a box that is |
| + a participant of a running operation, read under the host guard. |
| + The serialization moves to the boxes. *Recommended.* |
| + - **(b)** Failover waits for a console to reserve. Simple, but a |
| + console outage stalls every failover, against #236's intent. |
| + - **(c)** A standing per-region reservation the console keeps for |
| + relocations. It blocks executable updates on every box it names for |
| + as long as it is held, so updates would need to release and retake |
| + it. |
| +2. **What if a terminated instance's describe has no block devices.** |
| + H10 reads `DeleteOnTermination` from the same `DescribeInstances` |
| + reply. AWS may return an empty `BlockDeviceMappings` once an instance |
| + is `terminated` (to be confirmed on dev, §8 step 5), and EventBridge's |
| + state-change event carries none. If so, H10 as written never fences. |
| + Options: |
| + - **(a)** Keep H10 as written and accept that failover is manual |
| + whenever the mappings are gone. *Only if dev shows they stay.* |
| + - **(b)** Record each box's mappings from a describe while it is |
| + `running` (at enrollment and on every sweep), deny |
| + `ec2:ModifyInstanceAttribute` and `ec2:AttachVolume` on platform |
| + boxes to every role but the operators', and fence on the terminated |
| + read plus the newest running record. *Recommended if (a) fails.* |
| + - **(c)** Also accept EventBridge's "EBS Volume Notification" |
| + `deleteVolume` result `deleted` for each recorded volume as positive |
| + proof. Best-effort delivery, so it adds to (b) and never replaces it. |
| +3. **How an Auto Scaling box is enrolled (spec 4, not blocking spec 1).** |
| + Claim P3's recommendation is operator-approved enrollment, but H5 |
| + scales out automatically. Options: **(a)** every enrollment is |
| + approved by an operator, so scale-out waits for a person; **(b)** the |
| + console enrolls a box automatically when its `boxid` evidence is in |
| + the deployment's account and region and a regional worker reports the |
| + instance a member of the region's group |
| + (`autoscaling:DescribeAutoScalingInstances`, read-only), and asks an |
| + operator for anything else. *Recommended.* |
| ## 12. Out of scope, for the later specs |
| - **Spec 2:** `headroom` thresholds, soft spread across an app's |
| instances, the `unplaced` state and its alert, rebalancing. |
| -- **Spec 3:** the placer loop: election, reading the termination queue, |
| - relocating every host off a terminated box in bounded parallel, waking |
| - always-on and scheduled instances, the failover gap target. |
| +- **Spec 3:** the placer loop: reading the termination queue, relocating |
| + every host off a terminated box in bounded parallel, waking always-on |
| + and scheduled instances, the failover gap target. |
| - **Spec 4:** the Auto Scaling group per region, launch template and |
| - join without SSM hand steps, ingress for boxes that come and go (a |
| - per-region NLB with fixed EIPs, or an EIP pool claimed at boot), |
| - HTTP-01 certificates, steering, scale-out on headroom, scale-in by |
| - drain (§5.8), per-region minimum (H5). |
| + join without SSM hand steps, enrollment of new boxes (§11 question 3), |
| + ingress for boxes that come and go (a per-region NLB with fixed EIPs, |
| + or an EIP pool claimed at boot), HTTP-01 certificates, steering, |
| + scale-out on headroom, scale-in by drain (§5.8), per-region minimum |
| + (H5). |
| - **Not planned:** relocating unit- and socket-backed routes (the |
| console and platform services). Their HA is the HA ladder's, separately. |
| @@ -924,3 +1227,61 @@ Two limits to lift in plan 1b: a file over 64 MiB is recorded in the |
| manifest as skipped and is not published (the store takes a whole body in |
| memory), and the third termination-evidence source (Auto Scaling activity |
| history) is not built until spec 4 verifies it. |
| + |
| +## 19. What rev 5 means for plan 1a (2026-10-06) |
| + |
| +Plan 1a (aadb860) was built against rev 4.2. Rev 5 keeps most of it and |
| +changes how some of it is used. Plan 1b is written against rev 5. |
| + |
| +**Superseded.** |
| + |
| +- **`control/boxes/<id>.json` as authority (H11).** `boxid.Load` and |
| + `boxid.Verifier` are now used once, at enrollment, and their result is |
| + stored in the roster entry's `cloud`. `eligibleBoxes` takes its |
| + verified identities from the roster, not from loading records. The |
| + record object stays as the enrollment evidence, create-only, and grants |
| + nothing. |
| +- **The operator bind** (`Bind`, `control/boxes/<label>.bind.json`, `carlos ops |
| + box bind`, plan 1b item 13). Its tofu check moves into enrollment |
| + (§5.1). The bucket policy's bind statement is harmless and can go at |
| + the next apply. |
| +- **`WarmCopyTrusted` and the activator's warm path (H13).** Nothing skips |
| + a restore, so plan 1b item 4 is dropped. The `InstallMarker` type may |
| + serve as relocation's last local install journal entry, as moves' |
| + `.files-gen` does, or be removed; nothing reads it to trust a local |
| + database. |
| +- **`chooseBox`'s last tie-break.** The lowest box id becomes P5-D2's |
| + greatest SHA-256 of `host + "\x00" + reference` (H12). The first two |
| + rules stand. |
| +- **`termfence`'s binding and source.** `Verify` binds to a |
| + `boxid.Identity`; it must bind to the roster entry's `cloud`, require |
| + every block device `DeleteOnTermination` and at least one mapping, and |
| + produce §5.9's record. `FromEvent` becomes a trigger: an event alone |
| + never fences (H10). |
| + |
| +**Stays as built.** |
| + |
| +- `internal/boxid`'s record, signature check and embedded regional |
| + certificates (verified on the dev box), as enrollment evidence. |
| +- The edges file's `box_id` (the label, which is the reference under |
| + #212 option B), `capabilities`, `draining` and `headroom`. |
| +- The home map settling two claims by installation stamp, and reporting |
| + equal stamps. |
| +- `eligibleBoxes`' one-box-at-a-time predicate, with the roster as its |
| + source. |
| +- `internal/hostfiles`: the ordered manifest, immutable hashed objects, |
| + `SyncDir`, the continuous publisher, the watcher, restore and wake |
| + state. Plan 1b adds the generation binding, the copied manifest at |
| + each cold admission, and retirement by the claim (§5.6). |
| +- `registry.ReplaceTimers`. |
| +- The infrastructure: one termination queue per box, root volumes deleted |
| + on termination, the create-only `control/boxes/` policy. The fence |
| + prefix `control/box-fences/` needs the same create-only, never-deleted |
| + statements (§6). |
| + |
| +**Plan 1b's list, revised.** Items 1, 3, 5, 9 and 10 stand, read against |
| +rev 5 (item 10's expected region now comes from the roster). Item 2 |
| +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. |