carlos / platform Public

spec: host lifecycle claim rev 3, the joint model's required changes CC1-CC13

Late-completion horizon and per-region quiet_after for the replication
exception; version-keyed park copies that accumulate; the parker's own lock
excluded everywhere; discovery parks at adoption through one routine; purge
adopts first and its reservation is its journal; declare-lost needs compute
fencing only; self-fence ends on a fresh read; stale holds released; no op
over a pending chore; quarantined boxes retire their own state; probes
render held config.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 8b5bccb2a97f8122938ad56bfc94631a335c332a parent cf3a786
1 file changed, +114 −23
  • docs/superpowers/specs/2026-10-02-host-lifecycle-claim-design.md +114 −23

CI cancelled — superseded by a newer push — run details

diff --git a/docs/superpowers/specs/2026-10-02-host-lifecycle-claim-design.md b/docs/superpowers/specs/2026-10-02-host-lifecycle-claim-design.md
index 611af3cbb..3e6fb5948 100644
--- a/docs/superpowers/specs/2026-10-02-host-lifecycle-claim-design.md
+++ b/docs/superpowers/specs/2026-10-02-host-lifecycle-claim-design.md
@@ -1,6 +1,6 @@
# The host lifecycle claim
-Design, 2026-10-02, **rev 2**. A small primitive cited by:
+Design, 2026-10-02, **rev 3**. A small primitive cited by:
- **#199**, deleting an instance and parking its data:
`2026-10-02-replica-ownership-design.md`, which this spec supersedes
@@ -17,6 +17,10 @@ I-7, adopts its replacement for D4, and answers N1, N3 to N10, N12 and the
carried-forward R3-12. The moves-only findings (N2's move side, N11, N13,
N14, N15) are the moves spec's. §12 maps every finding to its section.
+**Rev 3** folds in CC1 to CC13, the claim changes required by the joint
+executable model (`internal/sim/hostlife/` at 1486345c on
+`design-moves-any-region`, moves spec §19.4). §12 maps them too.
+
The method changes too. The moves session is building an executable
joint model: a deterministic randomized Go simulation covering every
actor, the real IAM split, late S3 completion, crashes, partitions and old
@@ -87,7 +91,8 @@ replaced whole with conditional writes (`If-Match` on the ETag read).
"retirement": "rt_…", "owner": "region:ie"}
],
"lost": [],
- "retired_incarnations": ["<hex>", "…"]
+ "retired_incarnations": ["<hex>", "…"],
+ "quiet_after": {"ie": "<RFC3339>"}
}
```
@@ -120,6 +125,9 @@ Field rules:
- **`retired_incarnations`** keeps the most recent 32 incarnations that
held this host. A create whose incarnation appears there is refused
(R3-12).
+- **`quiet_after`**, one entry per region, is the earliest time a new
+ install may write the host's namespace in that region (§6.2a, CC1).
+ Like `retired_incarnations`, it survives `free`.
## 3. Invariants
@@ -151,8 +159,15 @@ Every destructive storage call names **an exact object version**: a
`key?versionId=`. Never a bare key. Both buckets are versioned
(deployment-buckets/main.tf:23, :28). A request that completes late
therefore touches only the version it named, never a later incarnation's
-object at the same key. That makes late server-side completion harmless
-(J-N1).
+object at the same key, so late completion of the **operation's own
+calls** is harmless (J-N1).
+
+**That does not cover the replication exception (CC1).** A serving
+process's ordinary replication is plain PUTs to LTX names, and those names
+repeat across lineages. A PUT signed before stop evidence can still land
+afterwards, up to S3's 15-minute signature-age limit plus the request's
+own duration. Every retirement therefore waits out a late-completion
+horizon (§6.2a), and an install waits for the region's `quiet_after`.
- **A bare-key delete is forbidden on these prefixes.** On a versioned
bucket it creates a delete marker that would hide a successor's current
@@ -218,6 +233,8 @@ not by itself transfer execution authority: `op.exec` changes only by a
CAS that cites stop evidence or a lost-box fence.
Only one operation runs at a time, and a new one starts from `serving`.
+The start CAS also fails if any open obligation names a region or box the
+new operation will write (CC11). A pending chore there must finish first.
## 5. This spec's operations
@@ -275,8 +292,15 @@ site now uses:
### 5.3 App purge (J-N9)
1. **Reserve the app first.** Write `apps/<acct>/<app>/purge.json`,
- create-only. The create door refuses any create for an app with this
- reservation.
+ create-only, as `{id, phase: "reserved"}`. The create door refuses any
+ create for an app with this reservation. **The reservation is the
+ purge's journal (CC7).** It records the purge's phase, and the
+ recovery pass drives those phases in order: `reserved → adopted →
+ claimed → torn-down → retired → done`. Only the purge's own completion
+ removes it. A host claim going `free` never does.
+1a. **Adopt first (CC6).** Run the one adoption routine (§9) for every
+ record of the app that has no claim, before deciding which hosts the
+ app holds.
2. **CAS every host claim of the app** to `{purging, id}`, from `serving`.
A host under any other operation fails the purge with its reason. The
ones already reserved stay reserved, and the operator retries.
@@ -322,11 +346,17 @@ certified as stopped just because its parent exited. Its host needs the
cleanup operation first. A box without cgroup delegation cannot produce
evidence, so lifecycle operations on its hosts are refused.
+**Probes (CC13).** Every lifecycle probe renders the **held** config and
+materializes the **held** release (`RenderHeld`, `MaterializeHeld`)
+before it spawns. That covers moves, reverts and a future push. A probe
+never uses whatever the ordinary passes last wrote.
+
### 6.2 Parking, by exact version (J-N1, J-N6, J-N7, J-D5)
-**Destination.** Each retirement event has its own destination:
+**Destination.** Each retirement event has its own destination. Within
+it, each copy's key carries its source version (CC2):
- parked/<incarnation>/<host>/<retirement-id>/{instances,moves}/<relative key>
+ parked/<incarnation>/<host>/<retirement-id>/{instances,moves}/<relative key>@<source versionId>
The moves spec's equivalent is `superseded/<op>/<host>/<retirement-id>/…`.
A manifest belongs to exactly one retirement event and is never reused.
@@ -338,10 +368,12 @@ A manifest belongs to exactly one retirement event and is never reused.
(`ListObjectVersions`). From that listing, take every current version
except the live `lock.json`. A coordination lock is never data and
never enters a manifest (J-N6).
-3. **Copy** each current version by `versionId` to the destination.
-4. **Write the manifest last.** It records each entry's source key,
- source version id, size, and destination key and version. A manifest
- that already exists is never rewritten.
+3. **Copy** each current version by `versionId` to its version-keyed
+ destination. **Skip any destination that already exists**, so progress
+ accumulates across retries (CC2).
+4. **Write the manifest only once every copy exists.** It records each
+ entry's source key, source version id, size, and destination key and
+ version. A manifest that already exists is never rewritten.
5. **Delete** every version of each manifest key whose version id is in
the snapshot, **by version id**. That includes noncurrent versions, so
deleting the current one cannot resurrect an older one. A version that
@@ -350,7 +382,28 @@ A manifest belongs to exactly one retirement event and is never reused.
6. **Release** this worker's own `lock.json` version, conditionally, after
the obligation's completion CAS.
7. **Retry:** if the manifest exists, go straight to deletes of its
- recorded versions. If it doesn't, start again from step 2.
+ recorded versions. If it doesn't, re-list (step 2) and continue. The
+ copies already made are kept, because their destinations are keyed by
+ version.
+
+**The parker's own lock (CC3).** Its own `lock.json` is renewed while it
+works, so it is excluded everywhere, not only from the snapshot: from the
+emptiness check, from the horizon re-listing (§6.2a) and from manifest
+completeness.
+
+### 6.2a The late-completion horizon (CC1)
+
+`H` = S3's signature-age limit (15 minutes) plus the longest request the
+platform issues. A retirement acknowledges only after a **verification
+listing taken later than `evidence time + H`**, where evidence time is the
+moment its stop evidence (§6.1) was established. Anything that listing
+finds goes into a further retirement event of the same obligation, with
+its own retirement id and manifest, and the verification repeats.
+
+On acknowledging, the owner CASes the claim's `quiet_after[region]` to at
+least `evidence time + H`. Any install into that region's host namespace
+waits until `quiet_after` has passed: a create's first wake, a move's
+landing, a push. This holds even after `free`.
IAM: the box needs `s3:ListBucketVersions`, `s3:GetObjectVersion` and
`s3:DeleteObjectVersion` on its own data bucket. Today it has only
@@ -432,6 +485,12 @@ While a host is under any operation, each participant box must hold an
- If it cannot renew before `expires`, the box **self-fences**: it kills
the host's process tree using the cleanup-only operation (§6.1) and
refuses admission until it can read the claim again.
+- **A fresh read that shows no operation, or shows this box is not a
+ participant, ends the self-fence (CC9).** The box deletes its local
+ execution-lease state. A stale local file must never keep a box fenced.
+- The lease is for **liveness only**. A whole-VM pause also stops the
+ watchdog, and on resume the app can write before the watchdog fires.
+ The lease is therefore never proof that a box has stopped (CC5).
- An operation does not leave its first phase until every participant has
written its execution lease. That is how the operation knows each
participant has seen it.
@@ -444,20 +503,29 @@ The move form, `carlos move resolve --declare-lost`, delegates to this
command and keeps the move spec's restrictions: before commit only, the
exact participant, the source refused, and loss confirmed explicitly.
-The command:
+The command **requires compute fencing, and nothing else (CC5)**. The
+operator supplies the box's instance id, and the CLI verifies through the
+cloud API, with the operator's own credentials, that the instance is
+`stopped` or `terminated`. The console never holds EC2 rights. Lease
+expiry alone is never accepted.
+
+Two states it must also cover:
-- refuses unless the box's execution lease has passed its expiry plus a
- 60-second margin;
-- or, if the box never wrote one, refuses unless the operator supplies
- compute-fencing evidence that the CLI verifies through the cloud API,
- using the operator's own credentials. The console never holds EC2
- rights. For example, the instance must be `stopped` or `terminated`.
+- **A target that released dirty and was destroyed** before its writes
+ were returned. Its unreturned data is declared lost explicitly, and the
+ confirmation names the operation and phase.
+- **A destroyed box that owes only `park-local` chores**, which is legal
+ after a commit. Declaring it lost removes those chores, because its
+ local disk is gone with it.
Only then does it remove the box's obligations and execution authority.
It adds the box to `lost`, a quarantine list that survives the
operation's release. A box on `lost` is refused admission for any host
until its old state has been retired and an operator removes it with
-`--readmit`.
+`--readmit`. **The returning box retires its own state (CC12).** On
+start, before anything else, it checks the claims of every host in its
+local inventory. For each host where it is on `lost`, it runs local
+retirement (§6.3) under the host guard. Only then can it be readmitted.
**Cost.** This adds one renewal write per participant per 20 seconds,
only while an operation is running. A host that is simply serving holds
@@ -478,8 +546,16 @@ traffic.
Claims are listed only by the recovery pass, never on a hot path.
+**A stale hold (CC10).** Reconcile releases a held row whose `hold_op` is
+not the claim's current operation, on the box the claim names as executor
+of a `serving` host.
+
## 9. Legacy hosts (R2, J-N5)
+**One adoption routine (CC8).** There is a single `claim.Adopt(host)`,
+discovery included. Every adopting actor calls it: reconcile, the doors,
+purge (§5.3 step 1a) and the recovery pass.
+
A host that exists before this release gets a claim from the first actor
that needs one. That claim is written create-only, with:
@@ -488,8 +564,15 @@ that needs one. That claim is written create-only, with:
- `touched.regions` set to the record's region;
- `touched.boxes` set to the box, if it has local state.
-Because `complete` is false, a delete parks **every** region and **every**
-box of the deployment (§5.2 step 3). An adopted host cannot be reused
+**Discovery at adoption (CC4).** The adoption CAS also adds a
+`park-replica` obligation for every deployment region other than the
+record's. Data there cannot be the live lineage. Each region's owner parks
+it (§6.2, §6.2a). `touched.complete` becomes true once they all
+acknowledge, so an adopted host stops carrying another region's residue
+while it serves.
+
+As a fallback, while `complete` is still false, a delete parks **every**
+region and **every** box of the deployment (§5.2 step 3). An adopted host cannot be reused
until all of them have acknowledged, even when they have nothing to park.
A create on a host with no claim and no record goes through the #199
@@ -559,3 +642,11 @@ the model catches it.
| J-N12 | §10 |
| R3-12 | §5.1, §2 `retired_incarnations` |
| J-N2 (moves side), N11, N13, N14, N15 | move-any-app |
+| CC1 | C3, §2 `quiet_after`, §6.2a |
+| CC2, CC3 | §6.2 |
+| CC4, CC8 | §9 |
+| CC5, CC9, CC12 | §7 |
+| CC6, CC7 | §5.3 |
+| CC10 | §8 |
+| CC11 | §4 |
+| CC13 | §6.1 |